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)