← Selected work

// Case study

NotifyBridge.

An Android app that forwards notifications to a local MQTT broker so they surface in Home Assistant — no cloud, no Firebase, no third-party service. The phone publishes straight to a broker on your own LAN.

Role
Sole engineer — spec, design, build, test
Timeline
2026 — pre-release (v0.1.0)
Stack
Kotlin · Compose · Hilt · Room · WorkManager · HiveMQ MQTT
Platform
Android 8.0+ (API 26), targetSdk 35

The problem

Home Assistant's own Companion app exposes a "Last Notification" sensor, and on paper that is the same feature. In practice it loses things. It keeps only the most recent notification, so a burst of messages can overwrite the previous sensor value. I needed a durable queue for delivery across lock-screen, Doze and broker outages, rather than relying on the latest sensor value or a messaging app's notification summary.

So the goal was narrow and testable: every notification from an allowed app arrives, intact, even while the phone is locked or the broker is briefly gone.

How it works

A notification listener service filters against a per-app allow-list, writes the survivors to a Room-backed outbox, and a foreground service drains that outbox to the broker. Home Assistant picks the sensor up on its own through a retained MQTT discovery message — no hand-written YAML.

NotifListenerService per-app allow-list filter Outbox (Room) survives reboot / broker outage MqttForegroundService connectedDevice FGS Local MQTT broker on your LAN Home Assistant MQTT discovery

Three decisions worth explaining

Persist first, publish second

The obvious implementation publishes straight from the listener callback. That loses data the moment the broker is unreachable — which, on a home network, is often. Writing to a Room outbox before attempting delivery means a broker outage, a reboot, or a dead Wi-Fi link degrades to a delay instead of a loss. The cost is a state machine and a drain worker to maintain; the benefit is that the app's core promise survives the network being imperfect.

A foreground service, declared honestly

NotifyBridge uses a foreground service for its ongoing MQTT connection. Apps targeting Android 14 (API 34) or higher must declare an appropriate type and its type-specific foreground-service permission. dataSync is not deprecated: on Android 15, apps targeting API 35 or higher have a six-hour budget in a 24-hour period while the app is in the background, shared across all of the app's dataSync services. At the limit, Android calls Service.onTimeout(int, int) and the service must stop within a few seconds. Bringing the app to the foreground resets the budget.

I chose connectedDevice because the service interacts with a local broker over the network. This type is not subject to the same six-hour timeout. It requires FOREGROUND_SERVICE_CONNECTED_DEVICE and at least one of the documented prerequisites. NotifyBridge declares CHANGE_NETWORK_STATE, one supported option among several. The trade-off is the qualifying permission and an ongoing service notification; background-start and power-management rules still apply.

Local-only, including the failure modes

No telemetry or analytics service — notifications go directly to the user's own broker. TLS supports system CA and pinned certificates for self-signed home setups; a trust-all option is deliberately not exposed, because on a protocol carrying your notification text it would be a footgun disguised as convenience. Notification bodies in the in-app list stay masked until a per-item biometric unlock.

Screens

The first two are captures from a real device, cropped to remove live notification content. The four after them are Roborazzi goldens — Compose renders produced by the test suite itself. Nothing here is a mockup.

Broker disconnected with 3,082 notifications buffered in the outbox — nothing lost while the broker is unreachable
Broker disconnected with 3,082 notifications buffered in the outbox — nothing lost while the broker is unreachable
NotifyBridge Permissions screen with notification access, battery settings, startup guidance and app-lock controls
NotifyBridge Permissions screen with notification access, battery settings, startup guidance and app-lock controls
Status screen with a connected broker, outbox depth and recent forwards
Status screen with a connected broker, outbox depth and recent forwards
Broker configuration with host, port and TLS mode
Broker configuration with host, port and TLS mode
Per-app allow-list — only enabled apps are forwarded
Per-app allow-list — only enabled apps are forwarded
Recent list with notification bodies redacted behind the app-lock
Recent list with notification bodies redacted behind the app-lock

Keeping it honest

The reliability claim is only worth as much as the tests behind it, so the suite targets the paths that can silently lose data: notification mapping (including MessagingStyle and the body-priority chain), dedup and debounce, and outbox enqueue, drain and prune.

  • 124unit and instrumented tests
  • 20screenshot goldens
  • 2CI lanes

Screenshot tests use Roborazzi on Robolectric, pinned to a fixed device, SDK, locale and timezone so a diff means a real UI change rather than environment drift. I chose it over Paparazzi specifically because Paparazzi pins to particular AGP/LayoutLib versions, and this project had already been bitten once by that coupling. The two lanes are a JVM fast lane — build, unit tests, golden verification, detekt and lint — and an API-28 emulator lane, both running on every push to main and every pull request. The fast lane is a required status check before anything merges.

Where it stands

v0.1.0, pre-release. The paths CI cannot reach — the live forwarding path is stripped from the debug APK, so the instrumented suite never exercises it — are covered by a ten-step manual device checklist, backed by a disposable Docker Compose Mosquitto and Home Assistant rig committed alongside the app. Bidirectional control, notification actions and media controls are explicit non-goals — the app does one job.

The release build still uses a debug keystore, so production signing remains open. Startup guidance must also account for Android's stopped-package rules: after a force-stop, the app cannot self-start until an explicit request starts one of its components. This is not a restriction unique to Android 12.

App-lock compatibility uses BiometricPrompt on API 30+ and device-credential confirmation on API 26–29. AndroidX documents that combining BIOMETRIC_STRONG with DEVICE_CREDENTIAL is unsupported on API 28–29. The existing version-specific fallback handles that restriction.

← Back to selected work