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 (1)

Collapse
 
diegohh0411 profile image
Diego Hernández Herrera

This is great, I'm going to try it out to keep track of my messages with n8n.