At a glance
- Problem: the signage app only ran on hardware the company shipped, and leaned on a heavy WebView + JS-player + localhost-server stack.
- Built: a native Android app that installs on any consumer device, pulls playlists from the existing cloud, caches them, and plays offline. Built entirely against the existing server contract, with no server-side changes.
- Result: smoother playback on low-cost hardware, and QA passed on three device classes including a Fire tablet and the company's Android TV boxes. Not adopted in production yet.
What I built
- Right renderer per content type: native video (Media3/ExoPlayer), native crossfade for slideshows, WebView only for arbitrary static layouts. The old app forced everything through a browser engine.
- Offline-first cache: plays from local cache through any network drop. Eviction protects in-use assets so the screen never goes blank.
- Reverse-engineered the server contract: read it off device logs, since no spec existed, and documented it for reuse.
- Unit tests where a wrong answer changes the screen: the template detector that picks the renderer and the transition decider that drives slideshow behavior are both covered.
- Lean architecture on purpose: ViewModel + StateFlow, DataStore, OkHttp. No DI framework; the app didn't warrant one.
~50
Kotlin source files
3
render paths by content type
0
server-side changes to build it
Tech
How the rendering pipeline works
Each slide's template HTML is fetched, checked for changes via a timestamp comment, and routed to the renderer that fits it:
playlist → parse slides[] → per slide:
fetch template → timestamp changed? → download assets
├ video → Media3
├ slideshow → Compose Crossfade
└ static → pooled WebView
fetch template → timestamp changed? → download assets
├ video → Media3
├ slideshow → Compose Crossfade
└ static → pooled WebView
How offline caching survives the real world
A wall-mounted screen can't blank on a Wi-Fi hiccup. The app polls for playlist changes and reports telemetry on a 10-minute cycle, and assets download to a local cache keyed by sanitized URL, so playback continues from disk if a poll fails. When the cache crosses a storage threshold, eviction computes the active set (everything the current playlist references) and only removes files outside it, oldest first. In-use assets are never evicted, even if stale.