Android
A small Kotlin app around the server's own web page. The page still decides everything; the app adds what Android's web view cannot do - a real music player that survives alarms, calls and a locked screen, photo backup, the full screen - and the same app installs on Google TV, Android TV and Fire TV.
A web view shell
The app lives in android/: a handful of Kotlin files, no Compose and no AppCompat.
On first launch it asks for the server's address, checks that /healthz answers like
SoundStorm before keeping it (so a typo is caught at the door, not as a strange page), and then
shows the page edge to edge. It answers alert, confirm and
prompt, sends links off the server to the browser, offers Try again or Change server
when a load fails, and hands file pickers and a film's full screen to the platform.
| File | Job |
|---|---|
MainActivity.kt | The web view, the system bars, navigation rules, the back gesture |
PageScript.kt | Script run in the page before its own, on the server's origin only |
NativeAudio.kt, AudioService.kt | Songs played natively by Media3 |
PlaybackService.kt, MediaBridge.kt | The lock-screen player for what the page plays itself |
PhotoBackup.kt | Phone photo backup |
WebCookies.kt, ServerAddress.kt | Cookies for native requests; the saved servers |
The web app cannot tell the Android and iPhone apps apart, by design. Both give the page
window.soundstormApp and the same message channel
(window.webkit.messageHandlers.soundstorm.postMessage), so "Change server" and the
other app-only features needed no Android-specific code in app.js.
PageScript and the message channel
PageScript is injected with addDocumentStartJavaScript, for the server's
origin only, so it runs before any of the page's scripts. It sets up
window.soundstormApp, the message channel, and a navigator.mediaSession -
which Android's WebView simply does not have. The page already describes what is playing through
that API (title, artist, cover, position, which buttons work), so the stand-in passes it to the app
for the lock screen and notification, and hands button presses back to the page's own handlers.
addWebMessageListener, limited to the server's origin
and the main frame - not addJavascriptInterface, which every frame of every origin
would see, EPUB chapters included. A book should never be able to talk to the app.Native audio with Media3
The first versions let the page do all the playing, with a foreground service holding the media session. Two bugs came from that, both reported from a real phone:
- Music stayed stopped after an alarm. The web view pauses when an alarm or call takes the sound, and never plays again. The service then dropped out of the foreground like any paused player, and Android could end the app.
- With the screen off, music stopped after one song. Between songs the page is briefly paused; the service left the foreground, and the next song, starting with the screen off, had to come back from the background - which Android 12 and later refuse, and the uncaught refusal took the app down.
Each got a workaround, and then the owner chose the real fix: songs from the server are
played by Media3's ExoPlayer in a media session service (AudioService), which
Android treats as a music player. It keeps the foreground, pauses for an alarm or call and plays on
after, pauses when headphones come out, and gives the lock screen, notification and car their
controls.
The page still decides everything. PageScript gives the page's
<audio id="audio-player"> a stand-in on the element itself, installed the moment it
is parsed. Setting its src to a song, play, pause, seeking, volume and speed become
messages to NativeAudio, and the player's state comes back every half second as the
element's own events - playing, pause, timeupdate,
ended. The lyrics, the looks and the timeline keep reading currentTime as
before and never know the difference.
What stays in the page: downloads (they are blob: addresses the native player cannot
read), audiobooks and crossfade's second element, which is why crossfade is off in the app. For
those, PlaybackService still provides the lock-screen player from the page's media
session.
Handing over the next songs
The next song is handed to the player ahead of time (queueNext), so it moves into it
by itself - gapless, never stopping between songs. It then tells the page the song ended; the page
sets the next song as always and finds it already playing.
Android also reclaims a background web view's memory by ending its page. The app used to tear the
old page down in a way that told the player to stop, so music stopped while somebody was in another
app. Now a page Android ends is dropped without touching the music, and since a page that is gone
cannot hand over the next song, the page hands over the next ten up front
(queueUpcoming), each with its title, artist, album and cover for the lock screen.
The page takes the music back
When the app is looked at again and the page is rebuilt, it asks the player what it is doing
(askState). The page keeps its queue on the device as each song starts
(saveNativeQueue, up to 220 songs around the one playing). If the song playing is in that
kept queue, the page plays it through its normal path, and the stand-in recognises it as the song
already in the player: nothing is reloaded, the position is the same, playing or paused, with the
queue rebuilt around it.
Two smaller fixes from the same work: a song that had played to the end would not play again (a
finished ExoPlayer must be sent back to the start, and the same song asked for again looked "already
loaded"), and after the player moved into the next song by itself, the stand-in kept reporting the
old one until it started afresh on ended.
Photo backup with WorkManager
A phone's photos and videos are sent to the person's own photo folder on the server
(PhotoBackup.kt). The page decides nothing about the mechanics: Settings → Your
photos has the switch and options and talks to the app through
soundstormApp.backup(...); the app does the work.
- WorkManager jobs, so Android decides when, and backup carries on with the app closed: one when a photo or video is added, one every six hours. Wi-Fi only unless turned off, and optionally only while charging.
- Newest first, one file at a time, one backup at a time. A lock in the worker means a second job never sends the same file beside the first, and the switch's job keeps a running one rather than starting another. Settings shows the file being sent ("Sending a video: 240 of 600 MB"). A job runs in the foreground with a quiet notification where Android allows it, since an ordinary job is stopped at ten minutes - one long video on a slow link.
- One refused photo is passed over and named, rather than stopping the whole backup.
- Nothing sent twice. What was sent is remembered by its MediaStore id, and each
batch of 100 is first checked with the server (
POST /api/photos/backup/check), so a reinstall or a new phone sends nothing it already has. - The original file (
setRequireOriginal, with the media-location permission). Otherwise Android hands over a copy without the place it was taken, which would empty Places and differ in size. - Plain reasons when it stops: a refused permission, a full photo space, a full disk or an ended sign-in are shown until the next job.
After signing in on a phone with Pictures, the page asks once, "Back up this phone's photos?", and tells a member plainly that the owner of the server can see them. It is not offered on a TV. On a shared phone, signing out (or another account appearing) turns backup off, the phone sends the account it was turned on for, and the server refuses photos meant for somebody else; the next person is asked afresh. Backup goes to the secure name the page was on, never plain http on the Wi-Fi when a secure name exists.
Several saved servers
Since 0.19 the connect screen lists Your servers above the address box: the
latest used first, the one in use ticked, a tap to switch, a long press to rename or remove one
(ServerAddress.all, kept as a list beside the current address). Each server keeps its
own sign-in, since cookies belong to an address, so switching back asks for nothing. The iPhone and
Apple TV apps have the same list.
Full screen and the camera cutout
Both system bars are hidden everywhere, at the owner's asking - something the installed web app could never do, since Chrome owns its bars. A swipe in from an edge shows them for a moment. The app draws into the camera cutout, so there is no black strip over the camera.
The catch: the web view reports zero for the cutout it draws into (measured: a 136-pixel cutout,
env(safe-area-inset-top) read 0). So the stylesheet never uses env()
directly. Every edge is spaced by --safe-top and its siblings, which default to the
env() values in a browser and on the iPhone, and which the Android app sets to the real
cutout size in CSS pixels. That change was first made only on an Android branch, whose web files the
server never serves - so the installed app's title sat under the camera until it was brought to
main. It is why the project now has one branch.
The status bar takes the page's theme colour, watched with a MutationObserver on
meta[name=theme-color], so it matches Now Playing's backdrop.
The back gesture
Back steps back through the page's own history first (closing a menu, Now Playing, a film - see
the web app), and with nothing left it moves the app to the
background rather than closing it, since closing would stop the music. Android 16 no longer calls
onBackPressed for an app built for API 36, so the system closed the app from inside a
menu; MainActivity now registers an OnBackInvokedCallback that does the
same job.
Security
A review pass looked at the app with fresh eyes, and version 0.16 fixed what it found:
- Server addresses only. The native player and cover loader take only the server's own addresses; any other URL the page passes is refused.
- Cookies per host. The session cookie used to be copied into a request header
by hand - and a header follows a redirect to any host. Now
WebCookiesis the process's cookie handler and answers each address, redirects included, with that address's own cookies only. - Trusted controllers. A media session lets any app on the phone control playback
and read what is playing unless told otherwise.
onConnectnow accepts only the system, the app itself, and known car, watch and assistant apps. - Nothing off-server in the app's window, and other apps or new windows open only from a link the person actually tapped.
- The secure-name move is followed, not saved. From a plain-http address the page moves to the install's https name; someone on the same Wi-Fi could once have pinned the app to their own server that way. Now a move from a soundstorm.dev name is accepted only to its own twin (the same install id), and never written as the saved address.
- No backups of the session (
allowBackup="false"and data extraction rules),taskAffinity=""against task hijacking, a non-debuggable build, and a checksum on the Gradle wrapper.
Plain http is still allowed, because an install is often first reached by its LAN address and Android cannot allow only private address ranges.
One thing is for the developer only: on the install that has training switched on, Settings in the app can send a playback report - what the native player saw (volume, audio focus, outputs, the page's commands) - for chasing sound that cuts out mid-song (0.20). No other install shows it.
Releases
APKs are built on the Windows development machine from android/ on main
(gradlew assembleDebug), with versionCode raised every time so a phone
installs over the last one. They are published as GitHub pre-releases named
android-<version> and never marked Latest, because the Latest release is where the
server's Windows installer lives.
The same app on a TV
Google TV, Android TV and Fire TV run this same app, not a second one. The manifest adds the
leanback launcher category and a banner, and marks leanback and a touchscreen as not required so
phones install it exactly as before. On a TV the web view's user agent gains
SoundStormTV/1, and the page switches into its TV mode: remote-control focus, films
full screen, no mini-player. That is described on TVs.