The phone has been wearing tauri's logo. `tauri android init` writes the
generated project's `res/` from its own template and never reads
`src-tauri/icons/android/`, which has carried the falcon since 50afa6a, and the
detour build runs cargo and Gradle directly — so nothing ever copied it across:
the two dominant colours in the packaged `mipmap-xxxhdpi/ic_launcher.png` are
`#24c8db` and `#ffc131`, tauri's own brand cyan and amber. `src-tauri/android/res/`
is the app module's res now, tracked, and `build.sh` and `dev.sh` copy the whole
tree into `gen/android` before Gradle, because that project is regenerated and
gitignored and an asset that only lives there is an asset that disappears.
It is a real adaptive icon: the kit's foreground and its **monochrome** layer at
five densities, the legacy mipmaps under it, and the background as one
`#FF05080B` colour resource rather than five identical flat PNGs, so the gradient
being mocked separately replaces one file. The kit's own XML could not ship — it
points every layer at `@drawable/` while shipping them as PNGs under `mipmap-*`,
and in the generated project two of those three references resolve to Android
Studio's template robot rather than failing — so the overlay's XML says
`@mipmap/` and `@color/`, and its colour file takes the same name tauri's does,
because two files declaring one colour is a hard aapt2 error.
Losing the copy would be silent — the APK still builds, still installs, and wears
the old logo with nothing in the log — so `verify-icon.sh` opens every APK both
scripts make and reads the resources back out: the two `mipmap-anydpi-v26` XMLs
present, and four PNG names at five densities byte for byte from the overlay. A
release APK keeps neither the bytes nor the names — AGP re-encodes every PNG and
shortens every resource path — so that path asks `aapt2 dump resources` what each
name points at and compares pixel dimensions instead; 72×72 at hdpi still tells
the kit's icon from tauri's 49×49. No walk step: a launcher icon is not reachable
from inside the app, and the masking and the themed layer want a minute with the
phone's home screen.
1920 lines
123 KiB
Markdown
1920 lines
123 KiB
Markdown
# SoulStream on Android
|
||
|
||
The phone is the **same Tauri 2 project** as the desktop: the same Rust core
|
||
in `src-tauri`, the same commands over IPC, a third Vite entry for the
|
||
phone's own UI. Started 2026-09-12 on a Pixel 6a (Android 16). The design it
|
||
implements is `research/mobile-mockup/` (gitignored; see its README).
|
||
|
||
## What is where
|
||
|
||
| | |
|
||
|---|---|
|
||
| `mobile.html` → `src/mobile-main.js` → `src/mobile/Mobile.svelte` | the phone UI (its own Vite input, next to `index.html` and `mini.html`) |
|
||
| `src/mobile/mobile.css` | the foundations: the desktop palette, phone radii/spacing, Android insets, the sampled springs, the registered `--accent` and its transition |
|
||
| `src/mobile/shell.css` | the shared vocabulary, global on purpose: ground, glass bar, cards, rows, chips, the record head, the mini bar, the nav, the player sheet, the lane, half sheets, the screen transitions |
|
||
| `src/mobile/nav.svelte.js` | a stack per tab; the browser history mirrors it one entry per back-able layer so the **system back gesture** peels the sheet, a half sheet, a pushed screen, then the tab |
|
||
| `src/mobile/player.svelte.js` | the queue (a predetermined shuffle order, the unshuffled queue kept), the transport, gapless preload the desktop's way (`preload_local`, `track-ended { gapless }`), the session in localStorage (a reload follows the live audio thread, a restart restores paused with `needsResource`), the sleep timer, the lane's story (hold → arriving), the play-history ticks (the desktop's rule, the same table) |
|
||
| `src/mobile/art.svelte.js` | covers behind a LIFO gate (3 in flight), the `visible` action, the accent per cover through `src/lib/color.js`; an https URL goes through the desktop's art proxy (`catalog_art`) |
|
||
| `src/mobile/lib.svelte.js` | albums, recents, follows, playlists; artists derived from the albums |
|
||
| `src/mobile/catalog.svelte.js` | the catalog lookups, cached per session: an artist by name (the desktop's most-followed-exact-match rule), a catalog record by id |
|
||
| `src/mobile/media.svelte.js` | the lock-screen card's wiring: mirrors the bar into the media session plugin on every transport change (plus a top-up every 30 s), carries out the card's, the headset's and audio focus's actions |
|
||
| `src/mobile/xfernote.svelte.js` | the shade's download notice: mirrors what is still in flight into the transfers plugin (throttled, one post per 700 ms of changed content) and carries out its Stop all and Open |
|
||
| `src/mobile/slsk.svelte.js` | the Soulseek hub: the connection, the rooms, the chats, the buddies — the desktop's `slsk` store and events |
|
||
| `src/mobile/contacts.svelte.js` | the local view of a person on the network: a petname, a group, a note and the two marks (`contacts_meta_list` / `contact_meta_set`) — this phone only, published nowhere |
|
||
| `crates/tauri-plugin-media-session/` | a Tauri plugin with a Kotlin foreground service (`MediaService`) that owns the `MediaSession`, the MediaStyle notification, audio focus and the becoming-noisy pause. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows |
|
||
| `crates/tauri-plugin-transfer-notice/` | a second Tauri plugin with a second Kotlin foreground service (`TransferService`, `dataSync`) — the downloads' own progress notification on its own low-importance channel, with Stop all and Open on it, and the reason a Save outlives the app going to the background with nothing playing |
|
||
| `src/mobile/Sheet.svelte`, `HalfSheet.svelte`, `SyncRoom.svelte`, `Lane.svelte`, `Cover.svelte`, `Release.svelte` | the player (art / lyrics / queue faces, container transform from the mini bar, drags), the bottom sheets (track, record, sort, the page overflow, the sleep timer), the tap-to-sync room, the waveform lane, the two card pieces (a long press on a card is its sheet) |
|
||
| `src/mobile/screens/` | Home, Search (the library half: records and artists here, tracks through `library_track_search`), Library (albums / artists / tracks / playlists), Album, Artist (the library's shell at once, the catalog dissolving in: portrait, Popular, discography with what is here marked), CatalogAlbum (a catalog record, rows the library has play), Discography (every release, on one page), Playlist, Community (sign in; rooms, chat, buddies, taste — the Soulseek side; the private layer waits), Room, Thread, You (Appearance switches, real) |
|
||
| `src/mobile/prefs.svelte.js` | Appearance, kept in localStorage: ambience, accent from artwork, motion |
|
||
| `src/mobile/util.js` | what every screen shares and no screen should write twice: the formatters (`mmss` / `longDur` / `stamp` for a length in seconds, in words and in milliseconds; `ago` / `clock`; `fmtBytes` / `fmtCount`; `fmtOf` / `albumFmt`), the name and folder readers a peer's file needs, the `drag` action, and `devBuild` |
|
||
| `src-tauri/tauri.android.conf.json` | merged over `tauri.conf.json` on Android: one window at `mobile.html`, no desktop resources, `minSdkVersion` 26 |
|
||
| `src-tauri/gen/android/` | the generated Gradle project (`npx tauri android init`); `minSdk = 26`. Gitignored |
|
||
| `src-tauri/android/build.sh`, `deploy.sh` | the detour build (below) and build → install → launch → screenshot |
|
||
| `src-tauri/android/res/` | the app module's own Android resources, tracked: the adaptive launcher icon, its monochrome layer, the legacy mipmaps and the one colour the background is. `build.sh` and `dev.sh` copy the whole tree into `gen/android/app/src/main/res/` before Gradle; `verify-icon.sh` reads it back out of the APK |
|
||
| `src-tauri/src/android_glue.rs` | `JNI_OnLoad`: hands the JavaVM and app Context to `ndk-context` (cpal's AAudio backend needs it; tao 0.35 never fills it) |
|
||
| `src-tauri/src/updater_mobile.rs` | the updater's stand-in on mobile (`#[cfg(mobile)] #[path]`) |
|
||
| `src-tauri/src/lyric_align_stub.rs`, `vocal_sep_stub.rs` | stand-ins off Windows for the two Windows ML modules |
|
||
| `tests/mobile/smoke.mjs`, `perf.mjs`, `cdp.mjs` | the phone UI walked in headless Chrome or on the phone, the frame-time probe, and the DevTools client they share (below) |
|
||
|
||
Android-only manifest tables: `src-tauri/Cargo.toml` and
|
||
`crates/metadata/Cargo.toml` give reqwest `rustls-tls` on Android and the
|
||
platform TLS everywhere else (no OpenSSL to build against on Android). The
|
||
tables are exclusive on purpose — an unconditional reqwest entry unifies
|
||
default-tls back in. `tauri-plugin-updater` is a desktop-only dependency.
|
||
|
||
What the phone does differently at runtime:
|
||
|
||
- the library root is `app_data_dir()/Library` (Android's `getDataDir()`, so
|
||
`/data/user/0/com.soulstream.app/Library`) until the SAF folder story lands;
|
||
- the Soulseek password lives in `secrets.json` under the app's private data
|
||
directory (`config::save_password` and friends on Android) — there is no
|
||
credential manager the keyring crate speaks to, and the app directory is
|
||
what Android's account storage amounts to for an app;
|
||
- the audio output stream asks for an 8192-frame buffer (`open_output_stream`);
|
||
AAudio's default put it on the fast path with a 10 ms burst, where the mixer's
|
||
inline decode and file reads underran audibly;
|
||
- `pick_folder` answers "nothing picked" (desktop-only dialog); `save_file`
|
||
opens Android's create-document sheet, but answers a `content://` URI that
|
||
`std::fs` cannot open — which is why the playlist export goes through the
|
||
storage plugin's `create_document` instead;
|
||
- the window-glass command is a no-op (the ground is the artwork);
|
||
- the aligner and the separator report "not on this platform";
|
||
- the desktop's Win32 perf counters read 0 / None in the debug overlay.
|
||
|
||
## The shell, on real data
|
||
|
||
The mockup's rules, kept: the page ground is the artwork in play, blown up and
|
||
blurred (two layers, so a change of record crossfades); the accent follows
|
||
the artwork **per screen** — a record page wears the record's colour, an
|
||
artist page the artist's, everything else what is playing — extracted with
|
||
the desktop's own `color.js` and crossfaded by the compositor through the
|
||
registered `--accent`. The glass bar is `GlassBar.svelte`'s recipe and stays
|
||
anonymous until the page's large title has scrolled under it. The mini bar
|
||
is the desktop's 62px row over a 16px lane strip; tapping it is a container
|
||
transform (the small art flies to the big one); the big art opens the artwork
|
||
viewer — the cover at full size, pinch to zoom, one finger pans while zoomed,
|
||
a tap on the unzoomed cover or the back gesture closes. The player's scrubber
|
||
is the lane itself, no knob. Motion is Material 3 Expressive's springs,
|
||
sampled into `linear()`. Reduce motion (You → Appearance, or the OS setting)
|
||
turns it off.
|
||
|
||
The activity declares no `screenOrientation` and handles `orientation` and
|
||
`screenSize` itself, so turning the phone over is a resize and nothing else —
|
||
the audio thread, the stack and the screen's scroll all survive it, and the
|
||
layout is the only thing that has to answer. Two media queries do that.
|
||
Every claim in that sentence was read off the CSS and proved at 915×411
|
||
behind a viewport override; the phone walk now turns a real device over and
|
||
reads each of them there — the angle, the insets around the cutout and the
|
||
gesture pill, the activity, the audio. See **Verifying**. The player sheet
|
||
is the one surface whose portrait shape cannot survive a wide viewport: its
|
||
artwork is a square sized by WIDTH, so at 915×411 the cover came out 879px
|
||
tall and the title, the lane, the transport and the face buttons sat
|
||
below the bottom of a sheet that does not scroll — a corner of the cover and no
|
||
way to pause. Sideways it is two columns: the head across the top, the cover
|
||
down the left sized by HEIGHT, and the track, the scrubber, the transport and
|
||
the faces stacked on the right. With lyrics or the queue up, the face takes the
|
||
cover's column and the thumbnail goes — the record is already the ground behind
|
||
the whole sheet. That is an aspect problem rather than a height one, so the rule
|
||
keys off `orientation: landscape` alone and a tablet gets the same two columns.
|
||
|
||
The rest is a phone-sized query (`max-height: 560px`). The nav comes down to
|
||
Android's own 48dp minimum and the mini bar to a 60dp row over the same lane
|
||
strip, which gives the page back 22 of a 411-pixel screen — the chrome was 46%
|
||
of the height, it is 40%. The artist hero stops being a 300px portrait, which
|
||
covered its own Play button with the mini bar, and becomes a 190px banner: what
|
||
the desktop's hero has always been. The record head puts the cover beside the
|
||
title rather than above it, again the desktop's own shape, so Play is in view
|
||
without a scroll. A half sheet may take all but 36px of the height instead of
|
||
76% of it. Everything else — the grids, the rails, the artwork viewer, the
|
||
tap-to-sync room, the chat composer — already answered to width and was left
|
||
alone. `viewport-fit=cover` means the cutout and the gesture pill come in from
|
||
the SIDES in landscape, so the shell reads `--inset-left` / `--inset-right`
|
||
beside the top and bottom ones now. Those rules are appended at the end of
|
||
`shell.css` on purpose: they have to come after `.gbar`'s padding, `.mini`'s
|
||
and `.toast`'s left/right to win at equal specificity.
|
||
|
||
## Search and the resolver
|
||
|
||
Search is the desktop's top bar: the field asks the **catalog** once the
|
||
typing pauses (`catalog_search` — Deezer, MusicBrainz and Apple behind it),
|
||
and shows artists, a rail of records and songs, then what the library holds,
|
||
then — offered, not run — a raw Soulseek search grouped by peer (a peer's
|
||
copy plays from its row, the lane rising as the bytes arrive). A song row,
|
||
a catalog record's tracks and its play disc, and Popular on an artist page
|
||
all play through the resolver.
|
||
|
||
The resolver is the desktop's, not a fork of it: the source rules — the query
|
||
ladder with its poisoned-term hygiene, the paced search with its early exit,
|
||
the quality window, the candidate scoring for songs and folders, the
|
||
remembered stream sources — live in **`src/lib/sources.js`**, one copy for
|
||
both apps (moved out of `state.svelte.js` verbatim on 2026-09-12;
|
||
`state.svelte.js` imports them back). `src/mobile/resolve.svelte.js` runs
|
||
them on the phone: the library first, then a remembered source whose bytes
|
||
are still complete in the cache, then the network. A record queues every
|
||
track from the one folder that wins; a song played off a record queues the
|
||
rest of the record behind it as path-less entries that find their own copy
|
||
when their turn comes (`hooks.resolveEntry`). Failover is the player's: an
|
||
entry carries its runner-up peers in `t.alts`, and a stream that has not
|
||
sent a byte in 12 s (8 s for a retry) moves to the next one, then on.
|
||
|
||
A streamed record's cover comes out of the file's own tags once enough of it
|
||
is here (`stream_embedded_art`), and the accent follows it when it lands.
|
||
|
||
Search leads with the desktop's **top result** — the most popular exact match
|
||
of its kind (artist, record or song), scored as a share of its own kind's
|
||
leader, ties falling artist → record → song — on the featured surface, then
|
||
artist cards, records, songs. The **artist page** is the desktop's
|
||
(CatalogArtist + ArtistExtras) at phone scale: the banner with the wordmark or
|
||
the name, Play popular, Follow, the stat chips (releases, fans, years active,
|
||
genre, socials), Popular with listener bars, the latest release, the
|
||
discography filed the desktop's way (`src/mobile/disco.js` over the shared
|
||
`localReleaseType.js` + `editions.js`: tabs per category, editions folded
|
||
into families, the library's copies marked), releases by year, fans also
|
||
like, what is on this phone, About. Opened from a search row it carries the
|
||
row's identity (`seed`), so a namesake can never take the page.
|
||
|
||
A cold page no longer waits on one call for everything. Three lanes run off
|
||
the artist's id the moment it is known: Popular's ranked rows
|
||
(`catalog_artist_popular`), the banner alone (`catalog_artist_hero_seed`) and
|
||
the first page of releases (`catalog_artist_overview`) — the desktop's own
|
||
staging, for the desktop's own reason, since the identity pass inside the
|
||
full page is a MusicBrainz chain behind a one-request-a-second gate. The
|
||
overview's releases hold the discography up until the exhaustive page lands
|
||
with the classified one, and never the other way round; its `popular_tracks`
|
||
are dropped, because Popular already has a ranked lane of its own. The hero
|
||
seed pays for itself twice: its first source is the hero Kyle picked on this
|
||
phone, read off the file with no network at all, so **Choose hero photo**
|
||
shows on the next visit at once instead of behind a whole catalog page — and
|
||
both lanes are forgotten with the page when a hero is set or removed, or the
|
||
old banner would answer while the new one was still being fetched.
|
||
|
||
The **whole discography** is a page of its own
|
||
(`src/mobile/screens/Discography.svelte`), reached from the artist page's
|
||
Discography head with **All**, and from under its grid with "Show all N
|
||
Albums" — which is also where that grid now stops: it shows twelve of the
|
||
active tab, because before this an artist with 140 releases rendered 140
|
||
cards. The page is the desktop's `CatalogDiscography.svelte` reduced to the
|
||
four things it has that the artist page's tabs do not: **All** in front of
|
||
the categories (built by CONCATENATING the per-category families, never by
|
||
re-folding the whole set — a single sharing an album's base title would fold
|
||
into it as a phantom edition), a **sort** (newest, oldest, A–Z, Z–A), a
|
||
**grouping** by year, and the **editions kept whole**, opened in place under
|
||
their own record. Three of the desktop's are deliberately left behind: the
|
||
per-record tracklists (140 records is 140 `catalog_album` calls over mobile
|
||
data, for rows one tap away on the record page), the per-record accent
|
||
sampled from each cover, and the scroll-led banner. The year is written into
|
||
the list where the desktop has a sticky year rail — a phone list is read top
|
||
to bottom. The order and the grouping live on the nav entry and are edited
|
||
from the **Sort & group** sheet, Library's own idiom; under a name order By
|
||
year is not offered, because the years would repeat out of sequence, and the
|
||
sheet says so. The list is capped at sixty rows with a "+ N more releases"
|
||
button (Browse's rule), and the cap is taken BEFORE the grouping, so the last
|
||
year on screen can be a part of itself. Filing, families and labels all come
|
||
from `src/mobile/disco.js` — the artist page's own classifier, called a
|
||
second time over the same cached albums. The desktop cannot do that: its
|
||
filing depends on a background description harvest and deferred MB type
|
||
hints a second page would not read, which is why `CatalogDiscography.svelte`
|
||
takes the artist page's computed tab list as the same array object instead.
|
||
|
||
The **catalog record page** is the desktop's album page: the kind, the
|
||
artist byline with a portrait, year · tracks · runtime, Last.fm's totals,
|
||
Play / Save / Shuffle, the record's other editions (the artist's page folded
|
||
by `editions.js`, the desktop's own rule), the tracklist with listener bars
|
||
and disc heads, About this record (Wikipedia through `catalog_album_info`,
|
||
the lede promoted), and the release pills with who said so. **Save** fetches
|
||
the best folder by the DOWNLOAD rules into the phone's library folder; the
|
||
pipeline files it and says `soulseek:library-changed`, which the library
|
||
store watches, so the record appears on its own. A partial copy (a Save cut
|
||
short, a rip missing tracks) keeps the button as "Complete · N missing". A
|
||
long press on a row is the track sheet: Play, Save to library, Go to record /
|
||
artist. Verified 2026-09-13: Discovery, 14 FLACs from one peer, whole in the
|
||
library folder 31 s after the tap, filed as "Artist/Year - Album/… - NN -
|
||
Title.flac" with cover.jpg beside them.
|
||
|
||
**The THREE next tracks are readied while the current one plays** — the
|
||
desktop's depth (`STREAM_PRELOAD_DEPTH`), in the player's `armPreload`: a
|
||
library file is decoded and appended; a peer's copy is fetched into the
|
||
cache ahead of time; a record's later track that has no copy yet is found
|
||
first, QUIETLY (a background search never shows the resolver's line); the
|
||
immediate next is appended the moment it is byte-complete, so the boundary
|
||
is gapless. Streams wait four seconds after a start so nothing competes with
|
||
the track that is starting. Verified 2026-09-13 on a record the phone had
|
||
never played: the first track resolved and started in 14 s, the next three
|
||
were found and the first of them cached by 16 s, and the boundary crossed
|
||
gapless (`player.advance` says how each boundary was crossed, "gapless" or
|
||
"sourced" — the harness reads it).
|
||
|
||
**The library folder** (You → Library → Library folder) is picked through
|
||
Android's document tree picker in `crates/tauri-plugin-storage` (the same
|
||
plugin shape as the media session: `links`, `build.rs` with
|
||
`android_path("android")`, the capability `storage:default`). The picked
|
||
tree's content URI is kept with a persistable grant and resolved to a real
|
||
path when it is on a volume that has one (`primary:Music` →
|
||
`/storage/emulated/0/Music`; an SD card's `XXXX-XXXX:` too; a cloud
|
||
provider's tree has none and is refused), because the Rust scanner and the
|
||
download pipeline work on paths. That path becomes `download_dir` — the
|
||
folder IS the library — and a rescan follows. A folder in shared storage
|
||
needs Android's **all-files access** under scoped storage: the plugin
|
||
declares `MANAGE_EXTERNAL_STORAGE`, reports whether it is granted, and opens
|
||
the system screen that grants it (You shows the row only while the folder
|
||
is outside the app's private data and access is missing). Nothing is read
|
||
outside the folder the user picked.
|
||
|
||
**A file** from the phone is the same plugin's `pick_file`, beside
|
||
`pick_folder` and `pick_image`: Android's document picker with the caller's
|
||
own MIME filter, answered with `{ path, name, size, copied }`. It copies as
|
||
little as it can, because an image is small and an audio file is not. A
|
||
document already on this phone's storage hands its own path over as it lies
|
||
— the DOCUMENT sibling of the tree id the folder picker resolves, plus the
|
||
Downloads provider's `raw:` — so picking a 300 MB file costs nothing and
|
||
touches no cache. One that has no path (a cloud provider's, a document a
|
||
provider only streams) is copied into `cacheDir/picked` under its own
|
||
display name, and only as far as the caller's `maxCopyBytes` allows: zero
|
||
refuses the copy outright, which is what an audio-sized pick wants, and the
|
||
copy counts itself as it goes because a provider's stated size is often
|
||
absent and sometimes wrong. Those copies are swept at the start of every
|
||
pick — unlike `pick_image`'s, which live in the cache for ever — so a path
|
||
from one pick is gone by the next. The display name travels beside the path
|
||
because it is load-bearing: `playlist_formats::detect_format` reads the
|
||
extension off it and `parse` titles the draft from its stem, so a copy under
|
||
an invented name would read every playlist as plain text. The dialog plugin
|
||
cannot serve here: `tauri-plugin-dialog` does open the sheet from Rust, but
|
||
it answers with a `content://` URI, and `std::fs` cannot take one.
|
||
|
||
**Playback** (You → Playback) has the desktop's volume normalization switch
|
||
(−14 LUFS through the player crate's BS.1770 meter; on by default). There is
|
||
no graphic equalizer in the app — the "eq" in the code is the animated bars
|
||
glyph.
|
||
|
||
**Home** is the desktop's landing page on top of the library rows the phone
|
||
had: one slot per followed artist (`src/mobile/home.svelte.js` — their
|
||
catalog page, the MusicBrainz-only records, six related artists, warmed by
|
||
`home_warm`), the feed's lead on the featured surface (a new or coming
|
||
record; the newest record here when there is none), New from your artists,
|
||
the news, Jump back in, From the back catalogue, Recommended for you (each
|
||
card names the follow that earned it), Recently added, Out now, Complete the
|
||
set, Following. The rules are the desktop's, copied; a follow made anywhere
|
||
re-walks the slots (`soulseek:follows-changed`).
|
||
|
||
**Gaps** (Library → Gaps) is the desktop's gap line per shelf: only an artist
|
||
you FOLLOW is asked (an anchored identity, never a name guessed onto a
|
||
namesake), the catalog's released records minus the shelf's by loose title,
|
||
dimmed cards that open the catalog record page, the totals remembered in
|
||
`library.gaps.v1` so the line says its number before the lookup.
|
||
|
||
The server's room list on Community is filtered by name and shows 80 at a
|
||
time with a way to the rest (Soulseek lists hundreds).
|
||
|
||
Community's fourth tab is **Taste**: the interests this phone has
|
||
declared to the Soulseek server (`slsk_interests_mine`, `slsk_interest_set`)
|
||
and the people the server ranks as close to them (`slsk_similar_users`), a
|
||
row each into the peer sheet. A like or a hate is a chip and tapping one
|
||
takes it back; "Add a like" and "Add a hate" declare a new one. Every
|
||
declaration asks the server again and the chips stay quiet until it has
|
||
answered, because the ranking only moves when its input does. The lists
|
||
are this client's own — the backend keeps them and re-declares every one
|
||
at login, since the server's copy belongs to the account and another
|
||
client signed in elsewhere can change it under us. Mock-walked only: a
|
||
live sign-in and the server's own ranking are Kyle's to see.
|
||
|
||
## A peer, and their shares
|
||
|
||
A raw result's ⋯ (or a long press on its head) is the peer's sheet: play
|
||
the folder as a record, save it to the library as it is, browse their
|
||
files, message them, add or remove them as a buddy. **Browse** is the
|
||
hub's Browse tab as a screen (`screens/Browse.svelte`, `slsk_browse_user`)
|
||
— the same sheet is a buddy's on Community → Buddies (a long press), and
|
||
"Look up a user" there opens it for any name typed (the hub's browse by
|
||
name):
|
||
every folder they share with its files, sizes and formats, the totals in
|
||
the header; a folder opens to its files, plays through the stream engine
|
||
or saves whole. One request for the whole share, so a big one takes a
|
||
moment — and a share can hold thousands of folders (the first peer the
|
||
walk met: 7,511 folders, 158,937 files, 2.4 TB), so the screen shows a
|
||
filter and renders 80 at a time with a "more" button; rendering all of
|
||
them at once cost an audio dropout. The phone walk browses the first peer
|
||
of its network search.
|
||
|
||
Every name in Community can wear one you gave it. The peer's sheet ends in
|
||
**Name and notes** (`src/mobile/contacts.svelte.js`, `contacts_meta_list` /
|
||
`contact_meta_set`): a petname, a group, a private note and the two marks,
|
||
Favorite and Trusted. All of it sits in the app's own config on this phone
|
||
and is published nowhere — Soulseek never learns it, and neither does the
|
||
person it describes. The petname stands in for the username on the buddy
|
||
list, in the chat list, on a thread's bar, in the peer sheet, in a room's
|
||
roster and on a room line, and the real username stays with it — on the
|
||
line beneath, or beside the name in a room — so a stranger is never quietly
|
||
turned into someone you know. Favourites sit at the top of the buddy list,
|
||
a star and a shield mark the two, and clearing a person's details drops the
|
||
entry rather than storing an empty one — the backend's own rule.
|
||
|
||
More long presses: a catalog song in the search results opens the catalog
|
||
track's sheet (play, save, add to a playlist, the record, the artist); a
|
||
row of the queue face opens the track's sheet with Play now, Move to next,
|
||
Move up, Move down and Remove from queue on top; a raw result's file (and a file on the Browse
|
||
screen) opens its own — play from here, save this one file, browse; a name
|
||
in a room's list opens the peer sheet. The catalog record page's bar ⋯ is
|
||
the record's sheet: play, save, add every track to a playlist as streams,
|
||
the artist — and the same sheet is a long press on any catalog card (the
|
||
results' Records rail, Home's feed, the Gaps shelf). The now-playing
|
||
sheet's lyrics face has its tools on a long press or the link under the
|
||
words (`lyrics.svelte.js`, the desktop's): the offset nudged half a
|
||
second at a time and kept for the track, another LRCLIB candidate chosen
|
||
when the words are wrong, the lookup forgotten and done again. A record's
|
||
sheet re-fetches its cover (the desktop's per-record refresh); You →
|
||
Library clears the play history behind a confirm. The now-playing
|
||
sheet's ⋯ on a streamed track is the desktop's
|
||
Save menu: Save to library (at once when the cache is byte-complete, else
|
||
through the download pipeline) and Save this record for the streamed
|
||
record the queue holds; beside them, "Copy to a folder…" writes that same
|
||
cached stream into a folder picked on this phone, as the plain file it is
|
||
(`copy_stream_to_folder` — no re-download, no tagging, no library row; a
|
||
track that has not finished streaming is counted back, never fetched).
|
||
A moment after boot the phone warms the library's thumbnails the
|
||
desktop's way (`library_art_prewarm` at the cards' edge, one decode at a
|
||
time behind the tiles' gate). The network results carry the hub's filters (a free slot only; FLAC or
|
||
MP3 320+), and the lyrics sheet has the desktop's paste door (plain text,
|
||
or LRC with timestamps, kept for the track).
|
||
The rest of the lyrics tools came with it. The sheet carries the per-track
|
||
auto-sync switch (`lyrics_set_auto_sync`), so the timing correction the app
|
||
makes from a file's own lead-in can be turned off for one recording and back
|
||
on. "Re-time by hand" opens the tap-to-sync room (`SyncRoom.svelte`, one
|
||
more layer the back gesture peels): the song plays, the line to time sits
|
||
large in the middle of the screen with the one behind and the one ahead
|
||
faint around it, and a tap anywhere stamps it — the last tap undone, −5 s
|
||
and play/pause under the thumb, and Save writing the track's own document
|
||
(`lyrics_save_lines`, the top of the waterfall, where a paste lands too).
|
||
Lines never tapped are left out of it and the button says how many are kept;
|
||
a set left mid-tap by the back gesture is offered back by the row that
|
||
opened it.
|
||
|
||
The room also measures the recording. `lyrics_vocal_activity` is the
|
||
desktop's coarse vocal-band envelope — the centre channel band-passed to
|
||
180 Hz – 7.5 kHz, one value per 100 ms, read on a blocking thread — and it
|
||
draws as a strip under the head, so the singing ahead is visible while the
|
||
thumb waits for it. **Estimate the timing** lays every line across the
|
||
stretches the envelope says are sung, weighted by line length, through the
|
||
desktop's own generator (`src/lib/lyric-sync.js`, shared and unit-tested):
|
||
never an even spread over the duration, and a refusal rather than a guess
|
||
when the measurement shows nothing usable. The result is labelled an
|
||
estimate everywhere it shows — the hint under the line, the Save button —
|
||
the cursor goes back to the top so the taps correct it from there, and one
|
||
more tap on the button drops the whole thing back to an empty set (a rough
|
||
fill stamps every line at once, so Undo has nothing left to take back).
|
||
A streamed track has no finished file to measure, so the button says there
|
||
is no estimate for that one — the same rule the reduced-vocals copy follows,
|
||
and the same whole-file decode behind both.
|
||
|
||
"Play with reduced vocals" is the desktop's offline DSP
|
||
(`lyrics_prepare_instrumental` — a band-limited centre cancel, not a
|
||
separator, and the wording stays honest about that): the copy plays under
|
||
the same title, artist and duration, so the lyric identity is still the
|
||
recording's and the words keep following it, and one row puts the original
|
||
back.
|
||
One switch the desktop never needed: You → Downloads → "Downloads on
|
||
Wi-Fi only" (`network.svelte.js`, off by default) holds every Save — a
|
||
record, a track, a folder, a file, a playlist's bulk, the now-playing
|
||
Save — while the phone is on mobile data (`navigator.connection.type`);
|
||
streaming is never held back. The nav wears two badges the desktop's chrome has: transfers in flight on
|
||
You (the title bar's download ring) and unread chats on Community.
|
||
The Library's count line carries the collection's hours and the sort
|
||
offers By format (lossless by depth and rate first, then by bitrate); the
|
||
empty Search page shows Jump back in, the desktop's. The Library's cards wear the desktop's file stats (`library_album_stats`,
|
||
one call for the whole library: FLAC 24/96, MP3 320k, the size on the
|
||
list rows and the record page), and the artist page shows this phone's
|
||
own listening (`artist_play_stats`: time played as a chip, the most
|
||
played track under "On this phone"). Popular is asked on its own as well as
|
||
with the page (`catalog_artist_popular`: the Last.fm ranking and its counts,
|
||
waiting on nothing but the linked MBID, so the section can paint before the
|
||
full page is built — and an artist whose page came back with no Popular at
|
||
all still gets one), and a catalog record's rows that the album's own artist
|
||
anchor found no count for — a feature's guest, a compilation's stranger —
|
||
get the desktop's deferred pass by their OWN Deezer artist id
|
||
(`catalog_track_listeners`). An artist's photo is Kyle's to choose: the
|
||
artist page's ⋯ offers **Choose hero photo** (the phone's image picker, then
|
||
`set_artist_hero` — `pick_artist_hero`'s install step split out and shared,
|
||
so the desktop's dialog keeps its own door; 1920×800 and landscape or it is
|
||
refused) and **Remove custom hero**, which puts the catalog's own photos
|
||
back; the page is asked again either way, so the banner, the ground and the
|
||
accent all follow. The desktop's own chooser has had no home since its pill
|
||
left the glass bar — the phone has one first. You → Album artwork carries
|
||
**Refresh artist portraits** (`refresh_artist_art`), the desktop Settings
|
||
row. A `data:` URL is the image itself in `art.svelte.js` now — a chosen
|
||
hero comes back from Rust as one, and the thumbnail fetch used to throw it
|
||
away. Search corrects an obvious
|
||
misspelling from the names this phone knows (the library, the searches that worked) — the desktop's rule,
|
||
a word only when a real name is within two edits.
|
||
|
||
Tapping **About this user** on the peer sheet opens the stranger in full
|
||
(`screens/Peer.svelte`, `push("peer", { username })`): the card their own
|
||
client serves (`slsk_user_info` — what they wrote about themselves, their
|
||
picture, whether a slot is free, the queue, how much they have served),
|
||
the likes and hates they declared to the server (`slsk_user_interests`),
|
||
and the comparison this phone makes of their share against the library
|
||
(`overlap.rs`: `overlap_compute` browses them on purpose, `overlap_list`
|
||
reads what was compared before, `overlap_forget` drops it). Nothing is
|
||
uploaded to work it out — their folder names against your albums, here.
|
||
The comparison's rows are the reason to open a stranger: every record they
|
||
have by an artist in your library that you don't, each one opening the
|
||
catalog search that finds it. Their picture opens in the artwork viewer,
|
||
which takes a `data:` key now as well as a library path and an https URL.
|
||
|
||
## Playlists
|
||
|
||
The desktop's two playlist pages (`src/views/Playlists.svelte`,
|
||
`Playlist.svelte`), at phone scale, on the same commands
|
||
(`list_playlists`, `playlist_items`, `playlist_cover`, create / rename /
|
||
describe / pin / duplicate / delete, `playlist_add_items`,
|
||
`playlist_remove_items`).
|
||
|
||
- **The shelf** is Library → Playlists (`src/mobile/PlaylistShelf.svelte`):
|
||
the stat line, Pinned then All, the custom cover or the composed mosaic on
|
||
every card (fetched once per revision — `coverFor` in
|
||
`playlists.svelte.js`), a Setlist / N unresolved badge, New playlist (a
|
||
prompt sheet). A long press on a card is its sheet: Play, Shuffle, Pin,
|
||
Rename, Duplicate, Delete (a confirm sheet).
|
||
- **The page** (`screens/Playlist.svelte`) is the record page's shape: the
|
||
cover or mosaic, the kind and a Pinned chip, the description, the meta
|
||
line with its warnings (N unresolved, N missing), Play / Shuffle / More,
|
||
a filter, and the rows with the desktop's tags — `unresolved`,
|
||
`set audio`, `file missing`. Setlist cue times show only when every row
|
||
carries one. The bar's More is the page's sheet: Pin, Rename, Edit
|
||
description, Duplicate, Remove unresolved entries, Delete.
|
||
- **Playing** follows the desktop's `queueableRows`: Play queues the rows
|
||
that can play — present local files as themselves, stream rows as
|
||
path-less entries with their catalog identity, which the player resolves
|
||
on reach through `hooks.resolveEntry` (the same path a record's later
|
||
tracks take, so the three-ahead preload covers them). A tap on a weak row
|
||
with a name streams it one-off; a bare placeholder opens its sheet. The
|
||
playing row is found by `plItemId` on the queue entry. A set-recording
|
||
slice starts at its cue (`t.startSecs`) and the player moves on at the
|
||
next cue (`t.endSecs`); the scrubber still shows the whole recording.
|
||
- **A row's sheet** (`pltrack`): Play from here, Play next, Add to queue,
|
||
Save to library for a stream row, Find a local match for a row without
|
||
a file (the desktop's repair panel as a sheet: the library searched by
|
||
the row's name, a pick relinks the row through `playlist_relink_item`;
|
||
an unknown placeholder adopts the track's identity), Go to artist,
|
||
Remove from playlist; Move up, Move down and Move to the top reorder from
|
||
the sheet (`playlist_set_order`, the whole order in one call, as the
|
||
desktop's drop does) — the accessible path, and the one that stays honest
|
||
while the filter is on.
|
||
A removal is undoable for seven seconds from the neutral toast
|
||
(`notice.svelte.js` — the desktop's undo pill); the stash pins the
|
||
playlist it came from.
|
||
- **Drag to reorder** (`src/mobile/reorder.js` — one Svelte action, on the
|
||
container, for the playlist page's rows and the player sheet's queue
|
||
alike). Press and hold a row for 320ms and it lifts (with a short tap in
|
||
the phone's motor), the rows below make room, and letting go lands it.
|
||
Release without having moved and the row's sheet opens exactly as a long
|
||
press always opened it — which matters most on the queue, where a row has
|
||
no ⋯ and `oncontextmenu` is the only door to Play now, Move to next and
|
||
Remove from queue. A press that moves more than 8px before the hold lands
|
||
is a scroll, and the gesture lets go of it.
|
||
Nothing carries `touch-action: none`: a flick that starts on a row still
|
||
scrolls the list. `touch-action` is latched at touchstart and cannot be
|
||
flipped mid-gesture, so the page's own scroll is refused instead by a
|
||
non-passive `touchmove` listener added at the lift — which works because
|
||
the finger has been still for 320ms and the browser has not started a
|
||
scroll yet. A click and a contextmenu guard sit on the container in the
|
||
CAPTURE phase while a lift is live (a released drag would otherwise fire
|
||
the row's own click and play the track, and Android's own 500ms long
|
||
press would open the sheet in the middle of the drag); both are off when
|
||
no lift is live, so a tap and a long press behave as they always have.
|
||
Every row is measured once at the lift, in the scroller's CONTENT space,
|
||
so the edge pull — within 64px of either edge, ramped, up to 14px a
|
||
frame — leaves the snapshot true and the row keeps up with the finger.
|
||
Each displaced row moves by the exact difference between its own top and
|
||
its neighbour's rather than by one assumed row height (the desktop's
|
||
`rowH`), because the queue face puts an album separator between rows.
|
||
The drop reuses the mutations the Move rows already use: `moveItem` and
|
||
`moveInQueue` each take a numeric target now as well as `"up"` /
|
||
`"down"` / `"top"`, so there is still exactly one call to
|
||
`playlist_set_order` with the whole id order. It is optimistic — the rows
|
||
go where you put them at once, and come back with the error toast if the
|
||
backend says no. Two refusals guard the middle of a gesture: the playlist
|
||
drops nothing if a refetch has replaced its rows (`pls.dragging` also
|
||
defers the page's own re-read while a drag is live, the phone's version
|
||
of the desktop's `skippedWhileDrag`), and the queue drops nothing if
|
||
`player.qi` has moved, because a track boundary shifts the whole rendered
|
||
window and the absolute indices would then address the wrong rows.
|
||
Dragging is off while the playlist's filter has anything in it — a
|
||
filtered list's visible order is not the stored order — and the note
|
||
under the field says so; the sheet's Move rows address rows by id and
|
||
stay correct under a filter, which is why they are the way to reorder
|
||
without clearing it.
|
||
- **Add to library / Download all tracks** on the page's sheet — the
|
||
desktop's bulk actions (`runBulk` in `playlists.svelte.js`, the rules
|
||
verbatim): the first files every already-streamed track whose cache is
|
||
byte-complete, the second finds a source for every stream row (the
|
||
remembered one, else the ladder) and hands it to the download pipeline;
|
||
the page shows the run's line with Stop, then its summary.
|
||
- **The cover**: Choose cover photo on a playlist's sheet opens the phone's
|
||
image picker (the storage plugin's `pick_image`: the picked content is
|
||
copied into the app's cache, and `playlist_cover_set` installs it the way
|
||
the desktop's dialog pick does — squared, the master cover, the row's
|
||
revision moved); Remove custom cover brings the mosaic back.
|
||
- **Add to playlist** is on every track and record sheet: a pick-a-playlist
|
||
sheet (New playlist at the top). A file on the phone becomes a local row;
|
||
a streamed or catalog track keeps its identity (`track_id`) and cover URL
|
||
and becomes a stream row.
|
||
- Every mutation re-lists through `soulseek:playlists-changed`, which the
|
||
desktop's commands already emit; the page re-reads its rows when the
|
||
playlist's revision moves.
|
||
- **Importing** (`importer.svelte.js`, `screens/Import.svelte`): the
|
||
shelf's Import opens five doors — a playlist file on this phone (M3U ·
|
||
M3U8 · PLS · XSPF · CSV · JSON), a link (YouTube · Spotify · Apple
|
||
Music · TIDAL · Deezer; a YouTube playlist link rides this one too, the
|
||
backend dispatches it by host), a pasted tracklist (plain lines, a
|
||
timestamped setlist, or track links), a YouTube DJ set's posted
|
||
tracklist, and a folder of audio files on this phone. A paste holding
|
||
even one track link takes the link path (`playlist_import_track_links`,
|
||
the desktop's own detector verbatim): each line is read off its own
|
||
public page — the way past Spotify's 100-track public ceiling — and the
|
||
wait screen counts them as they come. The folder door picks with the
|
||
storage plugin's document-tree picker and hands the resolved path to
|
||
`playlist_import_folder`, which grew a `path` argument for it: on the
|
||
phone that command had no dialog to open and always answered nothing. The
|
||
file door does the same through the plugin's `pick_file`, and
|
||
`playlist_import_probe` grew `path` AND `name` for it — the picked
|
||
document's own display name is what `detect_format` reads the extension
|
||
off and what titles the draft, and a document this phone had no path for
|
||
arrives as a cache copy under a sanitized filename. A copied file's rows
|
||
still find the library: a relative locator that misses falls back to the
|
||
basename index, which is the one thing that reads differently from the
|
||
desktop, and the sheet says so.
|
||
The backend stages the draft over plain HTTP (the same commands as the
|
||
desktop, no yt-dlp involved), the review screen shows every row's
|
||
verdict, the stream rows are verified against the catalog while it is up
|
||
(the desktop's pass, verbatim), and Create swaps the review for the
|
||
playlist it made (`nav.replace`).
|
||
- **Exporting** (`playlist_export`, the playlist's own sheet and the shelf
|
||
card's): the desktop's five profiles, in the desktop's order and its own
|
||
words — M3U8, XSPF, PLS, CSV and the backup JSON. The file is built in
|
||
Rust by `playlist_formats`, ABOVE the platform fork, so the phone never
|
||
rebuilds a format and the two exports cannot drift. The doors differ
|
||
because the answers do: `tauri-plugin-dialog` does open Android's
|
||
create-document sheet, but it answers with a `content://` URI and
|
||
`std::fs::write` cannot take one, so on the phone the bytes go to
|
||
`tauri-plugin-storage`'s `create_document` and its Kotlin writes them
|
||
through the ContentResolver (`openOutputStream(uri, "wt")`), wherever the
|
||
sheet pointed. That is a plugin function called from Rust, not a command:
|
||
the file never passes through the WebView, and nothing was added to the
|
||
ACL. A dismissed sheet resolves nothing and the phone says nothing —
|
||
which is the desktop's `Ok(None)`. A row with no local file exports
|
||
exactly as it does on the desktop: named, with a comment where the
|
||
location would be (M3U8), carried without a location (XSPF, CSV, the
|
||
backup), or left out (PLS, which holds only real files). The locations
|
||
that ARE written are this phone's own paths, inside the app's folder —
|
||
the format behaving correctly, not a bug to rewrite.
|
||
- **The share sheet** (`crates/tauri-plugin-share`, `share.svelte.js`):
|
||
Share → SoulStream from any app. The plugin's `ShareActivity` (an
|
||
ACTION_SEND text/plain target, merged into the app's manifest from the
|
||
plugin module) keeps the text and brings the app's own task to the front
|
||
through its launcher intent; the WebView asks `pending_share` at boot and
|
||
whenever it comes back to the front, and a link opens the link door (the
|
||
first URL in the text — Spotify shares the link inside a sentence),
|
||
anything else the pasted-tracklist door. The phone walk sends the intent
|
||
itself (`adb shell am start -a android.intent.action.SEND …`).
|
||
|
||
Not on the phone yet: the listener door (it needs
|
||
yt-dlp, a desktop tool), and Share on SoulStream.
|
||
|
||
## The first run
|
||
|
||
Every walk and every deploy so far started from Kyle's own phone — signed
|
||
in, library folder set, a config full of his settings. What a phone that
|
||
has just installed the app actually has is different in three ways, and
|
||
only one of them is what you would guess.
|
||
|
||
**The library root is never missing.** `config.rs`'s `download_dir` has an
|
||
`#[cfg(target_os = "android")]` branch that falls back to
|
||
`app_data_dir()/Library` and returns before the desktop's `audio_dir()`
|
||
fallback is reached, so a fresh install has a real, writable, empty library
|
||
root at `/data/user/0/com.soulstream.app/files/Library` — invisible to
|
||
every other app on the phone, including the one that put the music there.
|
||
`pipeline::init` scans it at startup and `library_albums` honestly answers
|
||
`[]`. This is why "no folder yet" cannot be a null check anywhere: the test
|
||
is the PATH, `isPrivateFolder` in `setup.svelte.js`, which You.svelte had
|
||
first and now imports rather than keeping a second copy of.
|
||
|
||
**`setup_done` was already there and the phone never touched it.** It
|
||
round-trips through `get_settings`/`set_settings` like any other field and
|
||
`config::setup_done` unwraps a missing one to `false`, so **every phone in
|
||
existence reads as unstamped**, Kyle's included. Gating the tour on that
|
||
flag alone — which is what the desktop does — would have opened a setup
|
||
sheet on a phone already full of his own music, and then written the flag
|
||
into his config on dismissal.
|
||
|
||
**Nothing is signed in, and everything says so honestly and helpfully
|
||
nowhere.** `connectStored()` returns at once with no account saved, the
|
||
connection stays `offline`, and every stream and every Save ends as a red
|
||
toast with a sentence in it and no way out.
|
||
|
||
So: `src/mobile/setup.svelte.js` is the one store, and there are two
|
||
surfaces.
|
||
|
||
**The tour** is a half sheet (`kind === "setup"` in `HalfSheet.svelte`) —
|
||
one scroll, two cards, a foot. Where your music lives: the current folder,
|
||
a row that opens the storage plugin's folder picker, and the all-files
|
||
permission row beside it once a real folder has been picked. Soulseek: what
|
||
it unlocks, and one row that goes to the sign-in card on Community. **The
|
||
tour never handles a password** — there is no field in it; Kyle signs in
|
||
himself, on the card that already existed. Keeping the private folder and
|
||
staying signed out are both legitimate answers, so the sheet is answerable
|
||
by doing nothing, and the scrim and the back gesture close it like any
|
||
other half sheet.
|
||
|
||
**The boot gate is a three-way AND** — `!setup_done && !slsk.savedUser &&
|
||
!lib.albums.length` — and it is evaluated only after `loadLibrary()` and
|
||
`connectStored()` have both settled (`Promise.all` in `Mobile.svelte`'s
|
||
`onMount`). Read either of those early and they are `[]` and `""` on every
|
||
phone there is. Kyle's phone fails the second and third clause, which is
|
||
the only safe way to ship an auto-opening layer into a pipeline that
|
||
installs onto his device.
|
||
|
||
**The note** (`SetupNote.svelte`) is the fallback for the screens that are
|
||
empty because of the same two things, with the row that fixes each: Home's
|
||
empty state (whose copy still said music "lives in the app's own Library
|
||
folder for now", written before the picker landed in You), Library →
|
||
Albums, Library → **Artists** — which had no empty state at all, just a
|
||
`0 artists · 0 followed` line over nothing — and Search's empty page, where
|
||
"Sign in on Community to play what the library does not hold" was a flat
|
||
sentence and is now the row that does it. A row is drawn only for a thing
|
||
actually missing, and the component renders nothing when both are answered,
|
||
so a phone that is set up never sees it.
|
||
|
||
There are two doors onto the same folder — the tour's row and You's own
|
||
picker — and they tell each other: You re-reads when `setup.folder` moves,
|
||
and its picker calls `loadSetup()` on the way past, or the note on Home
|
||
would go on offering a folder that had already been picked on You. The
|
||
all-files row does the same trick You does with the grant: the system
|
||
screen takes the app away and gives it back, so `requestAccess` re-reads
|
||
the answer on the way back rather than leaving a row up that has been
|
||
answered.
|
||
|
||
**The preview door** is one row in You → This phone: "Set up this phone
|
||
again", `openSetup("preview")`. A preview reads the config and writes
|
||
nothing — `setup.writes`, the number of times the store has stamped
|
||
`setup_done`, stays where it found it. The phone walk drives exactly that
|
||
door on Kyle's phone and asserts the counter at zero on the way in and on
|
||
the way out; it types nothing, picks nothing and clears nothing.
|
||
|
||
The harness hosts the other device. `localStorage.mockFresh`, or `#fresh`
|
||
in the URL (which is what the walk uses — a reload cannot lose a fragment),
|
||
switches `src/lib/mockLibrary.js` to a fresh install: every library
|
||
command answers `[]`, `get_credentials` answers with no account,
|
||
`all_files_access` is not granted, and `mockSettings` is mutated in place
|
||
so the library root is the sandbox path and `setup_done` is false —
|
||
mutated rather than replaced so `set_settings` still works and picking a
|
||
folder in the walk genuinely moves the state the notes read. The
|
||
fresh-install block is the LAST thing in `MOCK_STEPS` because its reload
|
||
wipes every module's state, and it turns the flag off and reloads again at
|
||
the end so that anything appended after it is not silently running against
|
||
an empty device.
|
||
|
||
Two things a reload does NOT clear on its own, both handled in that block
|
||
because it is the first one on the mock leg. The webview's storage comes
|
||
back with the page, and the player writes the session out on `pagehide`,
|
||
which a reload fires — so removing the key up front puts it straight back;
|
||
the block adds a `pagehide` listener of its own, last and therefore run
|
||
last, and a phone with nothing in it comes up with no now-playing bar. And
|
||
the viewport emulation lives on the DevTools session rather than on the
|
||
page, so the re-attach after a reload drops it: the walk keeps what it set
|
||
and hands it back, or every still from there on would be the Chrome
|
||
window's own size at 1×.
|
||
|
||
Two commands the phone can reach had no mock at all until this round —
|
||
`player_control` and `open_external` — found by mapping every `api.js`
|
||
wrapper used under `src/mobile` against the mock (126 wrappers, 124
|
||
mocked). Unmocked they fall through to the real IPC, which on a `file://`
|
||
page rejects: a console warning and a toast, in whatever step happens to
|
||
touch them first. The transport now answers the way the real core does,
|
||
with an event; `resume` cannot restart the position ticks, so the state is
|
||
right and the position stands still.
|
||
|
||
## Settings that reach the config
|
||
|
||
`set_settings` takes **camelCase** arguments (Tauri's rule for command
|
||
parameters): `{ volumeNormalization, downloadDir, streamSourcePrefs,
|
||
downloadSourcePrefs }`. The phone's first You screen sent snake_case keys
|
||
and every save was silently ignored (found 2026-09-13 while adding the
|
||
Sources group — the walk now flips the switch and a chip and reads each
|
||
back from the config). You → Sources is the desktop's Source quality
|
||
section: per mode (streaming, downloads) the ceiling (no limit / lossy
|
||
only), the floor (any … lossless only) and the priority (best quality /
|
||
fastest transfer); the resolver re-reads them (`refreshSourcePrefs`).
|
||
You → Streaming is the desktop's Streaming section: how long the stream
|
||
cache keeps a track after its last play (or Forever — everything streamed
|
||
goes to the library) and the cache's size limit. Downloads (the album
|
||
folder format), Sharing (the library folder is always shared, the upload
|
||
slots, extra shared folders picked on the phone), Lyrics (word-by-word
|
||
fill) and Album artwork (canonical vs the uploader's art, the embedded
|
||
cover size, the folder cover, re-fetch all covers) follow the same
|
||
sections of the desktop's Settings. Not on the phone: the account (sign-in
|
||
lives on Community), the networks, updates (the APK comes from the build),
|
||
the lyric alignment models (a desktop runtime), artist artwork's upscaler.
|
||
Search's empty page lists the recent searches this phone used (the last
|
||
eight: Enter, or the screen left with a query in it), kept on the device.
|
||
|
||
Sharing carries one thing the desktop never exposed at all: the listening
|
||
port (`listenPort`), what other peers dial when they download from you.
|
||
Unset means the client's own 50300 (`DEFAULT_LISTEN_PORT`; bind walks
|
||
forward a few ports if it is taken); the app asks the router to forward it
|
||
over UPnP and falls back to the server relaying the connection, and a
|
||
change applies on the next connect — on mobile data the carrier's NAT
|
||
blocks incoming connections whatever the number is. `set_settings` only
|
||
assigns a port that is Some, so it can be changed but never taken back to
|
||
unset: the phone walk saves a spare number and then 50300, which is the
|
||
port an unset config already used.
|
||
|
||
The dev flavour gets one more row under This phone: Diagnostics
|
||
(`screens/Diagnostics.svelte`), the desktop's Ctrl+Shift+D overlay as a
|
||
screen — process and system CPU, memory, the network rates, the track
|
||
playing with its loudness correction, the tracks the pre-buffer is
|
||
fetching, and how big the play history has grown (`debug_stats`,
|
||
`play_row_count`), polled once a second while it is open and never
|
||
otherwise. `devBuild` in `util.js` is the gate: Vite's own DEV flag, true
|
||
only for the build that loads from the dev server over the cable, so the
|
||
embedded build the phone keeps has no row to tap. The GPU counters are
|
||
Windows PDH and the private commit is a Win32 call, so neither is on the
|
||
screen — off Windows they report None and 0.
|
||
|
||
## Background playback and the lock-screen card
|
||
|
||
The service is started with `startForegroundService`, so it promotes itself
|
||
FIRST in `onStartCommand` — with the card there is, or a plain one — and
|
||
only then mirrors the state, stepping back (`STOP_FOREGROUND_DETACH`) when
|
||
the track is paused or stopping when the bar is empty. Android 14 kills the
|
||
whole app about ten seconds after a foreground start that never called
|
||
`startForeground()`, and every restart restores a PAUSED track, which is
|
||
exactly that case: on 2026-09-13 the first real Save died with the process
|
||
that way, four tracks in. The check: force-stop, relaunch with a paused
|
||
track, wait 25 s, the pid survives.
|
||
|
||
`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs`
|
||
hands its `android/` Gradle module to tauri-build (which writes it into
|
||
`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin
|
||
class and exposes `update` / `clear`, and the Kotlin side is two files —
|
||
the plugin (arguments in, the `action` event out) and `MediaService`, a
|
||
`mediaPlayback` foreground service holding a `MediaSessionCompat` and a
|
||
MediaStyle notification. Playing → `startForeground`; paused → the service
|
||
steps back and the card stays, dismissable. Android 13+ asks for
|
||
`POST_NOTIFICATIONS` the first time something plays. Audio focus is
|
||
requested while playing (loss pauses, a transient loss pauses and resumes),
|
||
and headphones coming out pause. The app's capability grants
|
||
`media-session:default`; the plugin is registered on every platform so the
|
||
permission resolves everywhere, and on the desktop its commands answer
|
||
"not here" (the desktop has `smtc.rs`).
|
||
|
||
A trap: tauri-build regenerates `gen/android/tauri.settings.gradle` and
|
||
`app/tauri.build.gradle.kts` — the lines that include every plugin's Android
|
||
module — ONLY when `TAURI_ANDROID_PROJECT_PATH` names the project, which the
|
||
Tauri CLI sets and a hand-driven cargo build does not. Without it the plugin
|
||
compiled, the APK packaged without its Kotlin, and the app died at start-up
|
||
with a `ClassNotFoundException`. `src-tauri/android/build.sh` exports it;
|
||
after adding a plugin, touch `src-tauri/build.rs` too, so the app's build
|
||
script re-runs and rewrites the wiring.
|
||
|
||
## What a screen reader says
|
||
|
||
Every control on the phone is named by what it does, in the app's own
|
||
words, and where the desktop already has a word for something it is the
|
||
word used here too — Seek, View album artwork, Close artwork viewer. An
|
||
icon-only control carries an `aria-label`; one with visible text is named
|
||
by the text it shows.
|
||
|
||
The nav is a tab set: `role="tablist"` on the bar, `role="tab"` and
|
||
`aria-selected` on the five destinations, and the count badge round
|
||
seventeen added is part of the tab's own name — "You, 3 transfers",
|
||
"Community, 2 unread" — rather than a digit read out before the word it
|
||
belongs to. The badge itself is `aria-hidden`, because the number is in
|
||
the name now.
|
||
|
||
The two sheets are dialogs: `role="dialog" aria-modal="true"`, the player
|
||
sheet named for what is playing, the half sheet reading its own first
|
||
title back out of the DOM so that twenty-seven kinds of sheet need no list
|
||
of names to keep in step. Opening one puts the focus somewhere sensible —
|
||
the player sheet on its Close, the half sheet on its grab handle, which is
|
||
a real button now ("Close this sheet") and the first dismissal a screen
|
||
reader could ever reach; the scrim behind it never was one.
|
||
|
||
A sheet that is down is `inert` and `aria-hidden`. Neither sheet is
|
||
unmounted — they are translated off the bottom of the screen, and the half
|
||
sheet deliberately keeps its last body rendered through the slide down —
|
||
so before this the closed sheets' rows, the hidden mini bar and the nav
|
||
under an open sheet were all still in the accessibility tree, and TalkBack
|
||
could swipe into every one of them.
|
||
|
||
There is one live region, `.sr-live`, mounted for the life of the app and
|
||
clipped to a single pixel, because a region that appears with its words
|
||
already inside it is announced reliably by nobody; an announcement here is
|
||
a change of text in a node that was always there. That is what carries an
|
||
error and a notice, so an Undo offer is actually read out — and the Undo
|
||
button itself stays reachable, only the sentence beside it is hidden from
|
||
the tree so it is not said twice.
|
||
|
||
Artwork that opens the viewer is a control, not decoration. Artwork that
|
||
is decoration carries `alt=""` and every icon is `aria-hidden`, so the
|
||
tree never reads a filename out. The waveform is `role="slider"` with a
|
||
value and arrow keys where it can seek, and `aria-hidden` on the mini bar
|
||
where it is only a picture of the position. Switches say on or off, choice
|
||
chips say pressed, and the two long descriptions and the gap line say
|
||
expanded.
|
||
|
||
The check, so this does not quietly rot: the walk's probe sweeps every
|
||
interactive element in the live DOM and counts four things — the ones with
|
||
no accessible name (`a11yAnon`, with `a11yWho` naming the first three
|
||
offenders so one run tells you what to add), the ones whose name is junk
|
||
(`a11yBad`: bare digits, a filename, a lone glyph, an essay), the ones
|
||
sitting in a layer that is off-screen but still in the tree (`a11yGhost`)
|
||
and the ones that look chosen without saying so (`a11yState`). All four
|
||
are asserted at zero on Home, the library, a record, a catalog record, the
|
||
artist page, You, the taste page, a half sheet, the player sheet and the
|
||
viewer — and the anonymous count on a room and on both shapes of prompt
|
||
besides, because a room's message box and the prompt's own field are where
|
||
the fields this round finally named actually live: assert it only where
|
||
they are not and the round guards everything except its own work.
|
||
|
||
One known exception, left alone on purpose: the per-row ⋯ is a
|
||
`role="button"` inside the row's own `<button>`, and the mini bar's three
|
||
transport buttons sit inside its `role="button"` the same way. That is
|
||
invalid ARIA, and both ends are named so nothing is anonymous; unpicking
|
||
it means splitting `.trk` (one flex row with a whole-row `:active`) and
|
||
rewriting the walk's `.trk .r` selectors, for no gain a screen reader
|
||
would notice.
|
||
|
||
## When things go wrong
|
||
|
||
Thirty rounds built the happy path. A phone is also in a lift, signed out, on
|
||
a dead cell, or holding a library folder that has gone away, and until this
|
||
round every one of those showed up as an empty box, a spinner with no end, or
|
||
a Rust string in a red toast.
|
||
|
||
**One translator**, `src/mobile/trouble.js`. `say(e)` takes what the backend
|
||
actually hands up — `ClientError`'s Display impls (`io: {0}`, `protocol: {0}`,
|
||
`login failed: INVALIDPASS`, `user {0} is offline or unknown`, `transfer
|
||
rejected: {0}`, `timed out`), the commands' own literals (`not connected to
|
||
Soulseek`, `no stored credentials`, `no cache dir`, `could not reach server:
|
||
{e}`), and rusqlite or `std::io` through `e.to_string()` — and answers with a
|
||
sentence a person can act on. The rule that matters: **the stores keep the raw
|
||
string and only the DRAWING is translated.** `player.error` still holds what
|
||
Rust said, Diagnostics and the walk still read it, and `say()` is called at the
|
||
one place the toast is written — which is why fifty-nine `String(e)` sinks were
|
||
fixed without touching fifty-nine call sites, and why the sixtieth is covered
|
||
too. A message the backend already wrote in English ("The playlist needs a
|
||
name") matches no rule and passes through untouched.
|
||
|
||
The worst one it retires was on the sign-in card: a phone with no DNS drew, in
|
||
red under the password box, `could not reach server: io: failed to lookup
|
||
address information: no address associated with hostname`. It now says
|
||
"Couldn't reach the Soulseek server. Check the connection and try again."
|
||
|
||
The red toast gained the notice's shape — the sentence, and one action beside
|
||
it. **Try again** when retrying is the answer (a resolve that found no sources,
|
||
a library read that failed at launch), **Sign in** when `signedOut()` says the
|
||
trouble is that nobody is. The action is tied to the exact message it belongs
|
||
to (`player.retry = { for, fn }`), so a later, different error can never
|
||
inherit the last one's button.
|
||
|
||
What each screen says now, where it used to say nothing or worse:
|
||
|
||
- **Tracks** — a rejected `library_track_index` drew "0 tracks · 0:00 · unable
|
||
to open database file" and then "No tracks match.", which is a failed read
|
||
dressed up as an empty library. Now: "Couldn't read the library index", the
|
||
translated reason, and a Try again. "No tracks match" only when the read
|
||
SUCCEEDED.
|
||
- **A playlist** — a rejected `playlist_items` said "This playlist is empty."
|
||
Empty now means empty; failed says so, with a Try again.
|
||
- **A catalog record** and **an artist page** — the catalog's own error is
|
||
translated, the record's stub offers a Try again, and the artist's stub says
|
||
"the catalog didn't answer" rather than "had nothing to add" when that is
|
||
what happened.
|
||
- **Gaps** (Library) — the lookup's `catch` was empty, so the line flipped back
|
||
to "See what's missing" and could be tapped for ever. It now says "Couldn't
|
||
reach the catalogue — tap to try again".
|
||
- **Home** — `home.svelte.js` sets `slot.error` and nothing read it: offline
|
||
with follows, Home drew empty rails and said nothing. One line under the lead
|
||
now does, and points at the library below.
|
||
- **Search** — the whole network section sat inside `{#if online}` with no
|
||
`{:else}`, so signed out with a query typed there was no row and no reason,
|
||
just a gap. Now there is a line and a door to Community.
|
||
- **The lyrics face** — a lookup that FAILED was drawn as "No lyrics found for
|
||
this track.", and that branch was also the only one of the four with no `⋯`
|
||
button, so Paste lyrics was unreachable exactly when you wanted it. Both are
|
||
fixed: a failure says so, and both branches carry the `⋯`.
|
||
- **Transfers** — `failed · <rust>` on a row is `failed · <sentence>`.
|
||
|
||
**Two spinners were given an end.** `request_room_list` is fire-and-forget over
|
||
the wire (crates/soulseek-client/src/lib.rs — the client sends `room_list` and
|
||
there is no reply channel and no timeout), so "Asking the server for its
|
||
rooms…" could sit there for ever. `slsk.roomsWaitMs` (12 s) arms a timer in
|
||
`refreshRooms()`, the `soulseek:room-list` handler clears it, and a server that
|
||
never answers gets "The server hasn't sent its room list." and a Try again.
|
||
Tracks' own "Reading the index…" has its failure branch above. Everything else
|
||
was measured and left alone because it is already bounded: `user_info` 30 s,
|
||
`user_interests` 20 s, a browse 45 s, a login 15 s, a net search 15 s.
|
||
|
||
**One tap could wipe the library.** `rescan_library` (src-tauri/src/pipeline.rs)
|
||
ran `prune_missing()` FIRST, and `prune_missing` deletes every track row whose
|
||
path does not `exists()`, then every album left with no tracks. With the root
|
||
unreachable — an unmounted card, a revoked SAF grant, the drive-letter class of
|
||
trouble — You → **Rescan library** emptied the whole index, silently, and
|
||
returned `Ok(())`. There is now a guard above the prune: make the root if it is
|
||
merely not there yet (a fresh install still works), and refuse with
|
||
`library-folder-missing: <path>` if it cannot be made or read. You catches that
|
||
into a row under Library folder with the path and what to do about it, and the
|
||
toast says it in English.
|
||
|
||
**What the walk has seen.** The round that wrote all of the above was straight
|
||
about what it had not proved: six of these fixes shipped with no expectation at
|
||
all, because neither `loadHome` nor `loadLyrics` was reachable from the `__ss`
|
||
handle the walk drives the app through, and both answer a second ask with
|
||
nothing — `loadHome` is gated on `started`, `loadLyrics` on the track's own
|
||
`key` — so no amount of tapping re-runs them. They are driven now: Home's
|
||
`catalogDown`, the Gaps line, a catalog record's stub, the artist page's two
|
||
different sentences, a playlist that would not read, the lyrics `failed` face,
|
||
and `failed · <sentence>` on a transfer row. Each is armed with
|
||
`__ssMock.fail`, asserted both by the words a person reads and by the Rust
|
||
string being absent from them, and disarmed by the step that proves the face
|
||
heals. The handle grew four names for it and no more — `home` and `loadHome`,
|
||
`lyr` and `loadLyrics` — because a test seam that exports the internals
|
||
wholesale stops being a seam. The toast's own **Try again** is pressed for the
|
||
first time too, through You → **Rescan library**: `lib.error` is written by
|
||
`loadLibrary` alone, and that row is the only door in the app that reaches it
|
||
a second time.
|
||
|
||
What is still unseen, honestly. **Three sites draw a raw backend string**, so
|
||
the rule above has holes: `screens/Browse.svelte` — a peer's shares that would
|
||
not read, which is a full error face, Try again and all, that was simply missed
|
||
— and `screens/Import.svelte`, twice. `SyncRoom.svelte` is a fourth, by way of
|
||
`saveTapSync`'s catch. **Six error faces have no expectation either**: Browse's,
|
||
Community's "The server did not answer.", Room's "Not in this room.", Album's
|
||
"That record is gone.", Playlist's "Playlist not found." and Peer's "Not
|
||
compared yet." — all reachable, the first two through the mock's weather and
|
||
the rest by pushing a nav entry with a dead id. And of the probe's fields, six
|
||
are still read out on every step and asserted on none: `accent`, `xlive`, `hd`,
|
||
`hwant`, `navMs` and `a11yWho`. The cheapest real win left in the harness is a
|
||
global `hd === hwant` check on every named step — the history mirror is how the
|
||
blank page after a confirmed delete was found — but it would fire during the
|
||
first-run reloads and needs its own thinking about the suppress window first.
|
||
|
||
## The downloads in the shade
|
||
|
||
Transfers is a screen inside the app; the desktop says what a Save is doing
|
||
with the ring in its title bar, the phone with a badge on the nav. Neither
|
||
is anywhere to be seen once the app is in the background, which is exactly
|
||
when a Save is left to run on its own. So a running Save now shows in the
|
||
shade: how many files are still in flight, the one it is on, a progress
|
||
bar, and two buttons — **Stop all** and **Open**.
|
||
|
||
Its own channel ("Downloads", `IMPORTANCE_LOW`, no badge, private on a
|
||
locked screen, since a filename is nobody else's business), separate from
|
||
Playback so one can be silenced without the other, and created when the app
|
||
starts rather than when the first download does — a user who wants the
|
||
downloads quiet should not have to wait for one to be noisy first.
|
||
|
||
Its own service too, and that is the part worth spelling out.
|
||
`foregroundServiceType` is per-service: a download is `dataSync`, playback
|
||
is `mediaPlayback`. `MediaService` stops itself the moment the bar empties,
|
||
so folding the notice into it would take the card down mid-Save; keeping it
|
||
alive for a download instead would leave a `mediaPlayback` service running
|
||
with no session, which Android 14+ treats as a violation. And nothing else
|
||
keeps the process alive for a Save with nothing playing — which is exactly
|
||
how the first real Save died on 2026-09-13, four tracks in. A `dataSync`
|
||
foreground service that exists while downloads are in flight is the
|
||
structural fix, and it can only be its own service. The same promote-first
|
||
rule applies: `onStartCommand` calls `startForeground()` before it looks at
|
||
anything, because Android kills the app about ten seconds after a
|
||
foreground start that never did. Android 15 caps a `dataSync` service at
|
||
six cumulative hours a day and kills the app if it is still up when the
|
||
callback returns, so `onTimeout` takes the notice down rather than being
|
||
taken down with it; the fetches are the Rust core's own and Transfers still
|
||
lists them.
|
||
|
||
The buttons come back as the plugin event `action`
|
||
(`addPluginListener("transfer-notice", "action", …)`). **Stop all** runs
|
||
`cancelAll()` in `transfers.svelte.js`, which is the same `cancel(id)` a
|
||
row's tap calls, once per live row — not a second path to the same thing.
|
||
**Open** brings the activity forward and puts the You tab on Transfers, and
|
||
the notice's body does the same. Transfers grew the matching button: Cancel
|
||
all while anything is in flight, beside Clear finished.
|
||
|
||
`src/mobile/xfernote.svelte.js` is the mirror, and it is driven by a Svelte
|
||
effect over the transfer store rather than off the `soulseek:download`
|
||
event. That is deliberate twice over: the effect tracks progress as well as
|
||
arrivals, and it means a row written straight into the store drives the
|
||
notice exactly as a fetched one does — which is how the walk exercises this
|
||
without fetching anything. Posts are throttled to one per 700 ms of changed
|
||
content; download progress arrives far faster than anyone reads it, and the
|
||
system rate-limits (and drops) notifications posted in a flood.
|
||
|
||
`crates/tauri-plugin-transfer-notice` is shaped like the media-session
|
||
plugin: `build.rs` hands its `android/` Gradle module to tauri-build,
|
||
`src/lib.rs` registers the Kotlin class and exposes `update` / `clear`, and
|
||
the Kotlin side is two files — the plugin (arguments in, the `action` event
|
||
out) and `TransferService`. The app's capability grants
|
||
`transfer-notice:default`; the plugin is registered on every platform so
|
||
the permission resolves everywhere, and on the desktop its commands answer
|
||
"not here". The tauri-build trap above applies to this plugin as well: the
|
||
Android module only reaches the app project when
|
||
`TAURI_ANDROID_PROJECT_PATH` is set, which is why adding it also touched
|
||
`src-tauri/build.rs`.
|
||
|
||
## Performance
|
||
|
||
`tests/mobile/perf.mjs` logs the frames the WebView produces through
|
||
scripted scenes (a long scroll, Home's scroll, a record push, the player
|
||
opening, idle playback) under CSS variants injected live, so an effect's
|
||
cost is measured rather than guessed. On the Pixel 6a the frosted chrome —
|
||
`backdrop-filter` on the nav, the mini bar and the glass bar — turned
|
||
Home's scroll from 60 Hz to 30 Hz (16 of 129 frames over 32 ms; 0 without)
|
||
and dropped every other frame of the player's opening (12 → 0). The
|
||
ambience blur, the hero's blur and the entrance springs cost nothing
|
||
measurable. So the phone's chrome is solid by default and the blur is a
|
||
switch (You → Appearance → Glass blur). Re-run the probe before adding any
|
||
backdrop blur to the phone; `--quick` runs the base variant only, and the
|
||
app must be on the phone's screen.
|
||
|
||
The launch itself had never been measured. The bundle the phone loads is
|
||
`mobile.html` and three chunks: its own (about 300 KB), the shared one
|
||
(about 130 KB) and a small third (about 27 KB) — roughly 460 KB of JS and
|
||
57 KB of CSS, all of it eager. The static graph from `src/mobile-main.js`
|
||
outward is 64 modules and contains no dynamic import at all, so there is one
|
||
chunk and the phone evaluates every byte of it at every launch. Importing
|
||
that graph in headless Chrome costs 11.6 ms at full speed and 59.2 ms at a
|
||
6× CPU throttle.
|
||
|
||
The desktop is not riding along, which was checked rather than assumed: of
|
||
`state.svelte.js`'s 44 long string literals exactly four appear in the
|
||
chunks the phone loads, and all four are event names it legitimately uses.
|
||
`heroGpu.js`, `trace.js`, `ImportWindow.svelte`, `TitleBar` and
|
||
`DebugOverlay` contribute nothing; there is no shader source anywhere in the
|
||
bundle. The one passenger is the dev mock library
|
||
(`src/lib/mockLibrary.js`, about 32 KB of the shared chunk), which
|
||
`src/lib/api.js` and `src/lib/events.js` import unconditionally because
|
||
`mockInvoke` has to answer synchronously. It costs 6.6 ms of every launch at
|
||
6× throttle, only 1.4 ms of which is its top-level work — the rest is parse
|
||
and function definition, which only removing it from the bundle can recover.
|
||
`import.meta.env.DEV` cannot do that, because the mock walk builds
|
||
`dist-smoke/` with a plain production `vite build`; getting it out needs a
|
||
build-mode flag the walk itself would have to pass. Named here rather than
|
||
taken.
|
||
|
||
The boot chain was the expensive half. It is 26 commands the census can see,
|
||
plus two plugin listeners that register through `@tauri-apps` directly and go
|
||
past it. Until this round its critical path — the longest chain where each
|
||
call was issued only after the one before it had answered — was ten deep,
|
||
because `listen` is an invoke like any other: `loadLibrary` asked for five
|
||
independent SQLite reads one after another, `bindSlsk` registered eight
|
||
listeners one after another, and `watchLibrary` and the player's `bindEvents`
|
||
each did two. They go out in one round now, and the difference is countable
|
||
rather than felt: ten of those commands used to leave in the shell's own
|
||
first turn and twenty-three do now, and the critical path is three instead
|
||
of ten. Measured in a rig that runs the real store modules —
|
||
both trees, the same onMount script — with the bridge instrumented and the
|
||
backend's work modelled behind the library's single `conn: Mutex<Connection>`:
|
||
the same commands both ways, and `lib.loaded` 85 ms → 53 at a 6 ms bridge
|
||
round trip, 105 → 57 at 12 ms, 148 → 62 at 20 ms. Those are a desktop rig's
|
||
milliseconds with the round trip swept rather than measured; the structural
|
||
half holds anywhere, and the phone's own numbers arrive the first time the
|
||
walk prints `bootMs` and `bootLibMs`.
|
||
|
||
Three things were looked at and deliberately left alone. Home's per-artist
|
||
catalog walk is the deepest chain in the app — `catalog_artist` →
|
||
`catalog_artist_identity` → `catalog_related`, serially, per followed artist
|
||
— and that is the pacing, not an oversight: `crates/metadata/src/lib.rs`
|
||
holds a Deezer budget of 45 requests per 5 s and records the MusicBrainz
|
||
identity chain at 4–6 requests behind a mandatory 1 req/s gate, 3.7–8.5 s
|
||
per artist. The follows are read twice at launch (once by `loadLibrary`,
|
||
once by `loadHome`), and the obvious guard does not work, because Home's
|
||
effect calls `loadHome(true)` the first time the follows land — one small
|
||
local read, left for a round that can do it properly. And lazy-loading the
|
||
one-tab screens (Diagnostics, Import, Browse, Peer, Room, Thread and the
|
||
importer) is 9% of the source against a real risk of a blank pushed frame,
|
||
so it is worth about 6 ms at 6× throttle and is not worth a dynamic import
|
||
yet.
|
||
|
||
The instrument is `src/lib/boot.js`: one counter every `invoke` and every
|
||
`listen` passes, sealed at the first frame the shell paints and never
|
||
re-opened, so a probe landing a second and a half later still reads the
|
||
launch. The mock walk holds a budget against it at the Home step and both
|
||
walks print `bootCalls`, `bootSync`, `bootMs` and `bootLibMs` on every step.
|
||
`bootSync` is the clause with no clock in it: it is how many of the boot
|
||
commands left in the shell's own turn, so a chain put back in a queue can
|
||
only fall under its floor, whatever the machine is doing. Three things the
|
||
instrument does not do. It counts the app's own traffic, so the media card's
|
||
and the transfer notice's `addPluginListener` are invisible to it. It counts
|
||
commands, not bytes, so a later round that adds an import to a shared file
|
||
can move modules between chunks with nothing failing — that gap is open. And
|
||
the mock's exact count depends on how much of the first screen has asked for
|
||
its covers by the time the second frame lands, so the call ceiling is set to
|
||
catch a regression in kind (a per-artist walk or an index pulled to boot)
|
||
rather than a handful of calls.
|
||
|
||
The audio side had its own lag: `dumpsys media.audio_flinger` showed the
|
||
track on the fast mixer with runs of ~55 ms underruns. The 8192-frame buffer
|
||
above moved it to the normal mixer; the phone walk samples the underrun
|
||
counters before and after and fails if they rose.
|
||
|
||
### The long session
|
||
|
||
Every walk is minutes long; a phone runs the app for hours, and nothing in
|
||
the port had ever been looked at for a leak. It has now, by driving the real
|
||
bundle in headless Chrome for lap after lap of the whole app — five tabs,
|
||
the Library, a record, playback, the mini bar, all three faces of the sheet,
|
||
the artwork viewer, a row's half sheet, an artist page — and counting what
|
||
the app was still holding at the start and at the end.
|
||
|
||
**Listeners and timers do not leak, and the obvious counter says they do.**
|
||
With a registry installed at document start — an investigation, not what
|
||
ships — add minus remove climbs 23 → 119 over eight laps, about twelve a
|
||
lap, almost all of them `scroll`. None of it is a leak: `{#key nav.seq}`
|
||
replaces the `.screen` element on every navigation, so each new screen gets
|
||
its own handler and the old element is dropped whole. Counting only what is
|
||
still ATTACHED — `window`, `document`, or an element that is still
|
||
connected — gives 29 at the first lap and 29 at the eighth, key for key.
|
||
Intervals are the same story: two at boot (the media card's 30 s, the
|
||
player's 10 s session writer), three from the first lap on, and three at the
|
||
last; `Lane` and `Diagnostics` both clear theirs in their effect teardown,
|
||
and every `bind*()` in `src/mobile/` is guarded by a module-level `bound`
|
||
flag, so no Tauri subscription is ever registered twice.
|
||
|
||
**What the walk ships is narrower than that, and says so.** A script
|
||
registered with `Page.addScriptToEvaluateOnNewDocument` does not survive the
|
||
first-run block's reloads — the re-attach opens a new DevTools session — so
|
||
the registry the walk installs goes in as a STEP, after everything else, and
|
||
can only see what is registered from that moment on. Its absolute counts are
|
||
therefore small and depend on how much ran first: `tallyEars` reads 2 at the
|
||
end of the full mock walk and 8 from a settled boot, `tallyTicks` 1 in both
|
||
(the mock's own playback ticker). Those numbers are not a census of the app
|
||
and are not asserted as one. What is asserted is the DELTA across the laps,
|
||
which is where a per-navigation leak shows, and that was proved by planting
|
||
one: a listener and an interval stranded once a lap take `grewEars` 0 → 9
|
||
and `grewTicks` 0 → 9, and the step fails.
|
||
|
||
**The thumbnail map did leak, and is bounded now.** `src/mobile/art.svelte.js`
|
||
kept every cover it had ever fetched for the WebView's lifetime: one browse
|
||
of a 114-cover library took it from 5 keys to 114 and gave nothing back, and
|
||
a catalog crawl adds to the same map without limit. A 360 px cover comes
|
||
back from `album_art` as base64 JPEG — about 63 KB of string, measured
|
||
against this machine's own thumbnail cache — so that browse alone is roughly
|
||
7 MB retained. The bound is the desktop's, by its own numbers:
|
||
`ART_THUMB_CACHE_MAX` 512 and `ART_THUMB_CACHE_MAX_CHARS` 16 MiB from
|
||
`src/lib/state.svelte.js`, trimmed oldest first. Two rules the desktop does
|
||
not need are added, because the desktop's callers ask through `getArt(path)`
|
||
while the phone's templates INDEX the map (`art[path]` in Cover, Release,
|
||
Sheet, Viewer, Artist, Discography, Home and the media card): the entry that
|
||
has just arrived is never dropped, and neither is one a connected `<img>` is
|
||
painting — `Cover`'s `visible` action re-arms only when its path PROP
|
||
changes, so an evicted cover that is still mounted would stay a disc icon
|
||
for good. The few places that paint a cover as a `background-image` rather
|
||
than an `<img>` — the ground, the sheet's wash, the artist hero — are
|
||
covered twice over: each of them reads `art[path]` inside an effect that
|
||
calls `wantArt`, so an eviction under one re-fetches on its own. The cache
|
||
is therefore allowed to sit over its cap for as long as one screen needs all
|
||
of it, and comes down the moment that screen goes. Built with the cap forced
|
||
to 8 and scrolled through eighteen screens of a 140-card Library grid, the
|
||
map sat at 61 — every one of them spared for being painted — and not one
|
||
cover on screen lost its image at any point; the cards that had scrolled
|
||
away repaint cold and fill as they come back, which is what a cold first
|
||
visit already looks like. At the shipped cap of 512 a whole library walk
|
||
runs the trim zero times and the census is identical either way: on Kyle's
|
||
phone the change is inert, and it is the crawl past 512 covers that it
|
||
exists for. The accent table gets the same cap — an accent is a pure
|
||
function of its cover, so dropping one costs a recompute and nothing else.
|
||
|
||
**Playlist covers.** `pls.covers` is keyed `id:revision`, and an edit bumps
|
||
the revision, so every edit stranded one 360 px data URL under a key nothing
|
||
could ever read again. The new cover now drops that playlist's other
|
||
revisions as it lands — no cap and no policy, just the keys that are
|
||
unreachable by construction.
|
||
|
||
**Three things are unbounded on purpose.** `catalog.svelte.js`'s ten maps are
|
||
cached for the session by design, and evicting from them is not safe the way
|
||
the art map is: `Artist.svelte` calls `lookupArtist` inside `untrack`, so a
|
||
page whose entry went missing under it would never look it up again.
|
||
`lib.svelte.js`'s `tracksCache` is unbounded but finite — it is bounded by
|
||
the library. And `player.svelte.js`'s `scheduleRecents` leaves a 47-second
|
||
timeout per track start, which self-drains and is the desktop's own shape;
|
||
coalescing the two callers into one slot would cancel one of them, so it is
|
||
watched under a ceiling instead of changed.
|
||
|
||
The walk measures all of it. Every step prints `tallyArt`, `tallyChars`,
|
||
`tallyAcc`, `tallyPend`, `tallyCat`, `tallyQueue`, `tallyXfer` and
|
||
`tallyNodes` from the app itself (`artTally()` on the `__ss` handle, because
|
||
the map and its side tables are module-private), and `tallyEars`,
|
||
`tallyTimers`, `tallyTicks` plus the `grew*` deltas from a registry the
|
||
session block installs in the page. The registry is installed by a STEP, not
|
||
by `Page.addScriptToEvaluateOnNewDocument`: the first-run block reloads three
|
||
times and the re-attach opens a new DevTools session, so a script registered
|
||
on the old one would be gone. The block's last step takes it out again —
|
||
`EventTarget` and the four timer functions restored, `__ssCount` and the
|
||
baseline deleted — so nothing appended after it inherits a patched platform,
|
||
and every step after the block reads −1 for those fields, as does the whole
|
||
phone walk, where it is never installed at all. The assertion is a threshold
|
||
with headroom, not a stopwatch — the listener and interval counts are held
|
||
flat, the art map is held under its cap, and `tallyTimers` is a ceiling
|
||
rather than a delta because the recents window keeps a handful pending
|
||
during a walk this fast. Across eleven laps of the block as it ships, driven
|
||
against a real build of this tree, the listeners and the intervals moved by
|
||
0, the timers stood at 9 against a ceiling of 32, the art map grew 17
|
||
against a headroom of 80 and the catalog 5 against 24, and the DOM ended 10
|
||
nodes smaller than it started.
|
||
|
||
## Prerequisites (home machine, done 2026-09-12)
|
||
|
||
- Android SDK at `%LOCALAPPDATA%\Android\Sdk`: command-line tools,
|
||
platform-tools, platforms 34–36, build-tools 35/36, **NDK 28.2.13676358**,
|
||
cmake 3.31.6. Installed with `sdkmanager` alone — no Android Studio.
|
||
- User environment: `ANDROID_HOME`, `NDK_HOME`, `ANDROID_NDK_HOME`,
|
||
`JAVA_HOME=C:\Program Files\Java\jdk-17`; PATH has platform-tools and cmake.
|
||
(A bash session may not see them; the scripts below set their own.)
|
||
- Rust targets: `aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android`.
|
||
- The Tauri CLI here is npm's: **`npx tauri …`** (there is no `cargo tauri`).
|
||
- **Windows Developer Mode** (Settings → System → For developers). Tauri
|
||
symlinks the built library into `gen/android/app/src/main/jniLibs`; Windows
|
||
refuses symlinks without it. Until it is on, use the detour below.
|
||
- No emulator: virtualization is off in this machine's firmware (MSI B550
|
||
Tomahawk: OC → Advanced CPU Configuration → SVM Mode) and Windows
|
||
Hypervisor Platform is off. Builds go to a real phone over USB.
|
||
|
||
## Build and run
|
||
|
||
```bash
|
||
npx tauri android build --debug --target aarch64 --apk
|
||
```
|
||
|
||
With Developer Mode off, the CLI builds the Rust side and then fails at the
|
||
symlink. The detour, scripted:
|
||
|
||
```bash
|
||
bash src-tauri/android/deploy.sh # vite → cargo → strip → gradle → adb install → launch → screenshot
|
||
bash src-tauri/android/build.sh # the build alone; APK path printed at the end
|
||
```
|
||
|
||
What the scripts do, by hand:
|
||
|
||
```bash
|
||
# 0. the frontend, then the library — with tauri's custom-protocol feature ON.
|
||
# Without it the build embeds the dev URL (http://localhost:1421) instead of
|
||
# dist/, and the phone shows "Failed to request http://localhost:1421/mobile.html".
|
||
npm run build
|
||
cargo build --lib -p app --target aarch64-linux-android --features tauri/custom-protocol
|
||
# (with CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER / CC_ / CXX_ / AR_aarch64_linux_android
|
||
# pointing at the NDK's windows-x86_64/bin aarch64-linux-android26-clang(.cmd) wrappers)
|
||
# 1. strip the library's debug info (607 MB → 50 MB; the debug packaging keeps symbols)
|
||
cp target/aarch64-linux-android/debug/libapp_lib.so src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a/
|
||
"%LOCALAPPDATA%\Android\Sdk\ndk\28.2.13676358\toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-strip.exe" --strip-debug src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a/libapp_lib.so
|
||
# 1b. the app's own resources, over the generated project's (the launcher icon)
|
||
cp -r src-tauri/android/res/. src-tauri/gen/android/app/src/main/res/
|
||
# 2. package with Gradle, skipping the task that would redo the symlink
|
||
cd src-tauri/gen/android && ./gradlew.bat assembleArm64Debug -x rustBuildArm64Debug
|
||
```
|
||
|
||
APK: `src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk`.
|
||
A frontend-only change still needs the cargo step: `dist/` is embedded in
|
||
the library at compile time (the app crate alone recompiles, ~1 min).
|
||
|
||
Phone (USB debugging on, prompt accepted):
|
||
|
||
```bash
|
||
adb devices
|
||
adb install -r src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk
|
||
adb shell am start -n com.soulstream.app/.MainActivity
|
||
adb logcat --pid=$(adb shell pidof com.soulstream.app) # Rust logs land under RustStdoutStderr / app_lib::*
|
||
adb exec-out screencap -p > shot.png
|
||
adb shell input tap X Y # drive it: 1080×2400 px, 2.625 px per dp
|
||
adb shell input keyevent KEYCODE_BACK
|
||
```
|
||
|
||
Music for a test library goes in as the app (no root needed on a debug build):
|
||
|
||
```bash
|
||
adb push "F:\Music\SoulSeek Music\<Artist>\<Album>" /data/local/tmp/ssm/
|
||
adb shell "chmod -R a+rX /data/local/tmp/ssm; run-as com.soulstream.app sh -c 'mkdir -p \"Library/<Artist>\" && cp -r \"/data/local/tmp/ssm/<Album>\" \"Library/<Artist>/\"'"
|
||
adb shell am force-stop com.soulstream.app # the scan runs at launch
|
||
```
|
||
|
||
## Iterating without cargo (the dev flavour)
|
||
|
||
Every embedded build pays ~2 min of cargo for a CSS change, because `dist/`
|
||
is compiled into the library. The dev flavour skips that: the same detour
|
||
build **without** `tauri/custom-protocol`, so the library embeds the dev URL
|
||
(`http://localhost:1421`), and `adb reverse tcp:1421 tcp:1421` makes the
|
||
phone's localhost:1421 this machine's Vite dev server **over the USB cable** —
|
||
no LAN address, no firewall hole, hot reload on the phone. Cargo runs only
|
||
when Rust changes. Both flavours share `target/`: a switch recompiles tauri
|
||
and the app crate once (~3 min, the leaf crates stay fresh).
|
||
|
||
```bash
|
||
bash src-tauri/android/dev.sh # build the dev library (when Rust changed), install, tunnel, launch
|
||
npm run dev # or the Browser pane's "frontend" config — the phone loads from it
|
||
bash src-tauri/android/dev.sh --no-build # after a reconnect: reinstall/tunnel/launch without cargo
|
||
bash src-tauri/android/deploy.sh # the embedded build again — what the phone keeps when the cable goes
|
||
```
|
||
|
||
The dev APK needs this machine and the cable; put the embedded build back
|
||
before the phone leaves. The toolchain env both scripts share is
|
||
`src-tauri/android/env.sh`. Verified 2026-09-13: a shell.css edit reached
|
||
the phone in under a second. The page's origin stays `http://tauri.localhost`
|
||
— Tauri proxies the dev server through its own scheme on mobile — so the
|
||
harness and the app see one origin in both flavours.
|
||
|
||
## The release build
|
||
|
||
Every APK in this port is `assembleArm64Debug` — every `deploy.sh`, every
|
||
`dev.sh`, every walk, every build on Kyle's phone. The generated project turns
|
||
**R8 on for `release` and only for `release`**: in
|
||
`src-tauri/gen/android/app/build.gradle.kts` the debug type is
|
||
`isMinifyEnabled = false` and names no proguard files, and the release type is
|
||
`isMinifyEnabled = true` plus `proguard-android-optimize.txt` and every
|
||
`**/*.pro` it finds in the app directory. R8 is the one thing that can break
|
||
this app in a way no build can see.
|
||
|
||
Tauri's Android plugins are reached by NAME, at three layers, none of which
|
||
R8 can trace:
|
||
|
||
- `register_android_plugin("com.soulstream.storage", "StoragePlugin")` becomes
|
||
the string `com/soulstream/storage/StoragePlugin` and goes to `FindClass`;
|
||
the instance is constructed through the JNI signature
|
||
`(Landroid/app/Activity;)V`.
|
||
- `PluginHandle.indexMethods()` walks the class and its superclasses with
|
||
`getDeclaredMethods()` and files every `@Command` under
|
||
`commands[method.name]`, every `@ActivityCallback` under
|
||
`startActivityCallbackMethods[method.name]`. `invoke()` looks up the name
|
||
Rust or the WebView asked for and throws `No command pickFolder found for
|
||
plugin …` when it is not there.
|
||
- Jackson fills every `@InvokeArg` class FIELD by field.
|
||
|
||
So a minified build compiles, installs, launches and shows Home — and then a
|
||
tap on Library folder, or the card's next button, does nothing at all. Four
|
||
plugin modules expose eleven commands, four activity callbacks and four
|
||
argument classes that way.
|
||
|
||
```bash
|
||
bash src-tauri/android/build.sh --release # or SS_ANDROID_RELEASE=1 bash src-tauri/android/build.sh
|
||
adb install -r src-tauri/gen/android/app/build/outputs/apk/arm64/release/app-arm64-release.apk
|
||
```
|
||
|
||
With no argument `build.sh` is what it has always been — the same cargo line,
|
||
the same `assembleArm64Debug -x rustBuildArm64Debug`, the same APK path — so
|
||
`deploy.sh`, `dev.sh` and `ship.sh` cannot reach the release path by accident.
|
||
The flag changes four things and nothing else: `--release` and
|
||
`--features tauri/devtools` on the cargo line, the `release` profile's `.so`,
|
||
`assembleArm64Release -x rustBuildArm64Release`, and a sign step at the end.
|
||
|
||
**Where the keep rules live, and why in two places.** Each plugin carries its
|
||
own `consumer-rules.pro` in `crates/tauri-plugin-*/android`, registered by one
|
||
`consumerProguardFiles("consumer-rules.pro")` line in its `build.gradle.kts` —
|
||
the same shape `tauri-android` and `tauri-plugin-dialog` already use in this
|
||
build. (`tauri-plugin-opener` ships a `consumer-rules.pro` and never registers
|
||
it; the file is inert. That is the mistake this line exists to avoid.) Consumer rules travel with the plugin, so a regenerated
|
||
`gen/android` keeps them, and they are inert in the debug APK because a
|
||
consumer file only does anything when the consuming app minifies. What belongs
|
||
to no single plugin is in `src-tauri/android/proguard-soulstream.pro`, which
|
||
`build.sh --release` copies into `src-tauri/gen/android/app/` for the
|
||
`fileTree(".")` glob to pick up — it cannot simply live there, because `gen/`
|
||
is generated and gitignored and `tauri android init` rewrites it. That file
|
||
holds three things: the runtime annotation attributes, a `-dontwarn` pair for
|
||
two `java.beans` classes jackson-databind references and Android does not
|
||
have (R8 treats a missing class as a build *error*), and a keep for four
|
||
`@Command` methods declared on `app.tauri.plugin.Plugin` itself —
|
||
`registerListener`, `removeListener`, `checkPermissions`, `requestPermissions`.
|
||
Tauri's own rule matches classes annotated `@TauriPlugin` and the members they
|
||
declare; the base class carries no such annotation, so nothing names those
|
||
four, and `registerListener` is what the lock-screen card's buttons and the
|
||
download notice's Cancel/Open ride on. That last rule is a precaution, not a
|
||
diagnosed bug — it is six lines, and it removes the question.
|
||
|
||
**What already keeps the wry bridge, and must not be thrown away.** The
|
||
`**/*.pro` in that glob is recursive, and the file it reaches that nobody
|
||
thinks about is
|
||
`app/src/main/java/com/soulstream/app/generated/proguard-wry.pro`, written
|
||
beside the generated Kotlin by wry's own build script. It is what keeps
|
||
`Rust`'s native methods, `WryActivity.setWebView`/`getVersion`/`getId`,
|
||
`Ipc`'s `@JavascriptInterface` methods and `RustWebView.evalScript` — the
|
||
entire JNI bridge between the Rust core and the WebView, all of it reached by
|
||
string from Rust. `proguard-tauri.pro` beside it keeps
|
||
`TauriActivity.getPluginManager()`. Neither is ours and neither is in git;
|
||
both are regenerated. Narrow that glob to a named list and the app stops at a
|
||
black screen. The four plugins are the gap because they are the only
|
||
name-reached surface nobody else wrote a rule for.
|
||
|
||
**What is different about a release build, beyond minification.**
|
||
|
||
- `usesCleartextTraffic` flips to `"false"` (debug sets the placeholder to
|
||
`"true"`). The page's own origin is `http://tauri.localhost`, which Tauri
|
||
serves itself, so this should not bite — but it is the first thing to check
|
||
if the WebView comes up blank.
|
||
- Not debuggable, so `run-as com.soulstream.app` is gone: the documented way
|
||
of pushing test music into the app's private Library folder does not work
|
||
on a release build. Point it at a folder in shared storage instead.
|
||
- DevTools exist only because `build.sh --release` passes
|
||
`--features tauri/devtools`; wry compiles
|
||
`setWebContentsDebuggingEnabled` under
|
||
`cfg(any(debug_assertions, feature = "devtools"))`. Without it there is no
|
||
`webview_devtools_remote_<pid>` socket and `smoke.mjs --phone` has nothing
|
||
to attach to.
|
||
- The APK Gradle produces is `app-arm64-release-unsigned.apk` — there is no
|
||
`signingConfigs` block in the generated project. `build.sh --release`
|
||
zipaligns it and signs it with `~/.android/debug.keystore`
|
||
(alias `androiddebugkey`, password `android`), the **same key the debug
|
||
builds use**, so `adb install -r` replaces the app in place. Sign it with
|
||
any other key and that install fails; the obvious next move — uninstall,
|
||
then install — deletes the app's data, which on Kyle's phone means his
|
||
settings, his account and his queue. This is a test build, not a
|
||
distributable one.
|
||
- `cargo build --release` fills a second `target/aarch64-linux-android/release/`
|
||
tree; the debug artifacts are untouched, but that cache roughly doubles.
|
||
- Both build types package `app/src/main/jniLibs/arm64-v8a/libapp_lib.so`, and
|
||
a release run leaves the release library sitting there. `deploy.sh` copies
|
||
over it unconditionally, so the next debug deploy is correct — but a Gradle
|
||
run by hand in between would package the release library into a debug APK.
|
||
- If lint fails on something unrelated, run Gradle by hand with
|
||
`-x lintVitalArm64Release`; AGP 8 splits that into an analyze task and a
|
||
report task, so `-x lintVitalAnalyzeArm64Release` may be the one that
|
||
actually has to go. It did not come up: `lintVitalX86_64Release` passed on
|
||
the build described below, so the exclusion is advice for a failure that has
|
||
not happened rather than one that has.
|
||
|
||
**What to smoke-test on a release build, and only on a release build.** The
|
||
walks do not cover this: the pipeline reinstalls the debug APK constantly, and
|
||
what minification breaks is exactly the part that is hands rather than CDP —
|
||
pickers, notifications and a share sheet. Every plugin command, one at a time:
|
||
|
||
- **storage** (six commands, four activity callbacks) — pick a library folder
|
||
(You → Library folder) and check the picked path comes back; the all-files
|
||
access row and the system screen its button opens; pick an image (a playlist
|
||
cover); pick a file (a playlist import); export a playlist, which is
|
||
`createDocument` and has no JS-facing command at all — it is called from
|
||
Rust, so it is the one a hand test forgets.
|
||
- **media-session** — play something and watch the card appear (`update`); its
|
||
prev, play, next and stop, which arrive through `registerListener`; stop
|
||
playing and watch it go (`clear`).
|
||
- **transfer-notice** — start a Save and watch the notice appear (`update`);
|
||
Cancel and Open on the notice, also `registerListener`; let it finish and
|
||
watch it go (`clear`).
|
||
- **share** — share plain text to SoulStream from another app (`pendingShare`).
|
||
|
||
A command that has been renamed answers `No command <name> found for plugin
|
||
<class>`, which lands in logcat rather than on screen:
|
||
`adb logcat --pid=$(adb shell pidof com.soulstream.app)`.
|
||
|
||
## The launcher icon
|
||
|
||
Until this round the phone wore **tauri's** logo. `tauri android init` writes
|
||
the generated project's `res/` from its own template and never reads
|
||
`src-tauri/icons/android/` — which has carried the falcon since `50afa6a`
|
||
— and the detour build runs cargo and Gradle directly, so nothing ever
|
||
copied it across. The two dominant colours in
|
||
`gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png` were `#24c8db`
|
||
and `#ffc131`: tauri's brand cyan and tauri's brand amber.
|
||
|
||
`src-tauri/android/res/` is the app module's res, tracked, and `build.sh` and
|
||
`dev.sh` copy the whole tree into the generated project before Gradle runs.
|
||
It carries:
|
||
|
||
- `mipmap-anydpi-v26/ic_launcher.xml` and `ic_launcher_round.xml` — the
|
||
adaptive icon: a foreground, a background, and a **monochrome** layer, which
|
||
is what Android 13's themed icons need and what tauri's generator has never
|
||
made;
|
||
- `mipmap-{m,h,xh,xxh,xxxh}dpi/` — `ic_launcher_foreground.png` and
|
||
`ic_launcher_monochrome.png` (108–432 px) from the kit's
|
||
`android/adaptive/res/`, and the legacy `ic_launcher.png` /
|
||
`ic_launcher_round.png` (48–192 px) from its `android/legacy/`;
|
||
- `values/ic_launcher_background.xml` — `#FF05080B`, the brand's night.
|
||
|
||
Three things about it are not obvious.
|
||
|
||
**The kit's own adaptive XML does not compile here.** Its
|
||
`android/adaptive/res/mipmap-anydpi-v26/*.xml` points every layer at
|
||
`@drawable/…`, and the kit ships those layers as PNGs under `mipmap-*` with
|
||
no `drawable-*` directory anywhere. That is worse than a missing resource:
|
||
the generated project still carries Android Studio's template vectors at
|
||
`drawable/ic_launcher_background.xml` and
|
||
`drawable-v24/ic_launcher_foreground.xml`, so two of the three references
|
||
resolve — to the green robot — and only `@drawable/ic_launcher_monochrome`
|
||
fails the build. The overlay's XML says `@mipmap/` for the two image layers
|
||
and `@color/` for the background instead.
|
||
|
||
**The background is one colour resource, not five identical flat PNGs.**
|
||
A richer gradient is being mocked separately; when it is approved it replaces
|
||
one file, and the three XML references do not move.
|
||
|
||
**The colour file is named `ic_launcher_background.xml` on purpose.**
|
||
`src-tauri/icons/android/values/ic_launcher_background.xml` declares the same
|
||
resource name (`#fff`), and two files in one `values/` directory declaring
|
||
one colour is a hard aapt2 error — `resource 'color/ic_launcher_background'
|
||
has a conflicting value`. Sharing the filename means the overlay overwrites
|
||
it rather than colliding with it, if a future `npx tauri android init` ever
|
||
copies it in.
|
||
|
||
That leaves `src-tauri/icons/android/` a second, stale copy of these same
|
||
resource names: white background, no monochrome, hdpi at 49×49. Nothing on
|
||
Android reads it while the build is the detour — but `npx tauri icon` writes
|
||
it, and it should be brought to the kit before anyone builds through the CLI.
|
||
|
||
### Checking it
|
||
|
||
`verify-icon.sh` opens the APK `build.sh` has just made and reads the
|
||
resources back out. It checks the overlay's own two XMLs first, before the APK
|
||
is opened — a `<monochrome>` layer that a later edit drops is a failure too —
|
||
and then reads the APK, debug and release differently, because AGP hands them
|
||
to aapt2 differently:
|
||
|
||
- **debug** — the packaging default for a debuggable build type is
|
||
`--no-crunch`, so every entry *is* the file on disk. Both
|
||
`res/mipmap-anydpi-v26/*.xml` must be in the APK, and all four PNG names at
|
||
all five densities must be byte for byte the overlay's.
|
||
- **release** (`--crunched`) — aapt2 re-encodes every PNG, *and* AGP's
|
||
resource optimization (`OptimizeResourcesTask` → `SHORTEN_RESOURCE_PATHS`)
|
||
renames the lot: an APK optimized here had no `res/mipmap-…` entry left at
|
||
all, only `res/Ag.png`. Neither the bytes nor the names survive, so the
|
||
check asks `aapt2 dump resources` what each name points at and compares the
|
||
pixel dimensions of whatever that is — 72×72 at hdpi still separates the
|
||
kit's legacy icon from tauri's 49×49. If the resource *names* have been
|
||
collapsed as well (that comes with resource shrinking, which this project
|
||
does not turn on), it says the icon is unchecked rather than claiming a
|
||
failure it cannot see.
|
||
|
||
Both `build.sh` and `dev.sh` call it, so no APK this repo makes can quietly go
|
||
back to the old artwork. It costs about a second and a half on the 107 MB
|
||
debug APK.
|
||
|
||
What it cannot check is what the icon looks like. `deploy.sh`'s `px-1.png` is
|
||
a screenshot of the app five seconds after launch; the launcher is not in it.
|
||
The one thing that may now differ there is the Android 12+ system splash,
|
||
which is drawn from the launcher icon — if a cold start ever runs past those
|
||
five seconds, that splash is the falcon on `#05080B` rather than tauri's mark.
|
||
**Adaptive masking and the monochrome layer can only be judged from the
|
||
phone's home screen**, which is a minute with the Pixel: the icon under the
|
||
launcher's mask, in the app drawer, in recents and in another app's share
|
||
sheet — and then Wallpaper & style → Themed icons on, to see the monochrome
|
||
layer take the wallpaper's colour.
|
||
|
||
## Verifying
|
||
|
||
```bash
|
||
node tests/mobile/smoke.mjs # headless Chrome + the mock library
|
||
node tests/mobile/smoke.mjs --phone --album "Spiderman" # the real app on the connected phone
|
||
```
|
||
|
||
The mock walk builds `dist-smoke/`, switches the mock library on
|
||
(`src/lib/mockLibrary.js`: 140 records, some without art on purpose, and a
|
||
**fake network** behind the same switch — a stored account that signs in at
|
||
boot, rooms with a few lines in them, a catalog with one artist, a search
|
||
answered by five peers, a stream that arrives over twelve seconds with its
|
||
waveform growing; backend events go over the mock's own bus, which
|
||
`src/lib/events.js` puts in front of Tauri's `listen`), drives Home → a long
|
||
press → Search → Library and its tabs → a record → its sheets → the player's
|
||
three faces → the back gesture → an artist page → Community → a room → a
|
||
catalog search → that artist's whole discography on a page of its own (All
|
||
in front of six categories, a year header per year, the editions opened in
|
||
place, a name order dropping the grouping, and a 140-record artist whose
|
||
grid caps at twelve and whose page shows sixty with the rest a button away)
|
||
→ a song through the resolver → the lane rising in the sheet →
|
||
a catalog record, its Save and the Transfers burst (and the shade's notice:
|
||
posted with a count while rows are in flight, gone when they are not) →
|
||
Gaps → the playlist
|
||
shelf and a page with every row kind (a local row, the stream row through
|
||
the resolver, the filter, a removal and its undo, a row dragged into place
|
||
and dragged back, a drag refused while the filter is on, a press released
|
||
without moving still opening the row's sheet, a drop the backend rejects
|
||
snapping back, unpin, rename, a new playlist, Add to playlist from a
|
||
record's sheet, delete) → the import doors
|
||
(a pasted tracklist, a link with its catalog verification, pasted track
|
||
links read a page each, a folder of audio files off the phone, a playlist
|
||
file through the phone's own document picker — with that picker cancelled
|
||
and a file it can hand no path for refused — and a share from another
|
||
app) → the playlist repair (an unknown row relinked from the
|
||
library) → the artwork viewer → a queue row's sheet and a queue row
|
||
dragged into place → a catalog song's,
|
||
a catalog card's and the record page's sheets → the now-playing Save →
|
||
a peer's sheet, a raw file's sheet, a user's shares browsed and a folder
|
||
of theirs streamed → a buddy's sheet → You (every switch and chip saved
|
||
and read back from the config) → recent searches and a spelling
|
||
suggestion → the sleep timer → the phone turned sideways (the player's three faces, a
|
||
record with a long sheet over it, an artist page, and the side insets faked,
|
||
all at 915×411) and back → and last, on a reload of its own, a phone that
|
||
has just been installed (the first-run sheet and its two cards, its
|
||
Soulseek door, the notes on Home, on both Library tabs and on Search, the
|
||
preview from You, a folder picked from the tour and the note losing the row
|
||
it answered), then back to the provisioned device, and after that the
|
||
playlist export door three ways (a file written where the sheet pointed, a
|
||
dismissed sheet that says nothing at all, and a refusal read back as a
|
||
sentence), asserts what each step should show — the Home
|
||
step holds the launch to a budget as well, reading off `__ss.boot` how many
|
||
commands the app issued before its first frame, how many of those left in the
|
||
shell's own turn, how long it took, and requiring round 12's art prewarm to
|
||
still be absent from them — screenshots every step into `tests/mobile/out/`, and fails on
|
||
any exception or console error. Near the end of the mock walk is the
|
||
weather: a library index that will not read and its Try again, the client taken
|
||
offline, a refused password and a server that cannot be reached, Search with
|
||
nowhere to search, a room list that never arrives, and a rescan that refuses
|
||
because the folder is gone — then all of it cleared and the app left as it was
|
||
found. After the weather come the faces that failure actually draws, each one
|
||
armed by the mock and read back by its own words: Home with every follow's
|
||
catalog page refused, the Gaps line and its second tap, a catalog record's stub
|
||
and its Try again, an artist page whose lookup failed beside one that was
|
||
answered with nothing, a playlist that would not read, the lyrics face on a
|
||
lookup that never came back, a transfer that was refused, and the library
|
||
toast's own Try again pressed for the first time — then all of that disarmed
|
||
too, and asserted disarmed. The mock answers every command the phone
|
||
uses; one it does not know is named in the report (`[mock] no mock for X`)
|
||
rather than surfacing as a TypeError toast, console warnings carry their top
|
||
frames (the smoke bundle is built unminified for that), and every probe
|
||
carries the history mirror — the entry's depth against the layers the app
|
||
says the back gesture can peel (`hd`/`hwant`; a drift is how the blank
|
||
page after a confirmed delete was found). The mock is also the weather:
|
||
`__ssMock.fail(cmd, message)` arms a command to reject with that exact string,
|
||
`answer(cmd, value)` arms one to resolve with a value the walk names (an empty
|
||
list, a null page), `offline(on)` takes the client down — everything the
|
||
backend funnels through `with_client()` answers "not connected to Soulseek",
|
||
the two connects answer the server string, and `soulseek:state` goes out — and
|
||
`clear()` puts all of it back. They reject with a plain string because that is
|
||
what Tauri rejects with. Fourteen mock steps drive them, and the phone walk carries
|
||
two safe steps of its own (`trouble-words`: eleven real backend strings through
|
||
`__ss.say` on the device, every output asserted free of Rust; and
|
||
`trouble-xfer`: one already-failed transfer row written into the store and
|
||
taken away again — nothing fetched, and a terminal row is not in flight, so
|
||
nothing reaches the shade). Fifteen more mock steps drive the failure faces
|
||
themselves, which are described under **When things go wrong**. A mock step can
|
||
also set the
|
||
viewport — `{ view: [915, 411] }` sends `Emulation.setDeviceMetricsOverride`
|
||
through the same client, which is how the walk turns the phone over and back;
|
||
the phone walk refuses one, because a real device's orientation is not the
|
||
harness's to set.
|
||
|
||
The device turns itself over instead, in a block of the phone walk's own,
|
||
after `background` and before the network — because a step list cannot do
|
||
either of the two things this needs. It reads
|
||
`settings get system accelerometer_rotation` and `user_rotation` FIRST and
|
||
latches the pair; only then does it put auto-rotate off and `user_rotation`
|
||
to 1 (a retry at 3 for a device whose natural orientation is landscape), and
|
||
it polls for the turn rather than waiting a fixed time. Then it asserts what
|
||
only a device can answer: `screen.orientation.angle` is 90 or 270 — an
|
||
`Emulation.setDeviceMetricsOverride` without `screenOrientation` leaves the
|
||
angle at 0, so the mock physically cannot fake this, and it is the one clean
|
||
"a real phone did this" clause — that the WebView RESIZED as well (a separate
|
||
clause, because a WebView that never got the configuration change looks
|
||
identical to a display that never rotated), that `--nav-h` computed to 48px
|
||
so the phone-sized landscape query really fired, that the activity was NOT
|
||
recreated (the same window token and the same `performance.timeOrigin`, the
|
||
same pid, no `AndroidRuntime` fatal — the manifest declares `orientation` and
|
||
`screenSize` handled, so a recreation here would be the bug), that the tab
|
||
and every stack are where they were, that the track is still playing and past
|
||
where it was, that nothing is clipped, and that the glass bar, the mini bar
|
||
and the screen spend whatever `env()` says. Then the player sheet sideways:
|
||
two columns, every control on screen, its own side padding. Then back.
|
||
|
||
The four safe-area insets are printed, not asserted. Nobody had ever measured
|
||
them on this phone: `MainActivity` calls androidx's `enableEdgeToEdge()`,
|
||
`themes.xml` sets no cutout attribute and nothing in the generated Kotlin
|
||
touches insets, so `env()` comes straight out of Chromium's own window-inset
|
||
mapping and whether the hole-punch shows up as a non-zero side inset depends
|
||
on the cutout mode that picks. The run records the numbers — `insets` as
|
||
top/right/bottom/left, `insetsReal`, the app's own `--inset-left/right` beside
|
||
them, and `chromePct` — and a later round can assert them once they are known.
|
||
Asserting a number nobody has measured is how a round fails on a correct
|
||
build.
|
||
|
||
Putting the phone back is the part that is written carefully, because the
|
||
phone is Kyle's. The saved pair is a module-level latch, and the restore is
|
||
reached from four places: the rotation block's own `finally`, main's
|
||
`finally`, `process.on("exit")` and SIGINT/SIGTERM/SIGBREAK. It has to be all
|
||
four — a failed clause only pushes a problem and walks on, but `still()` and
|
||
the probe call are wrapped in nothing, so a WebView that stops answering
|
||
rejects after 45 s and throws clean out of the loop, and Ctrl-C runs no `exit`
|
||
handler at all. The latch clears itself before it writes, so only the first
|
||
caller acts and a restore that fails hands the latch back for the next one.
|
||
The rotation goes back before auto-rotate does, so a phone that had the sensor
|
||
in charge gets it back last. A device whose two settings will not read is
|
||
skipped with a line rather than forced — `rotation skipped: the phone's
|
||
rotation settings would not read` — as is one that is already sideways, and
|
||
`--no-rotate` skips the block outright for a run where he is holding the
|
||
phone. Nothing else on the device is touched: no download, no room, no
|
||
message, no sign-in, nothing written to the config or the library. The one
|
||
case no handler covers is a hard kill of the machine, after which auto-rotate
|
||
is a Quick Settings tile away.
|
||
|
||
The stills are `phone-turned.png`, `phone-turned-sheet.png` and
|
||
`phone-turned-back.png`.
|
||
|
||
The phone walk is the same driver on the real thing (plus a step kind of
|
||
its own: `{ adb: [...] }` runs an adb command — the share-sheet intent, a
|
||
key — and a step may carry a `probe` as well, a function whose reading of
|
||
the system is merged into that step's own expectation, which is how the
|
||
shade's notice is asserted out of `dumpsys notification`; the shade steps
|
||
push two rows straight into the transfer store rather than fetching
|
||
anything, on purpose; every CDP call times out after 45 s and the reload step re-attaches
|
||
to the page, since a WebView that stops answering once hung a walk for ten
|
||
minutes; its audio check counts SoulStream's OWN tracks' underruns from the
|
||
mixer dump — the thread-wide counters move with any app's notification
|
||
sound, and sent the walk chasing dropouts that were not ours): a debug build's WebView
|
||
exposes DevTools on `localabstract:webview_devtools_remote_<pid>`, `adb
|
||
forward` puts it on a local port, and the steps run against the real library
|
||
— real playback, real covers, the colour following the record — with the
|
||
phone's own frames (`adb screencap`) as the stills. It refuses to run when
|
||
another app is on the phone's screen (it never takes the phone from whoever
|
||
is holding it), and a WebView in the background does not answer, so the app
|
||
has to be in front. One of its steps is `location.reload()`: the audio
|
||
thread keeps playing and the session comes back live, which is the reload
|
||
half of session restore tested for real. After the steps it force-stops and
|
||
relaunches the app (the restart half: paused at the position, play resumes),
|
||
checks the system's media session, sends media keys, backgrounds the app for
|
||
ten seconds, and — with the app signed into Soulseek — types `--query` into
|
||
Search, taps the first catalog song and watches it resolve and stream (the
|
||
peer, the phase, the coverage), then asks for the raw peers. `--lockscreen`
|
||
takes the lock-screen card's still last, because it leaves the phone locked;
|
||
`--only-lock` takes just that still of whatever is on the bar (resuming it if
|
||
paused) without the walk.
|
||
|
||
### What each walk actually proves
|
||
|
||
The two step lists are not the same test, and which is which had never been
|
||
written down. The mock walk is the bigger one (252 named steps) and the only
|
||
one that can arm a failure, turn the phone over or answer as a fresh install.
|
||
The device walk (121 named steps, up from 88) is the smaller one and the only
|
||
one that touches a real core, a real library, a real audio thread and the
|
||
system underneath them. The gap between them is where this list lives.
|
||
|
||
Writing it down turned up four phone steps — `library-ambience`, `queue`,
|
||
`lyrics` and `community` — that were named, screenshotted, and asserted
|
||
against nothing at all: the driver reads `expect[s.name] ? expect[s.name](info)
|
||
: true`, so a missing key in `PHONE_EXPECT` is a silent pass, and all four had
|
||
one. They have expectations now.
|
||
|
||
**Proven on the device.** Boot and its census; playback out of the real
|
||
library; the ambience following a real cover — the accent is in the still and
|
||
asserted by neither walk; the artwork viewer; the queue reordered, and a queue
|
||
row's sheet opened; the lyrics face, the nudge, the per-track auto-sync
|
||
switch, the tap room and the real-DSP estimate (all of it discarded); a reload
|
||
that keeps the audio thread; a library track's own sheet, off a real path; the
|
||
Library's shape, its sort and every one of its five tabs; Search and its
|
||
recents; a playlist made, filled, dragged, a row removed and put back by Undo,
|
||
filtered and deleted; four import doors — including the folder door on a real
|
||
folder, and two of their system pickers dismissed with BACK — and the review's
|
||
Keep-unresolved switch; the share-sheet intent; six of You's switches and
|
||
chips written to the real config and read back out of it, then put back —
|
||
volume normalization, word-by-word fill, the upload slots, the streaming
|
||
ceiling, the embedded-cover size and the stream cache's retention — and the
|
||
listening port both ways and a refused one; the sleep timer; the Wi-Fi-only
|
||
guard; the shade's notice read back out of `dumpsys notification`; contacts
|
||
read over real IPC; a cold artist page, its ⋯, the whole-discography page and
|
||
its A–Z; the first-run preview with `setupWrites` at 0; eleven raw backend
|
||
strings through `__ss.say`; and a refused transfer's sentence.
|
||
|
||
Then, after the step loop, seven things the mock cannot do at all: a
|
||
force-stop and the paused restore that follows it (waited out past the
|
||
thirteen-second FGS-kill window), the position restored, playback resumed, the
|
||
system media session and its ownership, media keys, ten seconds in the
|
||
background with the position still advancing, and SoulStream's own mixer
|
||
underruns.
|
||
|
||
One block in the phone walk pulls bytes, and only when the app is signed in:
|
||
the trailing network block types `--query` into Search, streams a peer's copy
|
||
into the stream cache, and browses the raw peers and a share. Nothing else in
|
||
either list downloads, joins, sends or signs in.
|
||
|
||
**Mock-only, and why** (178 named steps). These are the reasons rather than a
|
||
partition: a step can sit under more than one of them, and the last bullet is
|
||
the only one that says a step could move and has not.
|
||
|
||
- *Needs `__ssMock`* — the nine weather steps and the fifteen failure faces.
|
||
That object is the mock library's own door and does not exist in a real
|
||
build. The SENTENCES those screens print are proven on the device anyway, by
|
||
`trouble-words` and `trouble-xfer`; the screens that carry them are not.
|
||
- *Needs a fresh install* — the ten `fresh-*` steps. On the device only the
|
||
preview door is walked, and it writes nothing.
|
||
- *Needs a viewport override* — the nine landscape and portrait steps. A
|
||
`view` step on the phone walk is a deliberate failure, because a real
|
||
device's orientation is not the harness's to set, so the sideways layout has
|
||
never been seen on a device at all.
|
||
- *Needs the dev flavour* — `diag` and `diag-back`. `devBuild` is
|
||
`import.meta.env.DEV` or the mock key, and the embedded build Kyle carries
|
||
reads false, so the row is not there to tap.
|
||
- *Would download, save or rewrite his files* — the catalog, peer, browse and
|
||
Save steps, the cover re-fetch, the portrait refresh, chosen and pasted
|
||
lyrics, a saved sync, the vocal-reduced copy, a hero photo set.
|
||
- *Would join, send or sign in* — rooms and their users, buddies, the contact
|
||
editor, the taste chips, a user looked up.
|
||
- *Would change what is playing* — `queue-removed` and `queue-moved-up`. The
|
||
device opens that sheet and reads it; it does not tap a row in it.
|
||
- *Destroys his data* — `history-confirm` and `history-cleared`. Never on his
|
||
phone.
|
||
- *Structurally empty on this phone* — Home's feed sections and the Gaps
|
||
shelf. Follows live in the library folder and the phone's own folder starts
|
||
without any, so on the device `gaps` can only be asked to render and to stay
|
||
quiet.
|
||
- *No reason but time* — not every remaining step has a reason above it.
|
||
These would be safe on the device and simply have not been moved: the
|
||
sheet's own Move-up and Move-down rows and a drag refused under a filter
|
||
(all three on the walk's own playlist), rename, unpin, `wifi-off`, the
|
||
empty search and the did-you-mean pair (both wanting a query his own
|
||
library can answer for), and the several `-again` steps that only put a
|
||
screen back. This round stopped where it stopped.
|
||
|
||
**Conditional on the device.** Six of the new steps pass vacuously when his
|
||
own data cannot produce the condition, and are named here so they do not
|
||
become the next generation of silent passes: `gaps` (no follows),
|
||
`queue-row-sheet` (nothing after the walked track), `playlist-drag-held` and
|
||
the drag-refusal clause of `playlist-filter` (both need a playlist longer than
|
||
one row — a one-track record makes a one-row playlist, and `reorder.js`
|
||
refuses to lift a list that short), `import-keep-off` (a paste that resolved
|
||
completely), and `you-keep` (the cache already set to Forever, which the step
|
||
will not move because coming back writes the flag and not the number). The row
|
||
count `playlist-row-sheet` stashes is what keeps the rest of that block from
|
||
passing on a probe that read nothing.
|
||
|
||
**Needs a person.** A live room joined and a message sent; a real Save over
|
||
cellular with the Wi-Fi switch on; the lock-screen card (`--lockscreen` takes
|
||
that still and asserts nothing about it); the first real export written
|
||
through Android's create-document sheet, which the walk only ever dismisses
|
||
with BACK; the phone physically turned sideways; and a genuine first run on a
|
||
device that has never had the app.
|
||
|
||
## Not done yet
|
||
|
||
Thirty rounds in, this is the honest map: what is deliberately absent, what
|
||
is waiting on Rust, and what is simply smaller than the rest. Most of what
|
||
the backend exposes and the phone never calls is a whole module it has no
|
||
business calling, so the list is by module — that is the reason, not the
|
||
symptom.
|
||
|
||
- **Off by design — the private layer.** Spaces, holdings, the sealed inbox,
|
||
friends by key, shared playlists, Who has it, the persona and its profile,
|
||
and the Soulseek↔persona link: sixty-six commands across ten modules
|
||
(`freenet` 7, `friends` 7, `holdings` 8, `inbox` 3, `persona` 6, `profile`
|
||
4, `proposals` 3, `shared_playlists` 13, `slsk_link` 5, `spaces` 10).
|
||
Freenet on the phone is a later experiment; the network side is in, and
|
||
rooms and chat are built and mock-walked. A live room is Kyle's to join.
|
||
- **Off by design — the desktop's own machinery.** The tag-import pipeline
|
||
(all sixteen of `import.rs`: its audition, its cover report, its portable
|
||
root — which is the import session's root, not the library folder); hover
|
||
previews (all eight of `preview.rs`), and with them the three pause/fade
|
||
acknowledgements that live there because they exist only to hand silence
|
||
between the main player and the preview player; the mini window, the glass
|
||
mode, Show in folder and SMTC; the updater (the phone's own build of it
|
||
answers honestly that there is no channel here, and the version is in You,
|
||
from `app_status`); the hero GPU upscaler, the cover-wordmark OCR and its
|
||
debug tools. The phone shows a wordmark the page carries; it does not run
|
||
the pipeline that makes one — and it does not fetch a 3000px cover upgrade
|
||
over mobile data for a screen that cannot show it, which is why
|
||
`catalog_cover` and `invalidate_catalog_cover` stay here too.
|
||
- **Off by design — external runtimes.** yt-dlp and deno, the vocal
|
||
separator, the lyric alignment models, and the listener door that needs
|
||
them.
|
||
- **Still desktop-only, smaller.** Catalog track previews and the
|
||
fused-artist portrait verdict — that last one needs the identity merge the
|
||
phone does not run, so porting it means porting the merge first.
|
||
- **Per library folder.** Follows and the play history live in the library
|
||
folder, so the phone's own folder starts empty of both; they arrive with a
|
||
shared library.
|
||
- **A first run that restores rather than starts.** The tour covers the two
|
||
things the app cannot work without (a folder, an account) and stops
|
||
there. The desktop's own tour gates on an alpha key, which has nothing to
|
||
unlock here — the APK is side-loaded and there is no updater channel on
|
||
the phone — and its Community step has no counterpart, because Freenet is
|
||
not on the phone. The honest first run for Kyle's own phone is neither:
|
||
it is "point me at the desktop's library folder and read its follows, its
|
||
playlists and its play history", which works only when that folder is
|
||
reachable from the phone's storage. That is a sync door, not a setup
|
||
step. Making an account from inside the app is possible — Soulseek makes
|
||
one from the first sign-in with a new name — but it would mean handling a
|
||
password, which the tour deliberately refuses.
|
||
- **A release build on the phone.** R8 has now run and the keep rules hold —
|
||
but on an **x86_64** APK, on an emulator, not on `assembleArm64Release` and
|
||
not on Kyle's phone. R8 wrote no `missing_rules.txt` at all (so the
|
||
`-dontwarn` pair covers what jackson-databind reaches for),
|
||
`lintVitalX86_64Release` passed, zipalign and apksigner with
|
||
`~/.android/debug.keystore` produced an APK that `adb install -r` put over
|
||
the debug app without touching its data, and the minified app launched,
|
||
rendered Home, restored its session and walked **131 of 131** with no
|
||
exceptions, no console errors and nothing in logcat about a missing command
|
||
or class. What that does **not** cover: the arm64 variant and the
|
||
`cargo build --release` library the script actually names — only the
|
||
flavour differs, and the Gradle, R8 and signing steps are the same ones —
|
||
and the hands-only list below, the pickers and the notification buttons and
|
||
the share sheet, which no walk reaches. Work down the smoke list in "The
|
||
release build" before trusting a release APK on a phone.
|