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_STICKYensures restarts after kills. - Boot-time start:
RECEIVE_BOOT_COMPLETED+BootReceiverto 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-
INTERNETandACCESS_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
appandtypeso 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: MsgMirrorfor 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 (1)
This is great, I'm going to try it out to keep track of my messages with n8n.