DEV Community

Arian Amiramjadi
Arian Amiramjadi

Posted on

Message Mirror: a tiny, resilient Android notification + SMS forwarder (Flutter + Kotlin)

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 via MethodChannel → 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:

Configuration and destination settings
Permissions and service control
Logs and retry queue viewer

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)
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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(),
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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}}"
}
Enter fullscreen mode Exit fullscreen mode

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 and ACCESS_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
Enter fullscreen mode Exit fullscreen mode

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 and type 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"
}
Enter fullscreen mode Exit fullscreen mode

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)