// 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.
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.
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.






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.