Skip to content

The Android and iOS apps

mobile/ is a Capacitor 8 shell. It doesn't bundle the UI: it opens the user's own server, so the app is whatever that server serves. Its only local pages are in mobile/www: index.html asks which server, and error.html shows when the server doesn't answer.

  1. No server saved: Capacitor's defaults from capacitor.config.json serve www/ at https://localhost.
  2. A server saved (in SharedPreferences): MainActivity builds a config with ShellPlugin.configFor(). That's server.url set to the server, allowNavigation covering its host and the rest of its domain (where Authelia usually is), errorPath pointing at error.html, and NessieApp added to the user agent.
  3. Loading: on current WebViews, Capacitor injects its bridge (window.Capacitor) into the server's origin at document start, and its proxy stays out of the way. Pages load natively, so the sign-in page's redirects, cookies and the service worker behave as they do in Chrome. Links to other hosts open in the browser.

The app changes servers through its own links, not through the bridge. ShellPlugin.shouldOverrideLoad answers navigations to https://localhost/_nessie/…:

Link Does Used by
/_nessie/connect?url=… Saves the server and restarts the activity www/index.html
/_nessie/change-server Forgets the server and asks again error.html; Settings → Server (CHANGE_SERVER in native.js)
/_nessie/retry Opens the saved server again error.html (also when the phone comes back online)

In the UI, inApp() in native.js checks window.Capacitor?.isNativePlatform(), and appPlatform() says which app: 'android', 'ios', or null in a browser. keptCopy() and serverOrigin() are for the iOS app's kept copy (see Opening the iOS app offline). Settings → Server uses it to show "This app opens …" with Change server, and its About section uses appVersion() (the App plugin's getInfo()) to say which app this is. nativePlayer() in native-output.js finds the plugin for NativeOutput (see The player).

The native player

The player lives in native code, so it keeps playing when the web view is asleep. It's made of four classes:

  • PlaybackService runs Media3's ExoPlayer in a media session service. That gives the notification, the lock screen, headset and Bluetooth buttons, and audio focus. The queue lives here.
  • SignedInDataSource fetches streams and covers with the web view's cookies, read for every request. If a sign-in service turns a request away (a redirect to another host, or 401/403), it raises SignInNeeded rather than playing the sign-in page.
  • PlayJournal keeps the songs played to their end, with when they finished, until the web app takes them with takePlays().
  • PlayerPlugin, which is Capacitor.Plugins.NessiePlayer, is the web app's handle on the player. info() returns { version }. Its commands are setQueue, enqueue, move, remove, reorder, play, pause, next, previous, seek, skipTo, stop, setRepeat and getState. It sends state and error events, with error kinds sign-in, network, missing and unplayable. The header comment of PlayerPlugin.java is the reference.

A state is { status, queue?, position, elapsed, duration, buffering, repeat, at }. queue comes only when it changed. at is when elapsed was read, so the page can count on from there instead of asking every second. When things go wrong:

  • a network that's down is retried for about five minutes;
  • a song that's missing or can't be played is skipped, but three in a row stop;
  • a sign-in that ran out pauses the player.

Watch for this

The service and the plugin talk through a MediaController, which carries only a player error's code, not its cause. So PlaybackService works out the kind of error itself (errorKind()), and the plugin reads it from there. They run in the same process, on the main thread. Controller calls must run on the main thread; plugin methods arrive on Capacitor's own thread, which is why on() posts them over.

Watch for this

Once a server is set, Capacitor serves only error.html from the app. Anything that page asked for (a stylesheet, a script) would go to the network that just failed, so error.html keeps its styles, script and logo inline. tests/e2e/test_app_shell.py checks that it loads nothing else.

Downloads in the app

The app keeps downloads itself, so its player can play them offline in the background. Downloads is one store shared by the player, the plugin and the download service:

  • Files: Media3's DownloadManager into a SimpleCache with a NoOpCacheEvictor (filesDir/downloads-cache). One Media3 download per file, keyed by its URL: each song's stream (Downloads.stream() builds the same URL as item() in native-output.js, which is how the player finds it), the download's art and thumb, and each song's album cover for the lock screen.
  • Snapshots: downloads.json, the page's snapshot plus server, track_ids, saved_at and synced_at. list and get only answer for the page's own server.
  • Sharing: KeptFiles decides what to delete: a file stays while any download lists it, as in sw.js. It's plain Java with JUnit tests (KeptFilesTest, ./gradlew testDebugUnitTest).
  • Fetching: through SignedInDataSource, with the web view's cookies. A sync re-adds files that failed.

