← 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 collapses into one. It stops updating when the phone is locked or dozing. And for messaging apps it frequently degrades to a summary like "3 new messages" rather than the actual title and body.

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

Keeping an MQTT connection alive while the phone is locked needs a foreground service, and on Android 14+ that service must declare a type. The obvious pick, dataSync, is a trap: Android 15 deprecated it and imposes a cumulative daily runtime cap, which would quietly kill a persistent broker connection partway through a day. I declared connectedDevice instead — no such cap, and it honestly describes a service holding one continuous socket to a device on the LAN. The cost is a companion permission (CHANGE_NETWORK_STATE, required on API 34+) and a persistent notification the user can see. Being visible about background work is the right trade even though it spends a notification slot.

Local-only, including the failure modes

No telemetry, no analytics, no third-party SDKs — the only outbound connection is 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
The Permissions screen states the Android-12 boot caveat in the UI rather than hiding it
The Permissions screen states the Android-12 boot caveat in the UI rather than hiding it
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 honest open items: the release build is signed with a debug keystore, so it is not yet distributable; on Android 12+ the boot receiver only fires once the user has opened the app at least once, which is a platform constraint the onboarding has to explain rather than engineer around; and the biometric app-lock's effective floor is API 30, not the app's stated API 26 minimum — below API 30, setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) throws, and that gap is a documented must-fix-before-release rather than a discovered bug.

← Back to selected work