TL;DR
- What: Open-source Android companion that forwards notifications and optional SMS to your own webhook.
-
How: Foreground service + headless Flutter engine; Kotlin
NotificationListenerService
→ Dart viaMethodChannel
→ HTTP POST with retries/backoff. - Why: Keep control of your data, survive OEM kills/reboots, customize payloads.
- Repo: github.com/Dragon-Born/message-mirror
Screenshots:
The Problem (and why most apps weren’t right)
I wanted a simple, reliable way to mirror messages from my Android phone to a server I control:
- Forward as JSON to my own webhook, not a vendor cloud.
- Keep working across reboots, process kills, and spotty connectivity.
- Be selective: only from apps I choose; de-duplicate noisy repeats.
- Minimal moving parts, transparent logs, and an escape hatch when Dart isn’t ready yet.
Most existing apps were either:
- Tied to a proprietary backend or ads/analytics I didn’t want.
- Too complex for what’s essentially “listen → format → POST.”
- Not resilient under OEM background restrictions.
- Didn’t let me customize payloads.
So I built Message Mirror: an always-on Android companion that captures notifications (and optional SMS) and forwards them to a configured endpoint.
Repo: GitHub repo
Design Goals
- KISS: Lean code; prefer platform primitives over heavy dependencies.
- Own your data: No third-party servers; you configure your endpoint.
- Resilient delivery: Foreground service + headless Flutter engine; persistent retry queue with backoff.
- Practical UX: Clear logs, queue viewer, and per‑app filtering with icons and search.
- Minimal Kotlin, Flutter UI for ergonomics.
- YAGNI: No over-engineering for hypothetical features.
Architecture (one glance)
┌──────────────┐ onNotificationPosted ┌──────────────────────┐
│ Android OS │ ─────────────────────────────▶ │ MsgNotificationListener│ (NotificationListenerService)
└──────────────┘ └─────────┬────────────┘
(A)│ direct invoke
broadcast lol.arian.notifmirror.NOTIF_EVENT (B)│ broadcast
▼
┌──────────────────────┐
│ NotifEventReceiver │ (BroadcastReceiver)
└─────────┬────────────┘
│ MethodChannel("msg_mirror")
▼
┌──────────────────────┐
│ Dart MessageStream │
│ - build payload │
│ - dedupe │
│ - POST to endpoint │
│ - retry queue/backoff│
└─────────┬────────────┘
│ HTTP
▼
Your Webhook/API
Extras:
- AlwaysOnService: Foreground service that boots a headless Flutter engine (entrypoint: backgroundMain)
- BootReceiver: Starts service after device reboot
- ApiSender: Native HTTP fallback if channel isn’t ready
- LogStore: File-backed logs mirrored to logcat (tag: MsgMirror)
Core Pieces (selected code)
Small, focused excerpts that show the core model (subset of fields shown for brevity).
1) Kotlin: Notification capture with both direct channel delivery and broadcast (plus native HTTP fallback if needed)
// android/app/src/main/kotlin/.../MsgNotificationListener.kt (excerpt)
override fun onNotificationPosted(sbn: StatusBarNotification) {
val n = sbn.notification ?: return
val extras: Bundle = n.extras
val app = sbn.packageName ?: ""
val title = extras.getCharSequence("android.title")?.toString() ?: ""
val text = extras.getCharSequence("android.text")?.toString() ?: ""
val bigText = extras.getCharSequence("android.bigText")?.toString() ?: ""
val lines = extras.getCharSequenceArray("android.textLines")?.joinToString("\n") { it.toString() } ?: ""
val textResolved = if (text.isNotEmpty()) text else if (bigText.isNotEmpty()) bigText else lines
val isOngoing = (n.flags and Notification.FLAG_ONGOING_EVENT) != 0
if (isOngoing) return
// Filter by allowed packages persisted in prefs
val prefs = getSharedPreferences("msg_mirror", MODE_PRIVATE)
val allowed = prefs.getStringSet("allowed_packages", setOf()) ?: setOf()
if (allowed.isNotEmpty() && !allowed.contains(app)) return
// Broadcast with rich extras (subset shown)
val intent = Intent(ACTION).apply {
putExtra("app", app)
putExtra("title", title)
putExtra("text", textResolved)
putExtra("when", sbn.postTime)
putExtra("bigText", bigText)
putExtra("subText", extras.getCharSequence("android.subText")?.toString() ?: "")
putExtra("summaryText", extras.getCharSequence("android.summaryText")?.toString() ?: "")
putExtra("infoText", extras.getCharSequence("android.infoText")?.toString() ?: "")
putExtra("category", n.category ?: "")
putExtra("priority", n.priority)
putExtra("channelId", if (android.os.Build.VERSION.SDK_INT >= 26) n.channelId ?: "" else "")
}
sendBroadcast(intent)
// Also deliver directly via channel if available; else queue + native fallback
val payload = mapOf("app" to app, "title" to title, "text" to textResolved, "when" to sbn.postTime)
val ch = channel
if (ch != null) {
ch.invokeMethod("onNotification", payload)
} else {
synchronized(pendingEvents) { pendingEvents.add(payload) }
ApiSender.send(this, title, textResolved, sbn.postTime)
}
}
2) Kotlin: Headless engine bootstrap in the foreground service (channels are ready before Dart runs)
// android/app/src/main/kotlin/.../AlwaysOnService.kt (excerpt)
private fun initFlutterEngine() {
val loader = FlutterInjector.instance().flutterLoader()
loader.startInitialization(this)
loader.ensureInitializationComplete(this, null)
val appBundlePath = loader.findAppBundlePath()
val dartEntrypoint = DartExecutor.DartEntrypoint(appBundlePath, "backgroundMain")
engine = FlutterEngine(this)
val messenger = engine!!.dartExecutor.binaryMessenger
// Logs and prefs channels set up BEFORE running Dart
MethodChannel(messenger, "msg_mirror_logs").setMethodCallHandler { call, result -> /* append/read/clear via LogStore */ }
MethodChannel(messenger, "msg_mirror_prefs").setMethodCallHandler { call, result -> /* get/set reception, endpoint, template, allowed_packages, retry_queue, sms toggle */ }
// Message channel for events
val channel = MethodChannel(messenger, "msg_mirror")
MsgNotificationListener.setChannelAndFlush(channel)
engine!!.dartExecutor.executeDartEntrypoint(dartEntrypoint)
FlutterEngineCache.getInstance().put("always_on_engine", engine)
// Optional: register SMS observer if enabled and permission granted
// ...
}
3a) Dart: Build payload + filter/skip + dedupe (then render template)
// lib/message_stream.dart (excerpt)
Future<Map<String, dynamic>?> _buildNotifPayload(Map<dynamic, dynamic> m) async {
final String app = (m['app'] ?? '').toString();
final String title = (m['title'] ?? '').toString();
final String text = (m['text'] ?? '').toString().trim();
final bool isGroupSummary = (m['isGroupSummary'] ?? false) == true;
final int whenMs = (m['when'] is int) ? (m['when'] as int) : 0;
final allowed = await _getAllowedPackages();
if (allowed.isNotEmpty && !allowed.contains(app)) return null;
if (app == 'lol.arian.notifmirror' || isGroupSummary) return null;
final String body = text.isNotEmpty ? text : title;
if (body.isEmpty) return null;
if (_isDuplicate(_notifKey(app, whenMs))) return null;
final dateStr = _formatDate(DateTime.fromMillisecondsSinceEpoch(whenMs == 0 ? DateTime.now().millisecondsSinceEpoch : whenMs));
return _renderPayload(
from: title,
body: body,
date: dateStr,
app: app,
type: 'notification',
extraValues: {
'title': title,
'text': text,
'when': whenMs.toString(),
'channelId': (m['channelId'] ?? '').toString(),
},
);
}
3b) Dart: Minimal retry queue with exponential backoff, persisted via prefs
// lib/message_stream.dart (excerpt)
void _enqueueRetry(Map<String, dynamic> payload) {
if (_retryQueue.length >= _retryCap) {
_retryQueue.removeAt(0);
}
_retryQueue.add(payload);
_persistQueue();
_scheduleRetry();
}
void _scheduleRetry() {
_retryTimer?.cancel();
_retryTimer = Timer(Duration(milliseconds: _backoffMs), _flushRetryQueue);
Logger.d('Retry scheduled in ${_backoffMs}ms (queue=${_retryQueue.length})');
_backoffMs = (_backoffMs * 2).clamp(2000, _maxBackoffMs);
}
Future<void> _flushRetryQueue({bool force = false}) async {
if (_retryQueue.isEmpty) { _backoffMs = 2000; return; }
final current = List<Map<String, dynamic>>.from(_retryQueue);
_retryQueue.clear();
for (final payload in current) {
final ok = await _sendToApi(payload);
if (!ok) { _retryQueue.add(payload); if (!force) break; }
}
await _persistQueue();
if (_retryQueue.isNotEmpty) { if (force) _backoffMs = 2000; _scheduleRetry(); } else { _backoffMs = 2000; }
}
4) Kotlin: Broadcast receiver bridging events to any active Flutter engine (UI or background)
// android/app/src/main/kotlin/.../NotifEventReceiver.kt (excerpt)
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != MsgNotificationListener.ACTION) return
val data = mapOf("app" to intent.getStringExtra("app") ?: "", /* ... */)
var ch: MethodChannel? = null
FlutterEngineCache.getInstance().get("ui_engine")?.let {
ch = MethodChannel(it.dartExecutor.binaryMessenger, "msg_mirror")
}
if (ch == null) {
FlutterEngineCache.getInstance().get("always_on_engine")?.let {
ch = MethodChannel(it.dartExecutor.binaryMessenger, "msg_mirror")
}
}
ch?.invokeMethod("onNotification", data)
}
Customize Your Payload (templates)
You can override the JSON with a template (stored in prefs) using placeholders. Example:
{
"message_body": "{{body}}",
"message_from": "{{from}}",
"message_date": "{{date}}",
"app": "{{app}}",
"type": "{{type}}",
"reception": "{{reception}}"
}
Supported placeholders include notification extras when present: {{title}}
, {{text}}
, {{when}}
, {{isGroupSummary}}
, {{subText}}
, {{summaryText}}
, {{bigText}}
, {{infoText}}
, {{people}}
, {{category}}
, {{priority}}
, {{channelId}}
, {{actions}}
, {{groupKey}}
, {{visibility}}
, {{color}}
, {{badgeIconType}}
, {{largeIcon}}
(base64 PNG), {{picture}}
(base64 PNG).
OEM Survival Guide (hard‑won notes)
- Foreground forever: Use a foreground service with a lightweight, low‑importance notification to keep the process alive.
- Start sticky:
START_STICKY
ensures restarts after kills. - Boot-time start:
RECEIVE_BOOT_COMPLETED
+BootReceiver
to re-init after reboot. - Data Saver: On some OEMs, background networking is blocked unless the app is whitelisted. Expose a UX hint and an intent to open settings.
- Battery optimizations: Ask users to whitelist the app. Even then, certain OEMs will “optimize” aggressively; the foreground service helps.
- Dual path delivery: Broadcast + direct channel. If channel isn’t ready, queue, and as a last resort use native
ApiSender
. - App filtering: Persist allowed packages and skip everything else to reduce load and noise.
- Logging: Mirror to logcat (
tag: MsgMirror
) and to a file with rotation; expose a Logs screen in-app.
Permissions Cheat Sheet
-
POST_NOTIFICATIONS
(Android 13+) -
BIND_NOTIFICATION_LISTENER_SERVICE
(declared by the service) FOREGROUND_SERVICE
-
INTERNET
andACCESS_NETWORK_STATE
RECEIVE_BOOT_COMPLETED
-
READ_SMS
(optional, only if enabling SMS observer)
Tip: Data Saver ON can block background networking unless the app is whitelisted. The app surfaces a settings shortcut and a status readout.
Quick Test: local webhook
Spin up a quick receiver to verify payloads:
npx http-echo-server --port 9090 | cat
Then set endpoint to http://<your-ip>:9090
and trigger a notification from a selected app.
Privacy & Security (use it responsibly)
- Message content stays on-device until your app POSTs it to the endpoint you configure.
- You choose which apps are forwarded; SMS monitoring is optional and requires
READ_SMS
. - Payload templates let you minimize data or strip fields; defaults include
app
andtype
so the server can route/inspect. - Consider encrypting in transit (HTTPS), and be mindful of legal/privacy obligations for message content.
What I’d Like Feedback On
- Are the default payload fields sensible? Any missing fields you’d need server-side?
- Backoff/queue semantics: would you prefer per-app queues or a single global queue?
- Template rendering: more placeholders you’d find useful?
- OEM behavior you’ve seen in the wild: anything that breaks despite a foreground service?
- UI/UX suggestions for the Logs and Queue screens.
Roadmap
- Optional auth headers (e.g., bearer/API key) and TLS pinning.
- Multiple endpoints with per-app routing.
- Export/import configuration and logs.
- More granular filters (by channel/category) and richer template placeholders.
- Packaging: signed release + maybe F-Droid if feasible.
- Hardening: More unit tests for retry logic and native↔Dart bridge edges.
Quick Start
1) Build and install the app.
2) Open the app and set Reception (optional) and Endpoint.
3) Select which apps to forward; grant permissions (Notification Access, Post Notifications, optional Read SMS).
4) Start the foreground service; ensure Data Saver is off or the app is whitelisted.
Sample JSON payload (default):
{
"message_body": "Hello world",
"message_from": "Arian",
"message_date": "2025-09-03 19:00",
"app": "com.google.android.apps.messaging",
"type": "notification"
}
Final Notes
- Source: github.com/Dragon-Born/message-mirror
- Logs mirrored to logcat with
tag: MsgMirror
for quick debugging. - If your webhook was down for a while, open the Queue screen and tap “Force Retry” to drain immediately.
If this solves a similar itch for you, please drop a comment with your device/OEM experiences, star the repo, and tell me what you’d like to see next.
If this solves a similar itch for you, I’d love to hear how you use it—and what would make it better.
Top comments (0)