DownloadsService runs the downloads with a progress notification (POST_NOTIFICATIONS is asked for at the first save), and PlatformScheduler restarts it when the network or Wi-Fi it waits for is back. NessieDownloads (DownloadsPlugin) has list, get, save, remove, clear, status and setWifiOnly. Its summaries match the worker's, plus waiting ("wifi" or "network") and cover, the thumbnail as a data URL, which the pages prefer over thumb and art so covers show offline.

PlaybackService reads through Downloads.playerSource(), a read-only CacheDataSource: downloaded files come from the phone, the rest from the server. Offline, a song that isn't downloaded is skipped to the next one that is (skipToDownloaded()), and nothing is retried. On its first start, downloads.js clears the downloads the web view's worker kept before (retireWorkerDownloads(), once).

Songs the app played, and Download ahead

PlayedSongs is a second SimpleCache (filesDir/played-cache), after the downloads in the player's chain: downloads, then played songs, then the server, kept in played songs as it streams. Like the worker's, it has a limit chosen on the phone (1 GB, never more than 80% of the free space plus what's kept). SizedEvictor keeps it under that, least recently played first. It's Media3's LRU evictor, but its size can change; resize() runs while holding the cache's lock, as the cache's own callbacks do.

  • Download ahead: PlaybackService.downloadAhead() runs on each song change and queue edit. It fetches the next ahead() songs that aren't on the phone into played songs, one at a time on a single background thread, with CacheWriter. A new run cancels the old. Not offline, and not with Data Saver on.
  • Daily check: Downloads.check() and PlayedSongs.check() run when a kept song starts playing online, at most once a day per song (nessie-checked and nessie-etag in the cache's content metadata). A different ETag or length re-downloads a downloaded song and drops a played one.
  • Settings: offline.js sends offlineStatus, offlineSongs, removeOfflineSong, clearOfflineSongs and setOfflineLimit to the app when NessieDownloads.info() says version 2. Settings → Offline is then the browser's page, plus the app's Space and Wi-Fi only, and Download ahead goes to the app (setAppAhead). An app too old for this is told to update.

Watch for this

A VPN that stays up (Tailscale) keeps Android "connected" with no network under it. navigator.onLine stays true and requests to the server hang instead of failing, so connection.js may never notice. The player finds out itself: when a song that isn't on the phone fails to start (dataSpec.position == 0, so not a mid-song dropout), Retrying asks /health with a 5 s timeout. No answer counts the server out of reach for UNREACHABLE_FOR (2 min): it skips to a song on the phone and fires unreachable, which NativeOutput turns into reached(false). The page's setOffline overrides it either way.

Watch for this

A song's cache key is its stream URL, so the server's address must be the same when downloading and when playing. The page always passes serverOrigin() (location.origin, except in the iOS app's kept copy). Opening the server under another address (a LAN IP versus a hostname) shows none of the downloads made under the first, and plays nothing offline from them.

Android Auto

The car browses Nessie through PlaybackService, which is a Media3 MediaLibraryService (PR #44). Android Auto can start it without the page ever opening, so everything here is native and asks the server directly, with the web view's cookies:

  • The tree is the server's. /v1/apps/car (app/services/car.py) works out the branches (Playlists, with Liked Songs first; Mixes; Recently added), what's in each, the songs to play, and search. That way CarPlay draws the same tree (see CarPlay). Ids are strings the apps hand back unchanged: playlist:5, album:9, artist:3, smart:genre-mix:12, smart:liked, track:12, start, and playlist:5/3 for the fourth song in one.
  • CarLibrary is the library callback. The root shows Downloads first (the app's own: download:album-12, from Downloads.list/get), when there are any, then the server's branches. onSetMediaItems turns what the car chose, or a voice query (requestMetadata.searchQuery; empty means start), into queue items exactly like the page's (PlayerPlugin.items: track id, Downloads.stream(), cover). The page then takes the queue up as its own on activate. Items that already have a URI (the page's) pass straight through.
  • Offline, only Downloads shows, and search matches download names and their song titles (CarIds.matches). Car requests time out quickly (5 s / 8 s). No answer calls PlaybackService.unreachable(), the same 2 minutes out of reach the player uses, which also tells the page. A 404 or a sign-in doesn't count as no answer.
  • Covers: Android Auto loads list pictures itself, without cookies, so they go through CoverProvider (content://<app id>.covers/v1/library/artwork/…). It reads only artwork paths, through Downloads.playerSource(), so covers work offline too. The queue's songs carry these links as well (CoverProvider.queueCover, in CarLibrary.queued and PlayerPlugin.items), because the car's Queue view loads them the same way. With http links, it showed no covers on prod behind Authelia. The session's own image loader (Now Playing, notification, lock screen) maps them back to the server's address (CoverProvider.loading) and fetches them with our cookies as before.

Watch for this

Android Auto lists only apps from the Play Store unless its developer settings have Unknown sources on (Android Auto settings → tap Version ten times). Test with the Desktop Head Unit: on Linux it needs libc++1 and runs under xvfb-run, and its console takes tap x y and screenshot. Its microphone doesn't work there, so CarVoiceTest calls playFromSearch the way the assistant does. It opens the app before each test, because Android 15 gives audio focus only to an app in the foreground or one a foreground app started, as the car and the assistant are (logcat: AS.HardeningEnforcer: Focus request DENIED). It stops early if the phone stays locked. Run that test with am instrument: Gradle's connectedDebugAndroidTest uninstalls the app afterwards, taking its server, sign-in and downloads with it. Don't blind-tap the head unit either: the notification badge sits near the search key and opens the phone's messages.

Releases

The app is released with the server, but has its own version: the release where mobile/ last changed. So a release that didn't change the app carries the same one, and no phone is asked to update for nothing. scripts/build-release.sh v0.12.0:

  1. Finds the app's release: the last commit touching mobile/, except mobile/ios, up to the tag (git log -1 -- mobile ':(exclude)mobile/ios'), then the first release tag containing it (git tag --contains … --merged), say v0.11.0.
  2. Reuses ~/.cache/nessie/apk/nessie-v0.11.0.apk (NESSIE_APK_CACHE) if that version was built before. Otherwise it builds the app from that tag (git archive of mobile/, npm ci, cap sync, assembleRelease -PnessieVersion=0.11.0) and keeps it there. build.gradle turns the version into versionName 0.11.0 and versionCode 11000 (major×1,000,000 + minor×1,000 + patch), so each new app installs over the last.
  3. Signs it with the key in ~/.config/nessie/android-signing.properties (or the file NESSIE_ANDROID_SIGNING names), outside the repo. Without it, or with NESSIE_ANDROID=no, the image is built without the app.
  4. Builds the image, then adds the APK on top as a last layer at /app/android/nessie.apk (settings.android_apk), with its version in nessie.apk.version beside it. The Dockerfile and the dev stack never need it.

GET /v1/apps/android serves it as Nessie-<app version>.apk. /version's android_app says whether there is one, and android_app_version which (the server's own version for an image from before this). Settings → Server → About offers "Download the Android app" in a browser. In the app it shows the app's version instead, and when android_app_version is newer (newer(): plain x.y.z only, so dev and -debug builds are never compared), it says to update from the phone's browser.

Debug builds are their own app, io.github.jonp92.nessie.debug, named "Nessie Dev" (src/debug/res), so they install beside the release.

Watch for this

The app's web view can't download files: Capacitor sets no download listener, so a link to the APK does nothing in the app. That's why the update note sends you to the browser. Every update must also be signed with the same key, or Android refuses to install it over the app, and uninstalling loses the app's downloads. Kept, that key can also be the app's key on Google Play (Play App Signing).

To debug the Android app, inspect a debug build from desktop Chrome (chrome://inspect). Playwright can drive it too: run adb forward tcp:9333 localabstract:webview_devtools_remote_<pid>, then chromium.connect_over_cdp("http://localhost:9333"). A click that the app intercepts (the /_nessie/ links) leaves Playwright waiting for a navigation that never comes. Use adb shell input tap for those.

The iOS app

mobile/ios is the same app for iPhone and iPad: the shell from PR #38, the native player from #39, downloads and played songs from #41, opening offline from #43, and CarPlay from #57 (see CarPlay). Both its plugins answer info() with version 2, as Android's do, so the page treats the two apps the same. With no service worker, it opens offline from a copy of the web app it keeps itself (see Opening the iOS app offline).

  • MainViewController (a CAPBridgeViewController, set as the window's root in SceneDelegate) does what MainActivity does. Its instanceDescriptor() sets serverURL and allowedNavigationHostnames from the saved server. capacitorDidLoad() registers the app's own plugins with registerPluginInstance. The user agent and the navy background come from capacitor.config.json's ios block.
  • ShellPlugin.swift answers the same /_nessie/ links through Capacitor's shouldOverrideLoad(_ navigationAction:), matching any scheme on localhost: the app's own pages are capacitor://localhost, and Nessie's Change server link is https://localhost. The server is kept in UserDefaults. A restart puts a new MainViewController in the window, so the next server gets a fresh web view and bridge.
  • The error page is Capacitor's errorPath, served from capacitor://localhost/error.html, so its relative /_nessie/retry links reach the plugin as on Android.
  • Info.plist allows http:// servers (NSAllowsArbitraryLoads) and explains the local network prompt. Debug builds are io.github.jonp92.nessie.debug, "Nessie Dev", through the NESSIE_DISPLAY_NAME build setting.
  • About says "Nessie x for iOS" and never shows the Android download or update note: iPhones get the app through Apple (TestFlight).

The iOS native player

The same contract as PlayerPlugin.java (calls, state and error events, state shape), in four files:

  • Queue.swift: the queue and where it is, without the player, moving as Media3's playlist does: the song playing stays playing when others move around it, Next and Previous wrap only with repeat all, a song that ends by itself plays again with repeat one.
  • Playback.swift: one AVQueuePlayer holding only the song playing and the one after it (upNext), so songs follow each other without a gap while the queue stays ours. Each song is an AVURLAsset with the web view's cookies (WKHTTPCookieStore.getAllCookies, matched to the URL as a browser would, passed as AVURLAssetHTTPCookiesKey). It sets the audio session to .playback (UIBackgroundModes: audio), fills MPNowPlayingInfoCenter (the cover fetched with the same cookies) and answers MPRemoteCommandCenter. A call pauses it and shouldResume carries on; oldDeviceUnavailable (headphones out) pauses.
  • PlayerPlugin.swift: Capacitor.Plugins.NessiePlayer, running each call on the main thread and answering with the new state. Ids are plain Int: Capacitor's bridge doesn't carry Int64.
  • PlayJournal.swift: plays in UserDefaults (nessie-plays), the latest 1,000, until takePlays().

What went wrong is said by the same kinds as on Android. AVFoundation follows a sign-in page's redirect and only says it couldn't play what came back, so probe() asks the URL again itself: 2 bytes, the same cookies, redirects not followed. A redirect to another host or a 401/403 is sign-in (paused), a 5xx or no answer network (retried 14 times over about five minutes, shown as playing and buffering), another 4xx missing, and an answer unplayable (both skipped, three in a row stop). A stream stalled for five minutes counts as a network error. Paused while retrying, it says nothing: Play tries again.

Watch for this

AVPlayer seeks in the background, so right after seek() its time is still the old one. Playback reports the target (seeking) until the seek is done, as Android's answer has the new position at once. A song loading reports startAt, where it will start.

Watch for this

Signing for a phone needs the Mac's keychain, which an SSH session can't unlock (errSecInternalComponent), and Xcode can't use the Apple ID from one either. Build for the simulator over SSH signed to run locally (CODE_SIGN_IDENTITY=-, no keychain needed); install on a phone with Run in Xcode's window. A free Personal Team's signing lasts 7 days. Don't build the simulator app unsigned (CODE_SIGNING_ALLOWED=NO): nsurlsessiond knows apps by their signature, and every background download fails with "remote session is unavailable".

iOS downloads and played songs

The same contract as DownloadsPlugin.java, in Swift, with what's plain logic kept apart so swift test covers it:

  • KeptFiles.swift, OfflineRules.swift and LocalFiles.swift (tested): which files the downloads keep (a shared file stays while any download lists it); whether a fetch came back as the file or a sign-in page (another host, 401/403, or text/html); the file extension AVFoundation needs, from the Content-Type; which played songs make room. LocalFiles is a folder of files keyed by URL with an index.json (name, size, last played, ETag, last checked), and forgets files iOS cleared.
  • Downloads.swift: downloads.json and the files in Application Support (left out of iCloud backup), fetched by a background URLSession, which carries on with the app suspended or quit; AppDelegate hands iOS's wake-up to it. Cookies go on each request as a header. A network error is retried 5 times, from where it stopped when iOS gives resume data; then it's failed until the next sync. Wi-Fi only is each request's allowsCellularAccess, and waiting comes from NWPathMonitor. Failures are logged (category downloads).
  • PlayedSongs.swift: in Caches, under the same limit as Android's. AVPlayer doesn't hand over what it streams, so the song playing is fetched in full beside its stream (the double fetch, agreed for now), and Download ahead fetches the next ones before they play. Neither offline or in Low Data Mode (isConstrained).
  • Playback.swift plays a file on the phone first (downloads, then played songs), skips songs not on the phone offline (skipToPhone), and finds an unreachable server itself, as Android does: a song that isn't on the phone fails to start and /health doesn't answer in 5 s.

Watch for this

The phone's log can be read live from the Mac over the cable: idevicesyslog -u <udid> -p "nsurlsessiond|App" (from libimobiledevice, installed with ios-webkit-debug-proxy). Test background behaviour with Xcode's debugger detached (Stop, then open the app from the home screen): while it's attached, iOS never suspends the app.

Watch for this

An iOS app's web view gets a service worker only for "app-bound domains": up to 10, listed in the app's Info.plist at build time. A household's own server can't be listed in advance, so in the iOS app 'serviceWorker' in navigator is false, even over https (checked on prod). Everything sw.js does is missing there: opening offline, the song cache, Download ahead, and the worker's downloads. The iOS app has to keep songs natively, as the Android app does, and keeps its own copy of the web app to open offline.

Opening the iOS app offline

Without a service worker, a cold start with no network would show Capacitor's "Can't reach Nessie" page. So the app keeps a copy of its server's web app and opens that instead (PR #43):

  • KeptCopy.swift keeps it in Application Support/web-app/current (left out of backup), with a .manifest.json of { server, digest, files }. It refreshes when the app starts and comes to the front (at most every 5 minutes, but at once for a new server), from GET /v1/apps/web-files with the web view's cookies. Only files whose hash changed are fetched, and each is checked against its sha256. A new version is put together in next/ and swapped in whole under a lock, so the copy is never half of two versions. A server without the endpoint gets no copy. Change server clears it.
  • OfflineScheme.swift, a WKURLSchemeHandler registered in MainViewController.webViewConfiguration(for:), answers nessie://offline/. App files come from the copy, /v1/library/artwork/ from the downloads, and the rest of /v1/ and /health fail as having no network, which is how the page knows it's offline. index.html gets a script first thing that sets window.NESSIE_KEPT_COPY = { server, settings } and writes those settings to the copy's storage (KeptCopyRules.page).
  • ShellPlugin.shouldOverrideLoad catches Capacitor's capacitor://localhost/error.html. If a copy of the saved server is ready, it opens nessie://offline/#/downloads instead. While the copy is open, it asks /health every 15 s and whenever NWPathMonitor sees the network change. Any answer, even a sign-in redirect, opens the real page again. The native player keeps playing throughout.
  • In the page, the copy's origin is nessie://offline, so anything filed under the server's address uses serverOrigin() (native.js): downloads, the native queue's song and cover URLs, and Settings → Server. In the copy, NativeOutput.sendPlays() sends nothing: plays stay in the app's PlayJournal and reach the server once it answers.
  • Settings: the copy's storage is its own. handSettingsToCopy() in the iOS real page sends nessie-theme, nessie-library, nessie-native-shuffle and nessie-other-libraries-open to NessieShell.keepSettings at start and whenever the page is hidden. An older app has no such method, so the page doesn't call it.

Watch for this

A setting the copy should start with must be added to HANDED_TO_COPY in native.js. Anything else starts at its default there. The pure rules (what to fetch, safe paths, what each address gets, the page script) are in KeptCopyRules.swift, which is tested by swift test in mobile/ios/NessieTests. To try it, use a server you can stop: the copy opens only when the server doesn't answer at all.

CarPlay

The same tree as Android Auto, drawn with CarPlay's templates (PR #57). The car can start the app without the page ever opening, so it's all native, and asks the server with the web view's cookies.

  • The scene: Info.plist has a CPTemplateApplicationSceneSessionRoleApplication scene, and AppDelegate.configurationForConnecting gives the car's role CarPlaySceneDelegate (the phone's stays SceneDelegate). It needs Apple's com.apple.developer.carplay-audio entitlement, in App.entitlements (CODE_SIGN_ENTITLEMENTS, Debug and Release). Apple granted it to the App ID, so automatic signing picks it up.
  • CarPlaySceneDelegate: a CPTabBarTemplate of CPListTemplates, one per branch (Downloads when there are any, then the server's), each refilled when its tab is chosen. A browsable row pushes its songs; a song plays it and what it's in (Playback.setQueue), then pushes CPNowPlayingTemplate.shared, which reads the lock screen's MPNowPlayingInfoCenter and commands. Failures show a CPAlertTemplate; with nothing to show, the root is a "Try again" row under the reason.
  • Offline: an NWPathMonitor drives the tabs: offline, Downloads shows at once; while the server's tabs are missing it's asked again every 15 s (askEvery) and on each network change, and they join in place (updateTemplates).
  • CarLibrary: downloadsBranch and serverBranches (always asked, offline too), children, songs (queue items built like the page's: OfflineRules.stream(), the full cover) and cover (from the downloads first, cached). Requests time out after 8 s and don't follow redirects. No answer calls Playback.unreachable() (the player's 2 minutes); any answer, a sign-in's too, calls Playback.reachable(), which also clears the page's last setOffline, since iOS may have paused the page while it said offline. CarIds (download ids, a song's place in what it's in) is tested by swift test.

Watch for this

Test in the Simulator with I/O → External Displays → CarPlay; xcrun simctl io booted screenshot --display=external captures it. In the Simulator, the first song after the app starts shows 0:00 and Play in Now Playing while it plays (the log has rate 1); in a car it's right. Building for the Mac over SSH, use a git worktree for the branch: dev serves this checkout, and a branch without a migration its database has stops it.

TestFlight (PR #54): the household installs the iOS app as internal testers, so builds need no review by Apple. scripts/ios-testflight.sh v0.12.0 copies mobile/ as committed at the tag to the Mac (~/nessie-release), runs npm ci and cap sync ios, sets MARKETING_VERSION to the tag's and CURRENT_PROJECT_VERSION to the time (each upload needs a higher build number), and opens Xcode, where you archive and choose Distribute App. The team (DEVELOPMENT_TEAM) is in the project. PrivacyInfo.xcprivacy gives Apple the reason for each API it asks about (UserDefaults, free disk space); a new one of those must be added there, or the upload is refused. ITSAppUsesNonExemptEncryption is false.

To build, a Mac with Xcode 26: npm ci, npx cap sync ios, then Xcode, or xcodebuild … CODE_SIGNING_ALLOWED=NO for a simulator. To debug, Safari on the Mac (Develop menu) inspects the web view. To script it, ios_webkit_debug_proxy -s unix:<webinspectord_sim.socket> serves the simulator on localhost:9222. It speaks WebKit's protocol: after the first message it announces a page target, and commands go inside Target.sendMessageToTarget, and a promise needs Runtime.awaitPromise (there's no awaitPromise flag on evaluate). Playwright's connect_over_cdp doesn't work with it. When the simulator restarts, its inspector socket moves, so restart the proxy.

Watch for this

xcrun simctl spawn booted defaults write <bundle id> … writes the simulator's shared preferences, not the app's own. The app reads it, but can't remove it, so a server saved that way survives Change server. Save one through the Connect page instead.