Cross-device Android signage app

Replaced a heavy WebView-based player with a native Kotlin app so the product runs on any Android device, not just locked-down company hardware.

RoleSole builder (AI-assisted)
StackKotlin · Compose · Media3
DomainSaaS digital signage
StatusQA passed on 3 device classes · not yet in production

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

Kotlin 2.2 (K2)Jetpack Compose Navigation ComposeMedia3 / ExoPlayer OkHttpkotlinx.serialization CoilDataStore Gradle Kotlin DSLJUnit
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
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.