Author SHA1 Message Date
KeelrLeek 6216954f40 android: the floating back and more controls are bare glyphs with a shadow
Kyle's pick over the dark discs, seen on the artist page's hero: the
picture stays whole under them, and a two-layer drop shadow is what keeps
the chevron and the dots readable on a bright one. The tap target keeps its
size; a press shows a faint disc for feedback and nothing else.
2026-09-18 17:40:00 -05:00
KeelrLeek 69bb722590 android: the artist page, redesigned — the editorial shape
Kyle called the artist page "super glitchy" and asked for a rework in the
style of other music apps on mobile; from three mockups he picked the
editorial one (research/mobile-mockup, look B). What made it glitchy: six
sections each popped in on their own as their data landed, so the page
reflowed six times; the hero dissolved again every time a better picture
arrived; a cloud of stat chips wrapped to three lines; and the discography
tab jumped when the identity merge re-filed releases.

The page now: a 300dp portrait with the name on it and the play disc beside
it, Shuffle · Follow · one line of facts, the latest release as a card, Top
Songs as swipeable columns of four with dots ("See All" opens the list),
Albums and Singles & EPs as carousels with a link to the full discography,
On this phone, Similar Artists, and About with the bio, tags and socials.
The first sharp picture is the picture, locked; a cold visit renders
skeletons the size of the sections that are coming and swaps once; there
are no tabs on the page, the rails simply grow. No band over the hero — the
shell's two floating discs are the only chrome, as everywhere else. The
hero and its copy behind the scroller are both 300px now, as they must be.
2026-09-18 17:29:17 -05:00
KeelrLeek bb7f1f1b2d android auto: the card says what the engine is doing, and a skip no longer leaves the old song playing
Reaching a row whose bytes are not on the phone read as the player going
to the next track and freezing — the title changed, the position sat at
0:00, nothing said why — and after a skip the old song kept sounding under
the new title until the fetch delivered, because start_at notified the
card and spawned the resolver without stopping the engine (only the
natural boundary was silent).

The engine stops before it looks now: silence, the title, a spinner. The
card is built buffering from its first frame (headlessTrack carries
`resolving`), and the engine narrates on the artist line — "Signing in to
Soulseek…", "Searching Soulseek…", "45 copies · connecting to <peer>…",
"Streaming from <peer>…", "<peer> isn't sending · trying another copy…",
"No copy found · trying the next track…" — through headlessState, kept in
NowPlayingArgs.status and shown by SoulStreamPlayer while buffering; the
engine's own Playing event clears it. The first tap's reply builds a fresh
state after the JNI call the engine narrated inside, so it carries the
line over rather than showing the artist for a beat.

Verified on the head unit with a three-row test playlist: a cold tap reads
"Searching Soulseek…" at 0:00 and plays at 0:14 with its cover; a skip
into a row not fetched yet reads "Searching Soulseek…", then "Streaming
from nouvellevag0…", then the artist again at 0:07, with the prefetch
fetching the row after it.
2026-09-18 15:44:25 -05:00
KeelrLeek 9f7661afab android auto: the car's queue works like the page's, and every row gets its cover
"Needs the app" is gone. It named what the cold service could not do — the
resolver and the Soulseek session live in the page, and a car that starts
the app cold has no page — and Kyle's answer to the label was the right
one: a tap should find a copy and play it, in the background, and a
playlist should keep going through rows that are not here exactly as the
page keeps going.

headless_slsk.rs is a Soulseek session for a process with no page: the
stored account, one search (artist + simplified title, the server's
excluded phrases stripped), copies ranked by rules close to the page's
songCandidates — the title in the filename, streamable formats, a duration
within eight per cent, lossless over 320 over the rest, the artist in the
path, a free upload slot before anything else — and a download into the
same stream cache the page uses. Its own tokio runtime: a car-started
process has no Tauri one.

headless.rs is the page's queue in Rust. resolve_playable queues EVERY row
of a playlist in order, a row not here with its remembered peer file and
no path. The engine starts a row from its file when it is whole, from the
fetch already filling its cache file when one is, and otherwise finds it
on the network right then (the remembered peer first, then the search),
playing the bytes as they land; a peer that sends nothing for fifteen
seconds is dropped for the next copy, twice, and when a row has no copy
anywhere the queue moves on — only the last row's failure reaches the
card. The next three rows are fetched ahead of time, four seconds after a
start so nothing competes with the track that is starting, and when the
immediate next is whole before the current one ends it is appended to the
sink and the boundary is gapless — the page's preloadLocal and
preloadCachedStream in one. Fetches are keyed by cache file, not index, so
a shuffle cannot start the same file from the same peer twice. The card
shows the title at once with a spinner (STATE_BUFFERING through
headlessState), then the track; every source is remembered in the mirror,
so the row is "here" next time.

One session per process: when the app opens over it, login_inner ADOPTS
the client and reads its events through subscribe() instead of logging in
again — two logins from one address in quick succession is what the server
throttles — and the fetches in flight become the page's own preloads, so
the row the car was fetching ahead lands gaplessly under the page too.

Covers, always, like the desktop. Thirty of GhettoTechie's forty rows have
no catalog cover, and the car drew the app icon over them. In the cold
process the cover embedded in the file is extracted — the desktop's own
tagger::extract_art, keyed by the audio path under
<library>/.soulstream/stream-art/by-file/, a miss remembered as an empty
file — for browse rows, the card and the Continue row, and a row fetched
cold gets the same once 200 kB of it have landed. On the phone,
coverart.svelte.js is the desktop's playlist backfill as a background walk
over every playlist: the file before the catalog (the row's own cached
copy, persisted as art_path), then the ONE strict ladder the import review
uses (findCatalogTrack, persisted as art_url + track_id) — never a loose
name lookup. After boot, on every playlist change, every six hours, the
open page's rows first, a miss remembered for a day and only when the
catalog answered. The player does the same for the track that is playing
(ensureStreamArt: embedded, then catalog), makes the URL the entry's art
key and hands it to the walk to persist. The page's own hole is closed
too: a streamed row with no cover got no art key at all, and where it had
one the extractor's FILE PATH was stored where a data URL belongs; such
rows get a "stream:" key and the file is read as a thumbnail, and a stream
row's art_path now draws on the phone.

A car that starts the app cold has no page to walk for it, so the cold
process asks the catalog itself. cover_hunt.rs is catalogMatch.js in
Rust — the same Deezer field queries through the metadata crate, a match
only when the artist agrees, the base title agrees, the version
qualifiers are compatible and a known duration does not contradict; the
two are meant to stay in step — fed by every browse and every queue built
from a playlist. A blank stream row is answered from what was asked
before (stream-art/catalog.json, a miss for a day and only when the
catalog answered), else queued for one thread that looks rows up one at
a time behind the client's own Deezer pacing, writes the answer on the
row (art_url and the track id, only ever filling a NULL; the library is
WAL and the page's writer is another connection), and re-lists the
playlist on the car. The track that is playing gets the same, through an
entry that moves only the card's art and never its position. The cold
Soulseek search is the page's two-rung ladder as well: artist plus the
simplified title, then the title alone with the artist required in the
peer's path; the hunt's last rung is the page's last rung too, the
artist's whole shelf flattened to tracks, for the records the search
index buries — spent only on the track that is playing, bounded to forty
seconds, and the cover memory carries the matcher's version so a "no"
from an older matcher is no answer once a rung is added.

Two things the head unit found in the seam. media3's default onSubscribe
answers "not supported", so nobody was ever subscribed and the re-list
notice reached nobody; LibraryTree accepts subscriptions now, and covers
land on the car's list within seconds of being found. And the Soulseek
client spawns a download's transfer task with tokio::spawn, which needs a
runtime on the calling thread — the engine's resolver thread is not a
Tauri command, the spawn panicked, the thread died without a word, and
the card sat on BUFFERING; every client call from an engine thread runs
under the cold runtime's enter() now.

The phone's artist page never ran the MusicBrainz pass. It built its
discography from Deezer's list alone, so the releases Deezer's artist
listing omits — EPs, and whole albums — never appeared, nor the
authoritative release types or the guest-appearance and co-tenant
arbitration. The desktop's identity merge is a plain module now
(lib/artistIdentity.js, moved out of state.svelte.js verbatim, the
desktop importing it back), and the phone runs it after the page is on
screen — one implementation for both surfaces.

What the restarted head unit found, all in the seam between a car session
and the page — and one bug older than this branch.

The pump's calls into Kotlin never worked. "auto advanced to" has not
appeared in a single log while cold queues advanced through whole albums
under a card stuck on track one, and the new end-of-queue notice did not
fire either. FindClass on the self-attached pump thread resolves through
the system class loader, which cannot see com.soulstream.mediasession
.AutoNative; the lookup failed and the exception was cleared, silently,
every time. The class is taken from the object Kotlin hands every JNI
entry — on a Java thread, through the app's own loader — and kept as a
global reference; every pump call goes through call_auto_native.

Autoplay on connection worked unprompted and resumed Starboy at 3:50 of
3:50: the page writes the position one last time when a track runs out, so
a resume point can sit at the end. resume_queue treats a position within
two seconds of the duration as "the next item from the top", keeps every
entry (one not here is found on reach, like any other), and the headless
engine reports end-of-queue to Kotlin, which turns the card to paused. A
seek on the page reached the resume point only at the pump's next
five-second flush; seekSecs writes the session at once.

Opening the app over a cold car session handed the page the right queue
and the page called it a paused session that needed re-sourcing: the
transport snapshot it reads is built only from events after the page
subscribes, and an engine already playing sends no Playing. The bar said
paused, the card said PAUSED, the audio kept going. playback::init seeds
the snapshot from the engine's own progress when it adopts a running
engine, so the page follows what is sounding.

preview::init opened two more output streams at every launch for the
desktop's hover previews, which the phone never calls — three live AAudio
tracks on our uid with one sounding. It is gated off on Android.
2026-09-18 15:00:11 -05:00
KeelrLeek 5ddb6d4d59 tooling: gradle's temp dir on the work machine, and the walk's transient toast
Gradle's AF_UNIX pipes die anywhere under the user profile on the work
machine ("Unable to establish loopback connection" → "Invalid argument:
connect"), and every gradlew in build.sh, dev.sh and the prober went
through it. env.sh now points TMP/TEMP at C:\gradle-tmp when that directory
exists; a machine without it is untouched.

The mock walk's artist-hero-cleared step asserted a toast — a transient the
probe landed after, twice, with the toast plainly in the still. The
expectation also accepts the hero itself being gone.

Also: commands.rs carried a real NUL byte inside b"dwmapi.dll\x00", which
made grep call the file binary; it is the \0 escape now.
2026-09-18 12:35:55 -05:00
KeelrLeek 6f789687e4 android: keep a playlist offline
"Keep offline" on a playlist's More sheet: every streamed row fetched into
the stream cache in the background and pinned there, so the whole list
plays with the app shut — in a car, in a pocket — the way a downloaded
playlist does in any other player. Not the library: "Download all tracks"
files rows as albums, which is Save; this keeps the playlist a playlist of
streamed rows that are simply already here.

One pass at a time, one row at a time: the remembered peer file first, else
the resolver's ladder over the network, then the same preload_stream the
player uses for the next track, polled until whole, then pinned in the
stream-source mirror — and the cache pruner leaves a pinned file alone,
or the retention window would quietly undo the switch. The row being played
right now is skipped (a second transfer of a file a peer is already
streaming to us deadlocks both). The fetch rides the downloads notice in
the shade, which is also what keeps the process alive once the app leaves
the screen; Android 12+ lets that service start only from the foreground,
so a pass that begins on screen keeps going in a pocket. The list lives in
config.json (`offline_playlists`); the header says "Kept offline · N of M
here" once a pass has looked.

Verified on the phone: the shade read "Keeping 29 tracks offline", eleven
rows were done in the first forty seconds, and the car's playlist row went
from "8 of 40 here" to "10 of 40 here" two minutes later.
2026-09-18 12:35:55 -05:00
KeelrLeek 17942fc04b android auto: the car plays what is here, resumes, and is told the rest — soulseek dials back, and the storm is over
Four things the car could not do, all from a process with no Activity.

A streamed playlist row plays cold when its bytes are already here. The
page's memory of which peer file a catalog track resolved to lived only in
localStorage; it is mirrored now to `<data>/stream-sources.json`
(stream_sources.rs) — every remembered source, and the whole store once at
boot — and auto_browse.rs looks a row up there, computes its cache file
with the same hash playback::cache_path uses, and calls it playable when
the file is byte-complete. GhettoTechie reads "8 of 40 here" cold and a
tapped row plays from the cache with its catalog cover and colour.

The car resumes, the way every other player does. Where playback was is in
`<data>/resume.json` (resume.rs): the page names the queue whenever it
writes its session, the engine's pump keeps the position. media3's
MediaButtonReceiver is declared, so a PLAY key into a dead app — a
Bluetooth head unit, the Quick Settings card, Auto's autoplay on
connection — starts the service and lands in onPlaybackResumption, which
answers with the `ss:resume` row; the RECENT root serves the same row, and
the Recent tab lists it first. A force-stopped app came back PLAYING where
it left off from one `cmd media_session dispatch play`.

The car is told. A tap that cannot play — a streamed row with no cache copy
while the app is cold, a hand-off the resolver refused — used to be
silence. MediaHub.fail puts the reason in the playback state (STATE_ERROR,
which Auto draws as a card) and sends a SessionError carrying "Open
SoulStream"; the next track withdraws it. The page reports its own verdict
through the new `remote_failed` command.

Every tap while the app is UP is the page's. A library row tapped in the
car used to start on the engine underneath the page's own queue — the bar
naming one track while another played. `AutoNative.describe` spells the
row for the page and `playMediaId` plays it as the page's queue; the plugin
disarms the hand-off in onDestroy, and with the page gone the card's
transport goes to the engine. When the app opens over a car session, the
headless queue is handed to the page once (`playback::init` takes it before
clearing) and restoreSession builds the bar from it.

Every playlist row is playable: Auto draws a non-playable, non-browsable
item as a tile and BROWSES it on tap ("No items"). Rows that need the app
say so and fail on tap with the card; rows whose file is here wear the
host's own downloaded mark. The Recent tab's album subtitle counted played
tracks; it counts the album's now. The page never clears the resume point
and only drops its own session once it has booted — a visibility change
under a launch with the screen off wiped both.

The Soulseek session dials itself back after a drop. A server connection
dies for reasons that are nobody's — the phone changes networks, Android
freezes a backgrounded process past the five-minute keepalive — and until
now every one of those ended as `Event::Disconnected`, the page stamped
offline, and every streamed track after it refused with "sign in on
Community"; in a car nobody opens the login card. `schedule_reconnect`
redials with the stored credentials at 5, 15, 45, 120, then 300 s with
jitter, under the server's rapid-login throttle. Every session carries a
generation: an explicit login or disconnect bumps it, a redial loop stands
down when the user acts, and a drop reported by an OLDER session's pump
(login_inner shuts the old client down and its reader reports after the new
session is up) no longer stamps the fresh session offline — that race was
there all along. The page shows "connecting" on `reconnecting`, the
resolver waits up to twenty seconds for a dial in progress, and debug
builds expose `debug_drop_connection`. Verified by the debug command (drop →
redial 1 in 5 s → online at 7 s) and by a real drop when the head unit
connected: redial 1 failed on DNS, redial 2 at +15 s logged in.

The notification storm was media3 restarting the service into
onStartCommand. The morning's logcat had the card re-posted six times a
second for over a minute ("Package enqueue rate is 6.6 … Shedding"), then
"Too many PendingIntent created for uid" (media3 mints a fresh one for the
custom button on every rebuild), then twenty refused foreground starts in
two hundred milliseconds. media3 starts this service ITSELF on every
foreground notification update — MediaNotificationManager.startForeground
calls startForegroundService(startSelfIntent), a bare component intent with
nothing but a uid extra — and onStartCommand promoted on every intent it
saw, which re-posted the card, which made media3 start the service again.
It ran for as long as the service was foreground: while a track played,
and for the ten-minute tail after a pause (the capture stops at pause +
10:00 exactly). onStartCommand now promotes only for an intent that carries
an action or a data uri. A 250 ms coalescer on card rebuilds
(MediaHub.refreshSoon) logs the rate; it stayed silent through the storm,
which is what pointed at the restart. A full minute of playback after the
fix: zero shedding lines, zero PendingIntent complaints, zero refused
starts.

Verified on the Desktop Head Unit and the Pixel: the tree, the Continue
row, cold cached playback, the error card and recovery from it, the warm
hand-off, the media-key resume, the redial; mock walk 255/255, phone walk
142/142.
2026-09-18 12:35:54 -05:00
KeelrLeek 8f46f9c663 android auto: a catalog row is handed to the app, not failed at
Every entry in a streamed playlist is `kind='stream'` with no file on this
phone — only a catalog id. A service-only process cannot turn that into audio:
the resolver is hundreds of lines of shared JS that exist while a WebView does
and not otherwise. So the car tapped those rows and nothing happened, which is
the worst of the three possible answers.

Now the process asks whether the APP can do it instead. Rust, finding nothing
local, reports WHICH playlist and entry were meant rather than just failing —
a catalog row is not a broken row, it is one this process cannot do alone, and
that difference is what decides between an error and a hand-off. Kotlin carries
it over `MediaHub.onRemote`, which is non-null only while the plugin is loaded,
which is only while a WebView exists: the same fact is both the channel and the
test for "is the app warm". And the JS plays it with `playFrom(playlist, items,
item)` — the exact call the half-sheet makes when the same row is tapped in the
app, so the queue, the provenance and the failure message are the app's own.
Nothing about the car is special once it arrives.

Cold, it says so instead of pretending: the focus is handed back rather than
held for silence, and the log names the row. A phone in a pocket genuinely
cannot play a Deezer id, and a tap that quietly does nothing is worse than one
that stops.

Two things the same change fixed on the way past. A streamed entry has no cover
FILE, only a remote URL, so `COALESCE(art_path, art_url)` gives those rows their
sleeves and a remote uri now goes to the host untouched instead of through a
FileProvider whose root check it could never pass. And Recent, playlists and an
artist's albums join Albums as GRIDS — all four are things known by the picture,
which is what the grid is for.

`probe.sh` gained `SS_KEEP=1`, without which the warm path cannot be tested at
all: the script force-stops SoulStream, which is precisely the app the hand-off
needs alive.

**Proven:** cold answers `auto cold, cannot resolve` and releases focus; warm
answers `auto hand-off to the app` and the event reaches the JS with no console
error. **Not proven:** that the audio then starts. Those Deezer ids have to go
through the resolver exactly as they do when tapped in the app, and if the app
cannot play them the car will not either — which is correct behaviour and
indistinguishable, from here, from a bridge that silently failed. It wants
checking with the app in front of you.
2026-09-15 17:01:43 -05:00
KeelrLeek 54245a33b5 android auto: Recent means albums and playlists, and is never empty over a full library
"Recent" answered with TRACKS out of the `plays` table, and on a phone whose
history had just been wiped that was "No items" sitting over a full library —
the first thing a driver sees, saying the app has nothing.

Three changes, each about what the word means in a car.

**Albums, not tracks.** "Recent" in a car is "put that back on", and what
anyone means by that is the record. A list of single songs plays one and stops.
`plays` records what was played by NAME rather than by track id, so getting
back to an album means matching the pair again; anything played that has since
left the library drops out of the join, which is right for a row whose promise
is that tapping it works.

**Playlists, which were absent entirely.** They are in the database, they are
local, and they play from cold exactly as albums do — they were missing only
because the four root slots were spent, and Recent is somewhere they can live
without taking one. Counted out of `playlist_items` rather than
`playlist_tracks`: it is the richer table and the one that knows whether an
entry is a local file or something that needs the resolver. A playlist that is
part catalog says so on the row — "Playlist · 4 of 9 here" — so the gap is
declared before the tap rather than discovered after it, and tapping plays the
local entries in order. Entries that need the phone are shown rather than
hidden, so the car's list matches what is on the phone, and marked unplayable
rather than pretending.

**An unplayed library is not an empty shelf.** With no history at all, Recent
falls back to the newest albums, which is a defensible reading of the word and
a great deal better than an empty tab on a fresh install.

`ss:playlist:<id>` and `ss:pitem:<id>` join the tree and `resolve_playable`, so
a playlist or one of its tracks plays with the rest behind it.

Two fixes to the instrument that found this: `probe.sh` was passing
`--es play ""` when no query was given, which `am start` rejects outright
("Argument expected after play") — it is conditional now. And a splice left
`fn track_node(fn track_node(` in the source, which rustc reports as an
unclosed delimiter at the END of the file, pointing 400 lines away from the
mistake.
2026-09-15 15:08:21 -05:00
KeelrLeek 062e1845e0 android auto: one engine per process, actually — init was still spawning its own
`headless.rs` was written around a guarantee its own doc comment made and the
code did not keep: `playback::init` still called `Player::spawn_with_device`
directly. So a car could start a cold process, play a record into it, and the
moment the app's Activity came up the process built a SECOND player — two
AAudio streams out of one app, playing over each other. That is precisely the
failure the module exists to prevent, and nothing would have reported it but
Kyle's ears.

On Android `init` now takes the process's engine instead. The event receiver is
single-consumer and the headless pump already owns it, so the UI SUBSCRIBES to
the pump rather than reading the engine directly; and the fallback queue is
cleared on the way in, because from that point the JS decides what plays next
and two things advancing one engine would skip a track each time. The desktop
path is untouched — it still spawns its own, because there is nothing else in
that process to share with.

Also here: a log line on the transport's routing decision
(`auto playWhenReady=… headless=…` and `auto transport …`), added while
chasing a pause that turned out to be working. It is kept because the question
"did this button go to Rust or to a WebView that is not there" has now come up
three times and is invisible without it.

The pause was real, and the measurement was not. "One active AAudio track after
a pause" proves nothing — a paused sink still shows as active. The honest test
is that the session reports PAUSED(2) and the position stops moving, which it
does: 24873 ms, unchanged five seconds later.
2026-09-15 14:47:40 -05:00
KeelrLeek f59a2d773e android auto: the position the engine is actually at, and search by voice
**Pausing looked like the track restarting, and it was not only a look.** The
card carried the position it was BUILT with — zero, on the headless path,
because nothing wrote it again. media3 extrapolates a position while playing,
so the bar moved and seemed right, but every state rebuild snapped back to that
stale zero. Worse than cosmetic: media3 then BELIEVED the player was at zero,
and reconciled on resume by seeking there, so the track really did start again.
The same staleness made a seek look like it had done nothing — the engine moved
and the next rebuild reported zero.

`getState()` asks the engine now, on every rebuild, through `positionMs()` and
`sounding()`. One JNI call and always true, instead of a number remembered from
the moment playback began. A seek moves the reported position immediately
rather than waiting for the engine's next event, so the scrubber does not lag a
beat behind and read as snapping back.

**Search, which is the half of round fifty-two that needed no decisions.**
`onSearch` answers the count and `onGetSearchResult` the rows; both run the same
couple of LIKEs over the library, because there is no search index in this
schema and a few hundred albums do not need one. Albums rank before tracks:
"play Discovery" almost always means the record, and whoever wanted the song
would have said more — which matters because a voice command takes the first
result.

Voice comes in by a different door and it is implemented too. "Play X" arrives
at `onAddMediaItems` as an item with NO media id and a `searchQuery` instead;
Google's own samples disagree about whether Auto uses that or `onSearch`, so
both are answered. An empty query is "play some music" and gets Recent rather
than an error.

The prober gained a `search:` prefix for it — `probe.sh 2 false "search:discovery"`
issues `playFromSearch` through the session token, which is the path a spoken
sentence actually takes. It answers `auto voice 'discovery' n=1`,
`auto play ss:album:2 queued=14`, and the controller reports "One More Time"
playing: a sentence turned into a record, in a process with no Activity.

Still library-only, and that is now the whole remaining question. Playlists are
local and would play from cold exactly as albums do, blocked only on which four
root slots to spend. Catalog and Soulseek rows cannot resolve without the
WebView, but can whenever the app is warm — browsable always, an honest error
only when cold.
2026-09-15 14:21:28 -05:00
KeelrLeek 4d75395411 android auto: the car's own controls, the covers, and four tabs with faces
Everything here came from putting the thing on a real head unit and looking at
it, which is the only reason any of it was found.

**The transport did nothing.** Pause, seek, next and previous all routed
through `MediaHub.act`, which is a round trip out to the WebView — and a car
that started a cold process has no WebView, so every button drew, moved, and
sent a message to nobody. `AutoNative.driving()` reports whether the headless
queue is what is playing, and when it is, the transport goes straight to the
engine instead: pause/resume, a real seek, and next/previous that move the
fallback queue and push a card update after them. A normal phone session is
untouched and still goes through the JS, which owns shuffle, repeat, `alts`
failover and the real queue.

That is the THIRD instance of one bug: the headless path silently skipping
something the WebView path does for free. Audio focus was the first (a live
AAudio track, muted, audible to nobody), the card was the second (STATE_NONE
over playing music), the transport is this one. Anything the JS does on the
way to making a sound has to have an answer here too, and the failure is always
silence rather than an error.

**The covers, and the colour.** The playback screen was showing the app icon,
because nothing filled the card's art. Android Auto washes that screen with a
colour it extracts from whatever artwork it is handed — so every record was
tinted the falcon's blue. The engine already knows the album's cover path, so
it now travels with the track and the card is built from it. Random Access
Memories comes up gold. The app derives an accent per cover and the car cannot
be told to, but it turns out the car does it anyway, for free, given art.

**Four tabs with icons**, which is what every other media app in the car has:
a root browsable item's artwork is drawn as its tab icon, and ours had none, so
they rendered as bare words. `android.resource://` URIs rather than the
FileProvider — these are our own resources, identical on every install, and
granting a per-connection URI for an icon that never changes would be wrong.

**The grid, in the right place.** The hint was set on each node's own
`LibraryParams` when returning its children; Auto reads it from the ROOT as a
default and from the PARENT ITEM as an override. So the albums drew as a list
with the grid hint set correctly and delivered nowhere. The root declares
`CONTENT_STYLE_SUPPORTED` now and the `ss:albums` tab carries the override,
along with each artist, whose children are also sleeves.

**Shuffle** is a `CommandButton` on `setMediaButtonPreferences` — both spellings
exist in 1.10.1 and `setCustomLayout` is the older one. It uses the PREDEFINED
`ICON_SHUFFLE_ON` rather than a drawable of ours: a custom resource the host
does not recognise is dropped silently, which is what happened the first time,
and `ICON_SHUFFLE_OFF` names the state and is drawn as a circular arrow that
reads as repeat. It reorders only what is AHEAD, so the current song does not
restart under the driver's hands and "previous" still goes back to what was
actually heard.

The accent is `#31C0CB` in a car theme named by
`com.google.android.gms.car.application.theme`. Its parent is a FRAMEWORK theme
because the style is resolved inside the car's process, where AppCompat and
Material are not guaranteed to exist.

Proven on the Pixel 6a through a real Android Auto session, driven from the
DHU: the four tabs render with their icons, Albums is a grid of full covers,
the playback screen shows the album art with the colour pulled from it, and a
media `next` moves the queue and lands the card on "The Game of Love".

**What this does not touch, and it is the next question.** Everything in the
tree is a local file. Playlists are in the database, are local, and would play
from cold exactly as albums do — they are absent only because Auto gives four
root slots and four are taken, which is a trade to make rather than a limit.
Catalog and Soulseek rows genuinely cannot play from a cold process, but they
CAN whenever the app is warm, through the same `MediaHub.act` round trip the
transport just stopped depending on — browsable always, honest error only when
there is no WebView. That is a better answer than the design's "never" and it
is where this goes next.
2026-09-15 13:30:54 -05:00
KeelrLeek f50cf2018a android auto: a tap in the car plays, with no Activity anywhere in the process
Android Auto starts this app by binding a service. There is no Activity, no
WebView, no `tauri::AppHandle` — and until now no way to make a sound, because
the engine lived in Tauri's managed state. `src-tauri/src/headless.rs` moves it
into a `OnceLock` owned by the PROCESS: whoever needs audio first builds it,
`playback::init` on a normal launch or a car tapping a row into a process with
no UI, and everyone after that gets the same one.

**That singleton is the whole safety story.** `Player::spawn` opens an output
stream, so two of them in one process is two AAudio streams playing over each
other — the failure this module exists to make impossible. The event receiver
is single-consumer, so the pump lives there too and offers a subscription slot
for the WebView to claim when it finally appears, rather than a second engine
being stood up beside the first.

The queue here is deliberately not the app's queue. The JS one keeps shuffle,
repeat, `alts` failover, provenance and the three-ahead preload; this is a
vector and an index whose only job is that the album keeps playing when there
is no JS alive to advance it. Its metadata is read ONCE, when the queue is
built, so advancing never touches the database again — which matters when the
thing advancing is a background thread in a process with no UI.

Resolution happens in Rust, against the library, read-only. A tapped
`ss:track:<id>` brings its whole album at the right index, because selecting
one song and getting silence after it is not what a record does. Nothing
trusts a URI from the controller: the tree only ever publishes `ss:` ids, so
there is no file path for anyone to hand back, and an id that resolves to
nothing returns a count of zero rather than a guess.

Kotlin's half is three things that each fail invisibly on their own.
`COMMAND_SET_MEDIA_ITEM` is advertised now — without it a browse row is drawn
as playable and does nothing when tapped. `onAddMediaItems` passes ids through
instead of media3's default `UnsupportedOperationException`, which kills the
tap before it reaches the player. And `handleSetMediaItems` does the work on
its own thread, because the first tap in a cold process opens a database AND
starts an audio engine, and it is called on the application thread where ten
seconds is an ANR.

**Two things the device taught, both of which looked like silence.** The
headless path has to request audio focus itself: the first run showed a live
AAudio track on our uid, decoding perfectly, with `PortMuted=true` and the gain
at `-inf` — audible to nobody. On the WebView path the JS asks for focus; a car
tapping a cold process has no JS to ask. And it has to write the card itself:
`getState()` mirrors `MediaHub.state`, which only the WebView ever fills, so
the second run played audio while the session still reported `STATE_NONE` with
no title — a head unit showing a blank player over music. The engine knows what
it started, and that now goes into `MediaHub.update` in the shape the card
already reads.

Proven by the prober, which gained a `play` mode for it: bind cold, issue
`playFromMediaId` through the session token the browser handed back — Auto's
own path, not an interface of ours — and read the state back. `ss:track:1`
answers `queued=13` (the album behind it), and the controller reports
`state=3` with the title and a position moving 3.8 s → 11.8 s → 19.8 s across
the run. `probe.sh <depth> <recent> <mediaId>` is the invocation.

**Not done, and it is the display rather than the audio.** Nothing tells Kotlin
when the fallback queue advances, so the card will keep showing track one while
track two plays. The position moves on its own — `SimpleBasePlayer`
extrapolates it while playing — but a track change and a seek both need the
pump to push, and the pump currently only speaks to a subscriber that exists
when there is a WebView. The handover the design asks for (a UI arriving over
live headless playback, and exactly one queue driver surviving) is untested for
the same reason.
2026-09-15 11:49:40 -05:00
KeelrLeek 3f56f274c5 android auto: the covers, and the grid a record is owed
Round fifty put the tree in the car with every row a bare line of text. This
gives the rows their sleeves and asks for albums to be drawn as a grid — the
two things the design picked that a tree alone cannot express, and the only
parts of a car screen the app actually controls.

**Artwork goes out as a `content://`, not as bytes.** media3 will carry a cover
as data in the metadata and the legacy stub Android Auto binds turns that into
a bitmap per row — inside a single Binder reply with a one-megabyte ceiling for
the whole list. Two hundred rows of even thumbnailed JPEG is a
`TransactionTooLargeException` on a list that worked yesterday. A URI is a few
dozen bytes and the host fetches only what it draws. The provider stays
`exported="false"`; each browse grants read on its own URIs to the package that
asked, which `onGetChildren` already knows.

Reaching the library needed a new `file_paths.xml` in the res overlay, and it
needs BOTH spellings of one directory. Covers live under the app's DATA dir,
which `files-path` cannot reach — that tag means `getFilesDir()`, the `files/`
subdirectory. `root-path` can, but FileProvider resolves a file through
`getCanonicalPath()` before matching, and for the primary user
`/data/user/0/<pkg>` canonicalises to `/data/data/<pkg>`, so a root scoped to
`/data/user/` never matches on a phone. `/data/user/` still earns its place:
a car's driver profile is user 10 and does not canonicalise away.

The grid is `CONTENT_STYLE_BROWSABLE_HINT` on the parent's `LibraryParams` —
grid for `ss:albums` and for an artist's albums, list for artists and tracks.
Those keys belong to the host rather than to media3, which is why the same
constants serve a phone-projected car and a head unit alike.

**Three failures, one cause: a catch with nothing in it.** `artUri` returned
null on any throw and said nothing, and every row came back coverless with the
database, the res overlay and the APK all checking out — the binary XML was
dumped out of the APK to prove the root was in there. It cost an afternoon and
two wrong diagnoses. One `Log.w` in the catch named both real bugs in a single
run: `JSONObject.optString` returns the four-character string `"null"` for a
JSON null rather than `""`, so the tabs were handing `File("null")` to the
provider (`isNull` is checked first now); and the canonical-path substitution
above, which is visible ONLY in that exception's text — `readlink -f` on the
device does not show it, and believing it cost the first wrong answer. The
warning latches to one line per process, because two hundred identical traces
would bury the tree logging it sits beside.

The prober reports each row's `iconUri` now. A tree walk could say a row
arrived but never whether it would be drawn with anything, which is the one
thing this round changes, and `art:""` against a real `content://` is the whole
difference.

Verified on the Pixel 6a through Android Auto's own binder with the app cold:
both albums come back carrying
`content://com.soulstream.app.fileprovider/ss_library_canonical/...cover.jpg`.
The car screen itself is still the Desktop Head Unit's to show.
2026-09-15 11:11:35 -05:00
KeelrLeek 515b3da71f android auto: the tree under the root — four tabs read cold out of library.db
Round forty-nine put SoulStream in the car with an empty root. This fills it.
`onGetChildren` answers the four tabs Kyle picked — Recent · Albums · Artists ·
Saved — and everything under them: albums with their artist and track count,
artists grouped the way the app groups them, an album's tracks in disc and
track order, and every local file under Saved. `src-tauri/src/auto_browse.rs`
is one JNI export over `library.db`, and `AutoNative` in the plugin is the
Kotlin half.

**It reads read-only and it never calls `Library::open`.** That constructor
runs `PRAGMA journal_mode = WAL` and a `CREATE TABLE IF NOT EXISTS` batch —
writes, both of them — and a service that takes a write lock on the library to
draw a list is a bug waiting for a long drive. The path is derived the way
`config::download_dir` derives it, but without it: that function takes a
`tauri::AppHandle` and there is no Tauri in this process, so `config.json` is
parsed read-only for `download_dir` and the default is `<dataDir>/Library`,
with the database under `.soulstream/` as the portability rule requires.

**`AutoNative` calls `System.loadLibrary("app_lib")` itself, and that is the
round's other half.** When Auto binds a cold app there is no Activity, so
nothing has loaded the Rust side and none of it exists. Loading it here also
runs `JNI_OnLoad`, which publishes the JavaVM and the app Context to
`ndk-context` — the same hand-off the audio backend needs, arriving before
there is any audio, which is what round fifty-one stands on. `loadLibrary` is
idempotent, so it costs nothing when the app is already up.

Two things a head unit taught that no unit test would have. **The root must not
depend on the database.** The four tabs are a constant, and gating the whole
dispatch on a connection meant one unreadable library cost the car its entire
structure — it read as "Media isn't available for this list", which names
neither the tabs nor the reason. A library that cannot be read should cost the
ROWS, never the shape. And **`log::warn!` goes nowhere in a cold service
process**: nothing has run Tauri, so no `android_logger` is installed, and the
first failure here was completely silent. It writes to the platform log
directly now, which is the only reason the second failure had a cause attached.

`open_read_only` falls back to a read-write handle it immediately sets
`query_only` on. A read-only connection to a WAL-mode database needs the `-shm`
beside it and cannot create one, and the mode lives in the header, so it does
not go away when the sidecars do. This has NOT been observed to bite — the one
failure that looked like it was SELinux, below — but the fallback is cheap and
the connection still cannot change a row. `CREATE` is in neither flag set, so a
missing database stays an error rather than becoming an empty one.

Verified twice. The prober walks it cold on the phone AVD: 12 nodes, 139
leaves, 2.26 s, with the album tracklists in the right order and both artists
carrying the right albums. And it is on an Android Automotive head unit —
`ss_car`, API 34 — showing the four tabs, eleven Recent rows and five albums
with their counts, which is the first time any of this work has been seen
rendered by a car rather than described.

**Not done, and deliberately not guessed at.** There is no artwork: the plan
called for cover art through the existing FileProvider via a `files-path`
entry, but the library lives under the app's DATA dir and `files-path` maps to
`getFilesDir()`, which does not reach it — so the mechanism needs deciding, not
patching. And the rows are all lists; the grid Kyle chose for albums is a
content-style hint that is not set yet. Both are presentation over a tree that
is now correct, and both want the car in front of them.
2026-09-15 10:05:39 -05:00
KeelrLeek e1bf456743 android auto: the car can see the app, and the root it opens is empty
Android Auto reaches a media app through `MediaBrowserService`, so `MediaService`
becomes a `MediaLibraryService`, one intent filter carries three actions — media3's
`MediaLibraryService` and `MediaSessionService`, plus the legacy
`android.media.browse.MediaBrowserService` that Auto actually binds — and
`res/xml/automotive_app_desc.xml` with the `com.google.android.gms.car.application`
meta-data declares the app to the car. Auto lists SoulStream, opens it, and gets a
browsable root. There is nothing under it: `onGetChildren` is left un-overridden so
the default answers `ERROR_NOT_SUPPORTED` at once, and round fifty fills the tree.

`onGetLibraryRoot` answers out of `Futures.immediateFuture` — no database, no I/O,
nothing deferred — because `MediaLibraryServiceLegacyStub.onGetRoot` blocks the
binder thread serving the host on that future with NO timeout and runs the callback
on the main handler. A root that waits on anything is a head unit that gives up.

The round carries a mitigation it cannot be split from. androidx/media #3158 — open
since 2026-04-03, confirmed again 2026-09-04, reproduced on media3 1.11.0 — has a
host that binds a `MediaLibraryService` with nothing playing drive the service round
`onCreate` → `onBind` → `onGetSession` → `onConnect` → `onDestroy` at about thirty
cycles a second, deterministically after a force-stop with no existing binding. That
is exactly how Auto connects and exactly what the prober does. media3 will not act
before an Auto-side fix lands, so the loop cannot be stopped from here; it can only
be made to cost nothing. The session, the player, the notification provider, the
audio-focus request and the becoming-noisy receiver move into `MediaHub` for the life
of the PROCESS and `onCreate` allocates and registers nothing — round forty-eight's
version would have built a platform session and a legacy browser stub thirty times a
second, registered and unregistered a receiver sixty times a second, and abandoned
audio focus thirty times a second while music was playing.

Three consequences, each a trap for the next round. `onDestroy` calls `removeSession`
and never `release()`: `removeSession` drops the session from that instance's map and
clears its listener, which is what stops a destroyed service being called back, while
`markReleased()` would latch `released` and leave a process-lifetime player that never
updates the card again. The session is built with
`MediaLibrarySession.Builder(applicationContext, …)` rather than the
`(MediaLibraryService, …)` overload, because `MediaSessionServiceLegacyStub.initialize()`
does `attachToBaseContext(sessionImpl.getContext())` — a session built against the
Service pins a destroyed Service as the base context of a live
`MediaBrowserServiceCompat`. And nothing releases the session any more, so it and its
platform `MediaSessionCompat` live until the process dies; the walk's `one-session`
check is what keeps that honest. `onCreate` logs `auto service created n=…` and
`onDestroy` logs `auto service destroyed`, which are the only way to count the loop on
a real phone.

And the instrument. `tools/auto-prober/` is a throwaway app that binds the same way
Auto does, with Auto's own root hints, walks whatever tree comes back, and logs one
JSON object per event under `SoulAutoProbe`. It is framework-only
(`android.media.browse.MediaBrowser`, not androidx, not media3) because that is the
path Auto itself takes into a media3 app, and a prober built out of the library it is
probing would not prove that path works. It lives outside `src-tauri/gen/android/`
because `tauri android init` would delete it, and borrows the generated project's
Gradle wrapper rather than shipping one, so a run downloads nothing. With SoulStream
force-stopped there is no WebView, so no CDP, so `smoke.mjs` is blind to every bit of
this — the prober is the only eye the project has on Auto without a head unit. The
number it exists for is `root`: milliseconds from `connect()` to `onConnected()` with
the process cold.

Two corrections to the plan, argued in the files. `adb shell pm dump` is
`dumpsys package` and prints NO application meta-data, so the Auto declaration cannot
be a hard walk assertion — the two that ARE hard are the intent filters
(`auto-browse`, `auto-library`), which catch the `TAURI_ANDROID_PROJECT_PATH` trap
completely because the filters and the meta-data live in one library manifest and are
lost together; the declaration is read instead by the prober, through
`PackageManager.GET_META_DATA` and the app's own `Resources`, and echoed back by the
walk as the advisory `auto-desc`. And the prober module compiles at jvmTarget 1.8 like
every other module here, not 17 — JDK 17 is what RUNS Gradle.

No Rust, no frontend, no cargo. The round was authored where none of it could be
compiled and it has now been built and run: the Kotlin compiles, the APK installs, and
the prober binds the app cold. `desc` resolves the meta-data to a real
`xml/automotive_app_desc`; `filter` shows BOTH the legacy
`android.media.browse.MediaBrowserService` and media3's own resolving to
`MediaService`; `root` comes back at ~2.2 s with `ss:root` and a session token, of
which the callback itself is about nine milliseconds and the rest is the cold process
starting; `children` answers `n=-1` with nothing in it, which is the un-overridden
default doing its job; and `auto service created n=1 reused=false` — #3158 does not
loop here.

One fix the first run earned. The prober read the descriptor's `name` out of the
android namespace, but Google's `<uses name="media"/>` is a BARE attribute with no
namespace, so it came back `[""]` — a real declaration and a dangling resource id
reported identically, which is the single thing the event exists to tell apart. It
reads the null namespace now and keeps ANDROID_NS as a fallback; the second run says
`uses:["media"]`.

None of this proves the car screen: the Desktop Head Unit or a real head unit is the
only thing that shows what Auto draws, how it groups a tree, or whether a tap plays.
This proves the app answers.
2026-09-15 09:16:53 -05:00
KeelrLeek 3a8ccd4640 android: the lock-screen card, moved to media3 before anything is built on it
The card Kyle sees every day was a hand-built MediaStyle notification over a
MediaSessionCompat, on `androidx.media`, deprecated since May. Android Auto
needs a media3 session to browse, so the choice was to write the new thing
against a deprecated library or to move the old thing first; this moves the
old thing and adds no Auto. `MediaService` is a `MediaSessionService` and a
new `SoulStreamPlayer` is the `SimpleBasePlayer` media3 asks for when the
engine lives somewhere else — rodio, in Rust, behind a WebView round trip —
so `getState()` is a pure read of the last `NowPlayingArgs` off the bridge,
queue window and all, and every button routes back out through `MediaHub.act`.

Three corrections to the plan, each the kind that only shows up on a phone.
media3 **1.10.1**, not 1.11.0, whose POM drags kotlin-stdlib 2.2.10 into a
project that compiles with Kotlin 1.9.25. The audio-focus and becoming-noisy
blocks STAY hand-rolled: `setAudioAttributes(…, handleAudioFocus)` is an
ExoPlayer convenience that lands in a `@ForOverride` method which throws, and
deleting them would have lost "another app takes the output" and "headphones
out" with nothing failing anywhere. And the service is BOUND, never started —
`MediaSessionService.onStartCommand` returns `START_STICKY` without promoting
for any intent that is not media3's own, so a `startForegroundService` would
arm the ten-second kill timer and satisfy nothing, which is the 2026-09-13 bug
in a new coat. Four more things are load-bearing and silent when they break: a
playlist that must never be empty while a track exists, `COMMAND_GET_TIMELINE`
with `COMMAND_GET_CURRENT_MEDIA_ITEM`, `STATE_READY` while paused, and both
spellings of next and previous. Each ends in a card that is simply never
posted, with nothing in any log. The window carries `queueBase` now, for a
stable media-item uid as it slides, and `repeat`, whose numbering is swapped
between the app and media3, so that what a controller is told about repeat is
the truth and media3's own view of the end of the queue agrees with the
`hasNext` the next button is advertised on.

The 2026-09-13 bug has a second door, and `onStartCommand` is what shuts it.
media3 hands the framework a media-button `PendingIntent` built with
`PendingIntent.getForegroundService`, unconditionally, because the receiver the
legacy stub resolves is now this service — there being no `MediaButtonReceiver`
left for it to find. So the SYSTEM starts this service in the foreground on
every media KEY, while media3 promotes only for a session it calls
user-engaged. A key into a process built for that key, a key on a card paused
past the ten-minute tail that does not start playback, any key with the bar
cleared: none of them qualify, and each would take the app down ten seconds
later. (The card's own buttons are milder — only play/pause, and only while
paused, is a foreground start.) So `onStartCommand` promotes first, the way
this service always did — through media3's own `onUpdateNotification` when
there is a card to post, and otherwise with a plain notification of its own,
posted and withdrawn in the same breath. `Service`'s
`startForeground` is final, so the split is by what media3 can be asked to do,
not by watching it.

The walk gains `one-card` and `one-session` — the only checks that tell a
landed migration from a doubled one — plus an advisory `media-plumbing` line;
the logged window grew `base` and `rep`, which are the only readback either
field has anywhere on the device and which `queue-window` now asserts; and the
paused-restore wait goes to 25 s, which ANDROID.md had already claimed in one
place and contradicted in another. ANDROID.md also says plainly what the walk
cannot prove and Kyle has to try himself: headphones out, another app taking
focus, the card's actual look, and a Bluetooth headset's own buttons. The
Kotlin is unbuilt — no Kotlin compiler here — and was read against javap of
media3 1.10.1 and the real sources of `SimpleBasePlayer`, `MediaSessionService`,
`MediaNotificationManager` and `DefaultMediaNotificationProvider`; the manifest
and resources compile and link under aapt2, and the Rust type-checks.
2026-09-15 09:09:36 -05:00
KeelrLeek a44b4422fd android: the same SIGPIPE race, in the two focus checks it has not lost yet
deploy.sh and dev.sh both ask which app is on screen with
`echo "$FOCUS" | grep -qi`, under the same `set -o pipefail`. $FOCUS is one
line, so echo has always finished writing before grep -q exits and the race
has never been lost — but it is the identical shape that made verify-icon.sh
report a missing file in an APK that contained it. Herestrings, so there is no
writer to kill.
2026-09-15 07:48:09 -05:00
KeelrLeek c36ec439ce android: the icon gate answered fail / pass / fail to the same APK
Round 44's verify-icon.sh had never run inside deploy.sh — only by hand — and
it does two things there that it cannot do standalone.

deploy.sh exports MSYS_NO_PATHCONV=1 for its adb calls, and that switch is
global, so the APK path reached aapt2 as /f/Coding/... Windows binaries cannot
open an MSYS path, and aapt2's answer is "failed opening zip: I/O error",
which reads exactly like a corrupt APK rather than like a path. The four
aapt2 calls now get cygpath -w explicitly; unzip and cmp keep the MSYS form,
because they want that one.

The second is worse, because it is intermittent. Every membership test was
`echo "$VAR" | grep -q PATTERN`, under the `set -o pipefail` at the top.
grep -q exits the moment it matches; echo is still pushing 70 KB of unzip
listing into a pipe that just closed, takes SIGPIPE, exits 141 — and pipefail
promotes the writer's status to the pipeline, so a check that FOUND what it
was looking for reports failure. Whether it fires depends on where the match
falls in the stream, so three runs against one unchanged APK gave fail, pass,
fail, each naming a different missing file. Six of those, now herestrings:
no writer process, nothing to kill. Eight runs green afterwards.

Worth saying plainly: the APK was correct the entire time. The gate was
wrong, and a gate that is green at random is worse than no gate, because the
failure it invents points at the build.
2026-09-15 07:43:54 -05:00
KeelrLeek da4abeb8c9 android: the launcher's ground is the desktop's, not a flat colour under it
The icon round left the adaptive background as one `#FF05080B` and a note that
a gradient was being mocked. The mock settles it: the falcon covers about a
quarter of a circle-masked tile, so the ground is three quarters of what
anyone sees, and concept 04's glow survives every mask — the visible top-left
corner goes from `#05080B` to `#0E3543`. So `@color/ic_launcher_background`
becomes `@drawable/ic_launcher_ground`, a vector carrying the desktop icon's
own linear surface and the radial glow over it, every number
`src-tauri/icons/icon.svg`'s multiplied by 108/1024 and nothing else. The
colour resource stays where it is, unreferenced, because its filename is the
only thing stopping tauri's own `#fff` from colliding with it.

A vector and not five PNGs on evidence. The kit's glow has a uniform scale, so
the SVG ellipse is a circle and its rotation is a no-op, which is exactly what
`android:type="radial"` can carry; two numbers there and no vector could have
said it. The linear axis is 51.1° off horizontal, which rules out a
`<layer-list>` of shapes as well, whose angle must be a multiple of 45. It was
compiled and linked with the SDK's own aapt2 over the real merged res tree and
every stop read back out of the APK. The name is `ic_launcher_ground` and not
`ic_launcher_background` on purpose: the generated project still carries
Android Studio's template drawable of that name, so under the other name an
overlay copy that failed would silently link the green robot — under this one
it is a hard link error, which was tested too.

`verify-icon.sh` follows the layer. It does not ask whether the file is in the
APK; it asks what the adaptive icon's `<background>` resolves to, through the
resource table, in both build types, and then reads concept 04's six stops back
out of what it found — which catches a stale APK as well as a lost overlay.
Passing on a debug APK and on one put through `--shorten-resource-paths`,
failing on a stale one, and saying NOT CHECKED rather than guessing when aapt2
is absent. What none of it can see is a pixel: banding on the OLED, the glow's
weight under the Pixel's mask and how the falcon reads on a lit ground are a
launcher screenshot on the phone, and ANDROID.md says so instead of implying
the check covers it.

One slot the ground reaches is not the launcher at all. `MediaHub.appIcon()`
draws whatever `getApplicationIcon()` returns into a 512 px bitmap and hands
it to the lock-screen card as the large icon for any track whose cover has
not been decoded yet, so the card and the shade carry this ground as well —
the quickest place for Kyle to look at it, and the only reader of the
launcher icon inside the app. The walk reads that card's title and the
session's playback state and never its icon, so no step changes meaning. The
Android 12+ splash very likely changes as well, and the other way from what
the docs' one-line version of the rule suggests: the platform drops an
adaptive background only when it is a single colour close to
`windowSplashScreenBackground`, which round 44's flat `#05080B` was, so the
falcon has been drawing there alone and enlarged; a gradient is not one
colour, so it is kept and the whole icon is drawn. Likely, not proven — that
one is a screenshot too.
2026-09-15 07:22:02 -05:00
KeelrLeek afcd3cc7f3 android: the media mirror grows a queue window the card never shows
The Kotlin side of the mirror has always known about exactly one track, and
media3's `SimpleBasePlayer.getState()` — the thing an Android Auto session is
built on — wants a playlist and an index into it. So the data lands a round
before the migration that needs it, where it cannot break anything:
`NowPlaying` and `NowPlayingArgs` carry a bounded `queue` of at most
twenty-one entries, the current item and ten either side, each with its id,
title, artist, album, duration and art key, plus `queueIndex`, where the
current one sits within that window rather than within the whole queue.
`src/mobile/media.svelte.js` fills them from `player.queue` and `player.qi`;
`MediaService` ignores every bit of it, so the card is byte for byte the card
it was. Ten either side because one covers the card's own prev and next and
nothing else while a queue list — which is what Auto asks for — wants a
screenful plus a scroll both ways, and it is a cap rather than a length: a
two-thousand-track queue crosses the bridge as twenty-one entries exactly
like a twenty-one-track one, measured at two kilobytes of JSON for twenty-one
short titles and six with long ones and real cover paths, against the thirty
to eighty of base64 cover that already ride the same call once per record.

Nothing renders it, which is the whole difficulty in checking it — and both
hops of this bridge fail silently, because serde drops an unknown field and
tauri's Jackson mapper has `FAIL_ON_UNKNOWN_PROPERTIES` off, so a key spelled
snake_case where camelCase was wanted arrives as nothing at all with no error
anywhere. `MediaSessionPlugin.update` logs one line per update, and the phone
walk holds that line against `__ss.card` — the mirror media.svelte.js now
keeps of what it last sent, the shape xfernote has kept of the shade since it
was written — field for field, through a next and a previous. The art-key
count is compared as well as the size and the index, since that is the only
camelCase field an entry has. The check arms at queue index one rather than
at wherever the walk left off, which buys a row on either side — `prev()`
seeks to zero at the top of a queue and `next()` stops at the end — a track
that has only just started, so `prev()` is still a step rather than the seek
it becomes past five seconds, and a window whose own index is the queue
index, because nothing is clamped off the front while the index is under ten.
Each of the three readings has to be a fresh one at both ends, the push
counter up and a new line in the log, since a `prev()` that lands back where
it started reads exactly like a stale line otherwise; and a queue too short
to arm, or repeat-one, under which `next()` seeks instead of stepping, fails
the check rather than skipping it.

The plugin's `consumer-rules.pro` keeps the new `@InvokeArg` class by name
beside the one it already keeps; without that line a release build would fill
`NowPlayingArgs` and leave every queue entry null, quietly. Rust and Kotlin
both changed, so this one cannot go down `dev.sh` — it needs the full
`deploy.sh` detour. And everything the walk already asserts about the card is
untouched, which is the real check on this round.
2026-09-15 07:22:02 -05:00
KeelrLeek d42645d2a0 android: the icon where it moves — the glyph, the lock screen and a splash that is not white
Both notification glyphs are the same falcon now, the logo kit's monochrome
path verbatim inside a group that re-frames it for a 24dp box — the playback
card's and the transfers notice's, because a note beside an arrow does not
read as one app, and the transfers one keeps the tray bar it always had so
the falcon dives into it. The fill stays white on transparent: the shade
composites a small icon from its alpha channel alone and tints it itself, so
a coloured export would arrive as a white blob.

The lock screen falls back to the app's own icon instead of a grey hole.
`MediaHub.art` is null from the moment a track changes until the frontend has
finished decoding its cover, and null forever for a track with none, so
`appIcon()` decodes the launcher icon ONCE — through the PackageManager,
because a library module's R has no `ic_launcher` — and hands it to both the
card and `METADATA_KEY_ALBUM_ART`. The app manifest lives in gitignored
`gen/` and declares no category, so the media-session plugin's own library
manifest contributes `android:appCategory="audio"` through the merger, and a
`Theme.App.Starting` on the brand ground replaces Android 12+'s white flash —
in `values-v31` and `values-night-v31` both, because the qualifier order puts
night mode ahead of platform version and a splash written only into
`values-v31` is silently ignored on a dark-mode phone.

The walk gains `media-card`: after the session block, the card is still
posted on the `playback` channel with the playing track's title on it, and
`media-keys` below it still gets the transport back — which is what a
drawable the shade cannot inflate would actually break. The glyph itself and
the splash are screenshots; `dumpsys notification` prints a resource id and
never a pixel, and the splash is gone before the walk's first frame.
2026-09-15 07:22:01 -05:00
KeelrLeek 4ebc38d5f3 android: the falcon on the launcher — a real adaptive icon, with the monochrome layer
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.
2026-09-15 07:22:00 -05:00
KeelrLeek fbc6a0268b android auto: the UI is four tabs and a colour, not a screen — the design, decided
An earlier pass in this repo mocked a car UI from imagination: a tab strip, a
header with search and queue icons, a bottom mini-player, a big-art playback
view with a linear progress bar. None of those exist. Google's line is flat —
"You don't draw your user interface directly on Android Auto; the framework
has two well-defined UIs that are created automatically" — and the reference
frames confirm it. The bottom bar is the SYSTEM's (home, notifications,
Assistant). Progress is a RING around play/pause. The artwork sits small at
the left behind a blur of itself. About THREE rows fit on screen, not eight.

So the artifact that was needed was never a layout. It is the short list of
slots the app actually fills, and this commit decides them:

  tabs      Recent · Albums · Artists · Saved
  style     albums grid, artists and tracks list
  actions   Shuffle · Save, 2 of the 6 allowed
  reach     amber dot on rows that need the app; Save shows a tick when down
  accent    brand teal #31C0CB

Playlists loses its tab because the phone holds exactly one, and an empty tab
is a dead quarter of the only four we get. Saved takes it because it is the
set that plays with the app shut, which is the one thing genuinely different
about this app in a car. Grid-vs-list is not density — the car fits three
either way — it is what identifies the row: a record by its sleeve, an artist
by name, a track only by text. Save is the highest-value button on the screen:
hear it, save it, it plays cold on the way home. The dot marks the RISKY row
rather than the safe one, and thins out on its own as more music is saved.

Auto allows one accent pair, set at build time, and it cannot follow the
artwork the way the app does everywhere else — so it has to be the constant,
the falcon's teal.

None of this can ship before round 48. The card is MediaSessionCompat today,
Auto needs a MediaLibraryService, and two live sessions is not a safe
intermediate. Order is unchanged: 44 → 45 → 46 → 47 → 48, soak, then 49–52
build the table above.

The mockup that settles it is research/auto-mockup (gitignored): both real
libraries — the handset's three albums and the desktop's 418 — inside the real
template, at the three DHU resolutions, with an overlay that outlines which
pixels are the car's and which are ours.
2026-09-15 07:04:14 -05:00
KeelrLeek c1a2608bdd android: three the audit found — bars translucent over nothing, and two insets the WebView does not report
A pass over every screen the app can reach, at rest and scrolled, shot from the
DEVICE so the Android status bar is in the frame. Three of what it found are
nothing to do with the page's top edge and predate it; they are here on their
own.

**The bars are translucent over nothing.** Glass — the backdrop blur behind the
chrome — is off by default on the phone, deliberately: perf.mjs measured three
blurred bands taking a 60 Hz scroll to 30. But the bars kept the alphas that
were chosen to sit ON that blur, so what shipped was the middle state, 92% and
94% over no backdrop at all. The audit reads "Random Access Memories", "Alive
2007", "Latest release" and "Touch (feat. Paul Williams) 8:17" straight through
the navigation bar. The base values are the no-glass case —
`html[data-glass="blur"]` overrides them — so the base is what had to be
opaque; the blurred variants keep their alphas, because there they have
something to be translucent against. The device now answers `rgb(16, 16, 19)`
for the nav and `rgb(28, 28, 32)` for the mini bar, no alpha on either.

**The bottom inset is reported as zero, and the gesture pill is drawn anyway.**
The WebView answers 51px for the top and 0 for the bottom, while the page runs
the full height under Android's gesture handle — the nav measured y 855→915 of
a 915 viewport — so its labels sat exactly where the pill lands, straight
through the word "Library".

**And in landscape the top goes to zero too.** The cutout's inset correctly
moves to the side (portrait top=51 left=0; landscape top=0 left=51), but the
status bar is still drawn across the top there, so the clock sat on "MONDAY,
SEPTEMBER 14" and the battery icons sat on the floating ⋯.

Both are floors rather than replacements: `max(env(...), 18px)` and
`max(env(...), 24px)`, 24 being Android's own status bar height. A device that
reports its insets properly is untouched — portrait's 51 wins on its own — and
only one that reports nothing gets the floor, which every Android with a
gesture pill or a status bar needs. Everything along the bottom is already
anchored to `--inset-bottom`, so the mini bar, `.endpad`, the composer, the
sheets and the player frame all move in step with it.

What the pass did NOT find, having looked: the chip rows clipped at the right
edge of the sleep-timer and sort sheets are not broken — `scrollWidth 458` in a
`clientWidth 412` with `overflow-x: auto`, so they are meant to be swiped,
though nothing on screen says so. The player's three faces, the artwork viewer,
the track sheet, the page overflow and Community's four tabs all render
correctly.

The harness is in research/ (gitignored): shots.mjs walks every reachable
screen and shoots the device frame at several offsets, contact.mjs tiles a pass
into one sheet, clip.mjs records a GIF of the app scrolling, fadetry.mjs
previews a CSS value live without a build, and selectors.mjs checks in one
minute what the full walk takes twenty to cover. Two things it learned the hard
way are written into it: every overlay sits on top of whatever page is behind
it, and a pass once came back as twenty-five photographs of the same player
sheet; and every tab keeps its own stack, so go(tab) shows that stack's top and
only resets when the tab is already active — a pass shot Discography and
labelled it You.
2026-09-14 15:31:31 -05:00
KeelrLeek 1f9b30c51f android: the page ends at the clock — the bars go, the hero keeps the top edge
The bar was anonymous until you scrolled, then faded in carrying the title the
page already had in 27px type two centimetres below it. Kyle's rule for what
replaces it: the page is cut off where it meets the Android status bar and
dissolves there, and — the part that decides the whole shape of this —
**backgrounds may pass under the clock, words may not**.

A mask on the scroller cannot tell a photograph from a sentence. It takes both.
So the artist hero is painted twice: the page's own copy inside the masked
scroller, cut away in that band with everything else, and a second copy behind
the scroller (heroart.svelte.js), unmasked, showing through exactly where the
mask took the first one away. Same image, same box, same focal point, same
scale, offset by the same scroll — one picture to the eye, and only the name
dissolves. The second copy carries `overflow: hidden` for the same reason .hero
does: without it the 1.04 scale renders 268px in a 258px box and five pixels of
photograph appear under the hero's foot, below the black the scrim fades to.

That also retires an attempt that did not survive contact. The mask first
stepped aside for the hero on a scroll-driven `--fade-on`, which meant
rebuilding a mask-image on the scroller every frame; measured at 60 Hz median
and p90 either way, but it is work for nothing, and the flip that replaced it
put the artist's name out of existence in one step. With the photograph
protected by being OUTSIDE the mask, neither is needed: the mask is a constant
and nothing recomputes it.

No page has a bar now. A tab page's ⋯ — which is the app's own menu, the queue
and the sleep timer and Settings — moved beside that page's own large title in
`.phead`; Search and You grew the row they did not have, Community kept the one
it had. A pushed page floats a back chevron and a ⋯ over the page instead, on
flat discs with no backdrop blur, because the bar's own note records what three
blurred bands did to a 60 Hz scroll on the Pixel 6a and these would have been a
fourth.

Three numbers now come from one place. `--chrome-d` and `--chrome-gap` are the
disc and its margin; `.screen.has-chrome` reserves exactly that much at the top
of a pushed page, and `--fade-top` reaches exactly the chrome's foot. They were
independent for one build and it showed immediately: the page began underneath
its own buttons, so "Transfers" sat behind the back chevron before you had
scrolled at all, and the fade stopped 17px short of the discs so they stood on
crisp text and a record's floating ⋯ landed on a track row's own ⋯.

The walk followed. Nineteen steps reached a page's overflow through
`.gbar .iconbtn`, which now matches nothing: the four on a tab page go to
`.phead .iconbtn.muted`, the rest to `.fchrome.more`, and the album's back
chevron to `.fchrome.back`. The `title` field read the bar's own text, which is
a worse source than it looks — it only existed when chrome happened to be
drawn. The screen carries its meta as data attributes now and the three probes
read those, spelled exactly as `.gb-t`'s textContent was (the title, then the
kicker when it differs) so every expectation written against it still holds.

Checked on the device rather than argued: no `.gbar` and no `.gb-t` anywhere,
`.phead .iconbtn.muted` present on all four tab pages and opening "SoulStream ·
Queue · Sleep timer · Settings", both `.fchrome` controls present on a record,
and the title probe answering "Random Access MemoriesDaft Punk".

One thing this leaves, and it is a real cost rather than an oversight: a tab
page's ⋯ scrolls away with its header. The bar used to carry it the whole way
down, so on Home the sleep timer now wants a scroll back to the top.
2026-09-14 15:30:55 -05:00
KeelrLeek cdb861fcbd android: no bar arrives over the page — the tab pages lose their top banner
The glass bar was anonymous until you scrolled, then faded in carrying the
title the page already had in 27px type two centimetres below it. On Home that
meant "Good morning" over "Good morning", half-transparent, while the content
it covered kept moving underneath. Kyle's word: remove them.

So a tab page has no bar at all. `barOn`/`titled` and the scroll trigger they
read are gone; `showBar` is `!root`, and the element is not rendered on Home,
Search, Library, Community or You. Nothing fades in over what you are reading.

The bar was not only decoration, which is why this is more than a deletion: its
⋯ is the page overflow, and on a tab page that overflow is the app's own menu —
the queue, the sleep timer, Settings. That moves to where the page's title
already is, in `.phead`, beside Home's identicon and at the end of Library's
existing row of actions. Search and You grow the row they did not have.
Community already carried its own ⋯ and keeps it.

A pushed page still has a bar, because it carries the back chevron and a title
that is not repeated anywhere else on the screen. It is static now: rendered
only when there is somewhere to go back to, on from the first frame and titled
from the first frame. The opacity-0 default, the 180ms cross-fade, and the
`.gb-t` rise the title used to arrive on all come out of the stylesheet —
nothing is left that can catch the eye mid-scroll.

The walk reached that overflow through `.gbar .iconbtn` on a tab page, which is
now a selector that matches nothing there: `home-more` on both step lists and
the two sleep-timer steps go to `.phead .iconbtn.muted` instead. The other
`.gbar` steps are untouched on purpose — the playlist export sheet is a PUSHED
page and still has its bar, and `library-ambience` is the album page's back
chevron, not an overflow.

Checked on the device rather than argued: on Home `.gbar` is absent, the ⋯ is
present, and tapping it opens the half sheet on "SoulStream · Queue · Sleep
timer off · Settings"; on a pushed record the bar is there with its two
buttons, its title, and a computed opacity of exactly 1.

One thing this leaves: the bar was also what backed the OS status bar once a
page scrolled under it. The clock and the battery now sit on the content. That
is fine on the ground and on dark artwork, and it will not be over a white
sleeve — a short scrim with no title and no buttons would fix it without
bringing a banner back, and is deliberately not in this commit.
2026-09-14 11:09:34 -05:00
KeelrLeek 07dad65ca5 android: the keep rules, and the release build that proves them
Every APK this port has produced is `assembleArm64Debug`, and the generated
project turns R8 on for `release` and only for `release` — while tauri finds a
plugin class by string, files every `@Command` under `commands[method.name]`,
and lets Jackson fill every `@InvokeArg` field by field. Minification breaks
exactly that, and it breaks it without a word: the app compiles, installs,
launches, shows Home, and then a tap on Library folder answers "No command
pickFolder found for plugin ..." in logcat and nothing on screen. So each
plugin module now carries its own `consumer-rules.pro` naming its own
reflected surface — the shape tauri-android and tauri-plugin-dialog already
use in this build, and one that travels with the plugin into a regenerated
`gen/android`, which a rule written into that gitignored tree would not.

What belongs to no single plugin is in `src-tauri/android/proguard-soulstream.pro`,
copied into the generated project by the build script because the app's own
Gradle file is regenerated: the runtime annotation attributes, a `-dontwarn`
pair for the two `java.beans` classes jackson-databind references and Android
does not have (R8 turns a missing class into a build error, so without them
there may be no APK at all), and a keep for the four `@Command` methods
declared on `app.tauri.plugin.Plugin` itself. Tauri's own rule matches classes
annotated `@TauriPlugin` and the members they declare; the base class carries
no such annotation, so nothing names `registerListener` — which is what the
lock-screen card's buttons and the download notice's Cancel and Open ride on.

`build.sh` grows `--release`: the release profile, `--features tauri/devtools`
so the walk can still attach at all, `assembleArm64Release`, and a zipalign and
sign with the same debug key the phone already trusts, so the APK installs over
the app rather than forcing an uninstall that would take Kyle's settings with
it. With no argument the script is what it has always been, cargo line for
cargo line, so deploy.sh and ship.sh keep the path they run every twenty
minutes. And it has been built. Not on the phone and not as arm64 — as **x86_64**, on an
emulator, because that is what this machine can run — but the flavour is the
only difference: the same Gradle, the same R8, the same rules, the same
zipalign and apksigner. `minifyX86_64ReleaseWithR8` succeeded and wrote **no**
`missing_rules.txt` at all, which is the `-dontwarn` pair doing its job;
`lintVitalX86_64Release` passed, so the `-x lintVital` advice is for a failure
that has not happened. The signed APK went over the debug app with
`adb install -r` and left its data alone, exactly as the same-key argument
above says it must.

Then the minified app was walked: Home, the record, playback, the queue, the
sheets, the lyrics, the drags, the export — **131 of 131, no exceptions, no
console errors, every step as expected**, and nothing in logcat about a missing
command or class. That is the failure this round exists to prevent, looked for
and not found.

What it does not cover is the hands-only list in ANDROID.md — the pickers, the
notification buttons, the share sheet — which no walk reaches, and the four
`@Command` methods on `Plugin` itself remain a precaution rather than a
diagnosed bug. The document says so, command by command.
2026-09-14 11:09:33 -05:00
KeelrLeek 9c3273e6af android: the waits the walk could not see, and why they are off by default
The adaptive waits went in unmeasured — the comparison was still running when
work stopped. Measured against round 42's 255 step verdicts they are not green:
six steps that were `ok` at baseline fail — `artist-lanes-page`, `net-playing`,
`net-sheet`, `np-more`, `np-copied`, `np-saved`.

They were not six faults. `net-playing` threw `Cannot read properties of null
(reading 'click')` because the network row it wanted had not arrived, and every
step after it then worked the wrong track: `np-copied` and `np-saved` were
hunting for `Copy to a folder` on a LOCAL track's sheet, which does not have it.

The reading asked for three things and got one. `resolver.active` is real.
**`net.busy` and `lib.loading` are not fields on those stores** — `net` publishes
`searching`, `responses`, `files` and `groups`, `lib` publishes `loaded` — so
both read `undefined` on every poll, joined a constant into the fingerprint, and
left a search in flight indistinguishable from a settled page. The catalog was
not read at all, which is `artist-lanes-page`: an artist lookup sits in
`catalog.artists[name].status` until it turns `ready`, and the mock holds that
page for four seconds on purpose.

The names are corrected, the catalog's loading entries are counted, and the
counters that move as results land are in the reading, so partial progress
registers as progress.

The second half matters more than the names. **A stable fingerprint is not a
settled page.** A search that has sent nothing back yet holds every counter
still, so "two identical readings 90 ms apart" was satisfied in the middle of
the work it existed to wait for. Work in flight is its own leading field now,
and while the resolver is active, a search is running or any catalog entry is
loading, a still page does not count as a settled one. An unreadable page reads
as busy for the same reason. The declared wait was always the ceiling and still
is.

That fixed four of the six. What it did not fix is the reason this is now
**off by default**: across three full walks the catalog and network cluster
fails a *different subset* each time — one run lost `net-arrived`, `np-copied`
and `np-saved`; the next got all three back and lost `net-playing`,
`cat-album-row` and the three that hang off `net-playing`. A gate that is green
at random is worse than a slow one, and every expectation in this file was
written against the declared waits. So `settle()` sleeps the wait it was given
unless **`SS_ADAPTIVE=1`** asks otherwise, and the walk prints which mode it
ran in so no log is ambiguous about it. Turning that variable on and off is
also the A/B the comparison needed and never had.

With it on the saving is real — 162 of 414 declared seconds on the mock walk,
95 of 224 on a device, where the walk went 131 of 131 with no exceptions and no
console errors. It can have the default when it is green twice running.

Three steps carry `hold: true` either way, because they wait on the mock's own
clock rather than on the app: a streamed file handed over on a seven-second
timer, and the two writes whose only evidence is the notice that follows them.
2026-09-14 10:11:53 -05:00
KeelrLeek 9f0601027c android: the handoff — the pending rounds, the pipeline, and what is unverified
Everything written this session that has not been through the device: rounds 43
to 48 (each patch adversarially verified, 43's transient-notice expectation
fixed), a partial round 49, the pipeline scripts, the Android Auto design and
the icon comparison mock. Also the two harness changes that were never A/B'd —
adaptive waits, which may only shorten a declared wait and never lengthen it,
and a ship that runs the mock walk and the deploy at the same time. The A/B and
its 255-step baseline are in handoff/tools; main stays at what the phone has
actually walked.
2026-09-14 07:57:41 -05:00
KeelrLeek 867fd42972 android: turned over, for real — the walk rotates the phone, and puts it back
Round twenty-seven wrote the landscape layout and proved it at 915×411 in
headless Chrome; no real device had ever been turned, and the phone walk
refuses a `{ view: [...] }` step because a device's orientation is not the
step list's to set. That refusal stays. The turn goes in the phone tail
instead, right after `background` has proved playback is live with the app
off screen, and it asserts the four things the mock physically cannot show:
`screen.orientation.angle` is 90 or 270 — an override without
`screenOrientation` leaves the angle at 0, so this one is only ever a real
phone — the WebView RESIZED as well (a separate clause, because a WebView
that never got the configuration change looks exactly like a display that
never rotated), `--nav-h` computed to 48px so the phone-sized landscape query
really fired, and the activity was not recreated: the same window token, the
same `performance.timeOrigin`, the same pid, no `AndroidRuntime` fatal — and
those last two are read tolerantly, because `pidof` exits 1 when the process
is GONE and `adb()` throws on a non-zero exit, so the plain read threw the
whole report away at exactly the moment it mattered. Then the stack, the
track still playing and past where it was (a track that rolled over during
the turn is still playing, which is all the clause claims), nothing clipped,
and the player sheet sideways in two columns with every control on screen.

The safe-area insets are printed, not asserted. Nobody had ever measured
them here — `MainActivity` calls androidx's `enableEdgeToEdge()` and nothing
in the generated Kotlin touches insets, so `env()` comes straight out of
Chromium's window-inset mapping — and asserting a number nobody has measured
is how a round fails on a correct build. What is asserted is the plumbing, on
both edges, because a hole-punch is only on one of them: whatever `env()`
says, the glass bar, the mini bar, the screen and the sheet spend it.

The phone is Kyle's, so the two rotation settings are read before either is
written and put back from four places — the block's own finally, main's
finally, `process.on("exit")` and SIGINT/SIGTERM/SIGBREAK — because a failed
clause only walks on but a WebView that stops answering throws clean out of
the loop, and Ctrl-C runs no exit handler at all. The latch clears itself
before it writes, a failed restore hands it back for the next caller, and the
skip paths disarm rather than restore, so a phone that could not be read, one
already sideways and `--no-rotate` all leave it untouched. Nothing else on the
device is written.

One thing beyond the turn: the `{ adb }` step kept its stdout. `am start`
prints "Error: Activity not started, unable to resolve Intent" on stdout and
exits 0, so `share-import` — the step that exists to prove the share intent
resolves — could not fail for its own reason. It can now.
2026-09-14 07:27:40 -05:00
KeelrLeek 33f32cb4c9 android: the long session — what the app is still holding after an hour
Every walk this project has ever run is minutes long, and nothing in the port
had been looked at for a leak. Driving the real bundle lap after lap says two
useful things. The first is that the obvious answer is wrong: the naive
add-minus-remove listener counter climbs about twelve a lap, and every one of
those is a `.screen` element that `{#key nav.seq}` threw away — count only
what is still attached to window, document or a connected element and it is
flat, lap for lap, and so are the intervals. The second is that exactly two
things grow without a limit. `art.svelte.js` kept every cover it had ever
fetched for the WebView's lifetime — one browse of a 114-cover library takes
it from 5 keys to 114 and gives nothing back, at roughly 63 KB of base64
each — and `pls.covers` is keyed `id:revision`, so every playlist edit
stranded a 360 px data URL under a key nothing could ever read again.

Both are bounded at the desktop's own numbers rather than at invented ones:
`ART_THUMB_CACHE_MAX` 512 and `ART_THUMB_CACHE_MAX_CHARS` 16 MiB out of
`state.svelte.js`, oldest first. Two rules the desktop does not need are
added, because its callers ask through `getArt(path)` while the phone's
templates index the map: the entry that just landed is never dropped, and
neither is one a connected `<img>` is painting — `Cover`'s `visible` action
re-arms only on a path prop change, so an evicted cover that is still mounted
would stay a disc for good. Built with the cap forced to 8 and scrolled
through eighteen screens of a 140-card Library grid, the map sat at 61 —
spared, every one of them, for being painted — and not one cover on screen
lost its image; at the shipped 512 a whole library walk runs the trim zero
times. The playlist covers need no policy at all: the new revision drops the
old ones, which are unreachable by construction.

The verification is the round. `artTally()` on the handle reports the map and
its side tables, which are module-private; seventeen probe fields carry that,
the catalog's ten session maps, the queue, the transfers and the DOM on every
step of both walks; and seven mock steps install a live listener and timer
registry in the page — by a step, not by `addScriptToEvaluateOnNewDocument`,
which the first-run block's reloads would throw away — drive two warm laps, a
baseline and nine more, assert the census, and take the registry back out so
nothing appended after this block inherits a patched platform. Installed that
late it can only see what is registered from that moment on, so its absolute
counts are small and are not asserted as a census; what is asserted is the
delta across the nine laps, and that it fires was proved by planting a
listener and an interval stranded once a lap: `grewEars` 0 → 9, `grewTicks`
0 → 9, and the step fails. As it ships, over those nine laps, the listeners
and the intervals moved by 0, the art map grew 17 against a headroom of 80
and the catalog 5 against 24, and the DOM ended smaller than it started.
`tallyTimers` is a ceiling and not a delta, and says so: every track start
schedules a 47-second recents refresh, so a walk this fast keeps a handful
pending, and making it sharp would cost a fifty-second quiet period. The
catalog maps, `tracksCache` and that recents timeout are named in ANDROID.md
as left alone on purpose — evicting a catalog entry is not safe while
`Artist.svelte` looks one up inside `untrack`. Nothing new on the phone leg:
the registry is never installed there, those three fields read −1, and
`artTally()` is a pure read.
2026-09-14 07:05:55 -05:00
KeelrLeek 9bfbcfc96c android: the phone walk waits for the app, not just for the WebView
A walk always follows a fresh install, so the app is always cold: the shell
mounts, the library loads, Soulseek logs in and the share index is built.
Attaching only proves the WebView answers, and the walk used to begin its
first step there — against a page that had not finished mounting, which
reads as a broken feature rather than a slow boot. It now waits for the
handle it drives, and the attach itself gets two more tries when the
devtools socket outlasts its own forty seconds of patience.
2026-09-14 06:43:53 -05:00
KeelrLeek 0ce8c63cb6 android: what the device has actually been tested for — the map, and the steps it could close
Most of this port is proven against an object in memory and only some of it
against the phone, and nobody had written down which was which. Writing it
down found four phone steps — `library-ambience`, `queue`, `lyrics` and
`community` — named and screenshotted with no entry in `PHONE_EXPECT` at all,
and the driver reads a missing key as a pass, so all four had been green since
the rounds that wrote them; they have assertions now, tolerant of Kyle's own
library and of nothing else.

Thirty-three more steps move onto the device: everything that can be proven
there without downloading, joining, sending, signing in or writing to his
library. The library's shape and its sort (nav state, not config), a local
track's sheet off a real path, Search's recents out of this WebView's own
storage, a queue row's sheet opened and not tapped, the walk's own playlist
with a row removed and put back by Undo — the first round trip that stash has
made through real SQLite — the import review's Keep switch restored before the
draft is created, the sleep timer set and put out by the next step, the
stream-cache chip and the Wi-Fi-only guard read first and restored last, and a
port the core has to refuse.

ANDROID.md carries the map beside Verifying: what the device walk proves (121
named steps, up from 88, plus the seven things after the loop that no mock can
do), what stays mock-only and why — needs `__ssMock`, needs a fresh install,
needs a viewport the phone walk refuses to set, needs the dev flavour, or
would download, join, or destroy his data — which six of the new steps are
conditional on what his own library happens to hold, and what still needs a
person. No product code in this round: four inserts in the phone half of
smoke.mjs, one in the doc.
2026-09-14 05:20:02 -05:00
KeelrLeek a8adce087f android: the phone walk waits for the WebView instead of giving up on it
A launch leaves a window where the app is up, focused and has a pid while
the devtools socket the walk attaches to does not exist yet — on a loaded
machine it has been as long as ten seconds. attachPhone tried once and
reported "is SoulStream on screen?" about an app that plainly was. It now
tries for fifteen seconds, re-reading the pid each time in case the app
restarted underneath it.
2026-09-14 04:55:17 -05:00
KeelrLeek 537bfdd5af android: the faces the walk had never seen — six failure states, read back in their own words
Round thirty-one gave the phone honest error states and shipped six of them
unobserved, because neither `loadHome` nor `loadLyrics` was reachable from the
`__ss` handle and both answer a second ask with nothing. The handle carries the
two stores and their two loaders now, and a block of mock steps arms each
failure with the mock's own weather, drives the face, and asserts the sentence
a person reads as well as the absence of the Rust string behind it: Home's
catalog line, the Gaps line and its second tap, a catalog record's stub, the
artist page's two different answers, a playlist that would not read, the lyrics
lookup that failed, a refused transfer, and the library toast's Try again,
pressed for the first time.

It sits before the first-run block, which reloads and empties the mock's
follows; every failure is disarmed by the step that proves the face heals, and
a last step asserts nothing was left armed. The phone walk gets a safe step of
its own — a terminal transfer row written into the store and taken away,
nothing fetched and nothing reaching the shade. ANDROID.md now names what the
walk still cannot see: three screens that draw a raw backend string, six error
faces with no expectation, and the history mirror printed on every step and
asserted on none.
2026-09-14 03:59:09 -05:00
KeelrLeek f2cde4ec7c android: the whole discography, on a page of its own
The artist page already filed every release the desktop's way, but it only
ever showed one category at a time and it showed all of it: no slice anywhere
in that grid, so an artist with 140 releases drew 140 cards. There is a page
now — All in front of the categories, a sort, a grouping by year, and the
editions opened in place under their own record, which is the part a flat
list loses — and the grid behind it stops at twelve with the way down under
it. It is the same classifier twice over the same cached albums, which the
desktop cannot do because its filing waits on a background description
harvest; the three things its own page has and this one does not — a
tracklist per record, an accent sampled per cover, the scroll-led banner —
are 140 calls and 140 decodes over mobile data for rows that are one tap
away, and ANDROID.md says so rather than leaving them looking forgotten.

The list caps at sixty with the rest a button away, before the grouping, so
the last year on screen can be a part of itself; under a name order By year
is not offered, because the years would repeat out of sequence. The mock
gains a 140-record artist so both caps are walked rather than argued about.
2026-09-14 03:41:16 -05:00
KeelrLeek 1258b2b78d android: the rows go where you put them — a real drag on the playlist and the queue
The phone stood in for the desktop's drag with Move up / Move down / Move to
the top on a row's sheet, which is fine but is not what a finger is for. Press
and hold a row for 320ms now and it lifts, the rows below make room, and
letting go lands it — on the playlist page and on the player sheet's queue,
through one action in src/mobile/reorder.js. The Move rows stay: they are the
accessible path round 28 named to a screen reader, and they are the only way
to reorder while the filter is on. Release a press without having moved it and
the row's sheet opens exactly as a long press always opened it, which is what
keeps the queue's sheet reachable at all — a queue row has no ⋯, so
`oncontextmenu` is its only door to Play now, Move to next and Remove from
queue.

Most of the work is in not fighting the page. Nothing carries
`touch-action: none`, because a flick that starts on a row is how the list is
scrolled and `touch-action` is latched at touchstart anyway; the scroll is
refused instead by a non-passive `touchmove` listener added at the lift, which
works precisely because the finger has been still for 320ms and the browser
has not started one. A press that moves more than 8px before then is a scroll
and the gesture lets go of it. Click and contextmenu guards sit on the
container in the capture phase while a lift is live, or a released drag would
fire the row's own click and play the track. Rows are measured once at the
lift in the scroller's content space, so the edge pull — within 64px of either
edge — leaves the snapshot true, and each displaced row moves by the exact
difference between its top and its neighbour's rather than by one assumed row
height, because the queue face puts album separators between rows.

No new mutation and no new command: `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` carrying the whole id order, and the existing
queue guards were already the right guards. The drop is optimistic and snaps
back with the error toast if the backend says no. Two refusals cover the
middle of a gesture — the playlist drops nothing if a refetch has replaced its
rows, and the queue drops nothing if `player.qi` has moved — and dragging is
off entirely while the playlist's filter has anything in it, because a
filtered list's visible order is not the stored order. Both walks drive it
with real pointer events and assert the order out of the store: a drag, a drag
back, a refusal under the filter, a press that still opens the sheet, and a
drop the backend is armed to reject.
2026-09-14 03:24:26 -05:00
KeelrLeek defd8c5e6d android: a playlist file, picked
The storage plugin grew `pick_file` beside `pick_folder` and `pick_image`:
the document sheet with the caller's own filter, answered with a path Rust
can open. A document already on this phone hands its path over as it lies —
the document sibling of the same volume:relative id the folder picker
resolves, plus the Downloads provider's raw: — so a 300 MB pick copies
nothing; one that has no path is copied into cacheDir/picked under its own
name, and only as far as the caller allows, because a playlist is kilobytes
and a DJ set is not. Those copies are swept at the start of every pick,
which pick_image's never were.

The import sheet has its fifth door, on round 24's screen, wait card and
review, and the note that apologised for its absence says the one true thing
about it instead. `playlist_import_probe` took a path and a name the way
`playlist_import_folder` took a path, and the name is the load-bearing half:
the parser picks its format off the extension and titles the draft from the
stem, so a cache copy under an invented name would have read every playlist
as plain text. The mock walk drives the door and both of the picker's
refusals through round 31's fail door; the phone walk opens the real sheet
and dismisses it with BACK, reading nothing and writing nothing.

The brief asked for a second consumer — sending a file in a chat — and there
is nothing behind it: `community_pick_file` is the persona layer's three
native dialogs, `soulseek-client` has `say_room` and `message_user` and
nothing that sends a file, and both frontends' thread composers are text
only. Nothing was invented for it. The plugin command is general enough — a
caller's own filter, a zero-copy path, a bounded copy — that the one reader
still waiting, the local set listener the phone puts off by design, needs no
further plugin change. Playlist export is not on that list and never was:
writing a document out is the share plugin's errand, not a picker's. The
Kotlin is unbuilt — nothing on this machine compiles it.
2026-09-14 02:55:56 -05:00
KeelrLeek 04c87e0db1 android: a playlist, out of the phone
The desktop has had Export since playlists v2 and the phone had no door onto
it for one honest reason: `tauri-plugin-dialog` does open Android's
create-document sheet, but it answers with a `content://` URI and
`playlist_export` wrote with `std::fs::write`, which cannot take one. The
storage plugin grew a `create_document` whose Kotlin writes the bytes through
the ContentResolver — a plugin function called from Rust, not a command, so
the file never passes through the WebView and nothing was added to the ACL —
and `playlist_export` now builds the file above the platform fork with the
same `playlist_formats` writers, so the phone rebuilds no format and the two
exports cannot drift. The door is the playlist's own sheet, on the page and
on the shelf card, with the desktop's five profiles in the desktop's order; a
dismissed sheet says nothing, because nothing happened. The mock walk drives
it three ways — a file written where the sheet pointed, a sheet dismissed, a
refusal read back as a sentence — and the phone walk opens the real sheet and
dismisses it with BACK, writing nothing to the phone. The Kotlin is unbuilt:
nothing in this tree compiles it, though it is written against
tauri-android's own `PluginHandle`, `Invoke` and `JSObject` rather than
against a guess at them.
2026-09-14 02:38:44 -05:00
KeelrLeek 95c2bfeeba android: the first run — a folder to keep music in, and an account to reach the network
Nobody had seen this app on a phone that has just installed it. On Android
the library root is never missing — config.rs falls back to the app's own
sandbox, a real folder nothing else can see — so "no folder yet" cannot be
a null check, and setup_done has never been written by the phone, so every
phone in existence reads as unstamped, Kyle's included. The tour therefore
opens on three clauses, not one: no flag, no saved account, and no records,
read only after the library and the credentials have both answered. Two
cards, skippable, and no password anywhere in it — the Soulseek step goes
to the card on Community and stops.

Every screen that is empty because of those two things now says so with the
row that fixes it: Home (whose copy predated the folder picker), Library →
Albums, Library → Artists, which had no empty state at all, and Search,
where a flat sentence about signing in became the row that does it. A row
goes away because the config moved, so the two doors onto the same folder
tell each other — You re-reads when the tour moves it, the tour's store is
told when You's own picker does — and the all-files row re-reads the grant
when the system screen hands the app back, the way You already did.

You → This phone opens the tour again as a preview that reads the config
and writes nothing, which is the door the phone walk drives on Kyle's own
device. The mock can answer as a fresh install now — an empty database, no
account, no permission, the sandbox root — so the whole thing is walked end
to end, and two commands that had no mock at all (player_control,
open_external) got one on the way past. It is the mock walk's first reload,
which turned up two things a reload does not clear by itself: the player
writes the session back on pagehide, so the fresh block clears it from a
pagehide listener of its own, and the viewport emulation lives on the
DevTools session, so the harness hands it back after the re-attach.
2026-09-14 02:21:37 -05:00
KeelrLeek 46cf6a98fe android: the sweep — what thirty rounds left behind, and the byte that hid a file from grep
Nothing here changes what the phone does, and the walks are untouched on
purpose: they are the guard, not the subject. Out go nine imports no file
ever names again, two things nothing in src, tests, crates or src-tauri
has ever referenced — `forgetAccount` in the Soulseek store, whose Sign
out row calls `disconnect`, and `isLossless` in util.js — and two CSS
rules no markup can reach, `.cardpad` and `.iconbtn.liked`; `stamp` and
`fmtCount` had each been written twice word for word, so both bodies move
into util.js unchanged and four screens gain a name on an import line they
already had. The byte is in player.svelte.js, where the play-history key
joins artist and title on a NUL that was written into the source as the
character itself rather than as the escape catalog.svelte.js already uses
— that made the file non-text, so every grep of it since has answered
"Binary file matches" and shown nothing. One line is added rather than
removed: `.mini .mini-lane { width: auto }` reads exactly like the strays
and is the only thing keeping the lane from coming out 32px wider than the
strip it was cut for, so it now says so — and `.feat`, declared twice in
shell.css with different values, is left alone, because untangling that
moves pixels and this round may not. A tenth dead import is missing from
that first list on purpose: Sheet.svelte's `import * as api` is a namespace
import, the one shape Rollup cannot shake, so its reason was the bundle
rather than drift and it went out with the round before.
2026-09-14 01:55:00 -05:00
KeelrLeek 01bab007f7 android: the boot chain goes out in one round, and a census that holds it there
Thirty rounds of onMount had never been read end to end, and read end to end
it was a queue: two dozen commands with a critical path ten deep, because
Tauri's `listen` is an invoke like any other. loadLibrary asked for five
independent SQLite reads one behind the other, bindSlsk registered eight
listeners one behind the other, and watchLibrary and the player's bindEvents
did two each. None of them depended on anything before them. They go out in
one round now — the same commands, critical path 10 to 3, and ten of them
used to leave in the shell's own first turn where twenty-three do now — and
in a rig that runs the real store modules with the bridge instrumented,
lib.loaded lands at 53 ms instead of 85 at a 6 ms round trip and 62 instead
of 148 at 20 ms. Those are a desktop rig's milliseconds with the round trip
swept rather than measured; the phone's own numbers are unproven until the
walk prints bootMs and bootLibMs.

The behaviour is meant to be identical. loadLibrary's only movable part was
lib.error, which has two writers and used to be decided by the order they were
awaited in; the two messages are now carried out of the round and applied in
that same order, so a failing library still shows the library's message. Every
combination of which of the five reads fails, over six latency shapes, comes
out identical to the old code. bindSlsk still does not return until every
handler is up, so connectStored cannot let a fast connected state land with
nobody listening, and bindEvents still hands back the transport's unlisten and
still rejects only when the transport is the one that failed.

src/lib/boot.js is the project's first launch instrument — 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. perf.mjs measures frames; nothing measured the launch. The mock walk's
Home step now holds a budget against it and both walks print bootCalls,
bootSync, bootMs and bootLibMs on every step. bootSync is the clause with no
clock in it: a call that waits on the one before it can only issue its first
in the shell's own turn, so a chain put back in a queue falls under the floor
whatever the machine is doing. A bootCalls of -2 means __ss.boot is not there
at all, which is the census itself being broken rather than the app.

One import line goes with it: Sheet.svelte's `import * as api`, the only one
of the ten dead imports this round turned up whose reason is the bundle at
all. Rollup already drops an unreferenced NAMED import, so a dead name on an
import line is hygiene rather than bytes; a namespace import is the one shape
tree-shaking cannot see through, and this one was dead. It frees nothing on
its own — twenty-nine other files in the phone's graph hold the same module
the same way, so api.js is pinned into the chunk whatever Sheet.svelte does —
but it is the only one of the ten that belongs to a round about weight. The
nine that are drift go out with the sweep in the round after this one.

Two things were looked at and left alone, both written down in ANDROID.md
rather than half-done. Home's per-artist catalog walk is the deepest chain in
the app, and that serial walk is the Deezer and MusicBrainz pacing, not an
oversight. And the follows really are read twice at launch, but the obvious
guard does not work: Home's effect calls loadHome with force the first time
they land, so the guard would never fire. One small local read, left for a
round that can do it properly.
2026-09-14 01:38:15 -05:00
KeelrLeek 052e15302b android: what the phone says when things go wrong
Fifty-nine places put a raw Rust string into a field a person reads, and
the red toast drew it verbatim — a phone in a lift showed "could not reach
server: io: failed to lookup address information" under the password box.
One translator, src/mobile/trouble.js, called where an error is DRAWN
rather than where it is caught: the stores keep what Rust said and only
the sentence on screen is ours, so every sink is fixed at once. Five
screens that drew a failed read as an empty answer stop lying — Tracks no
longer reports a broken database as an empty library, a playlist that
could not be read is not an empty playlist, the lyrics face says a lookup
failed instead of "no lyrics" and finally carries the ⋯ that reaches Paste
lyrics, Gaps says why it could not reach the catalogue, and Search signed
out says so where it used to render nothing at all. The room list, which
is fire-and-forget over the wire, gets a twelve-second end and a Try
again.

rescan_library ran prune_missing() first, which deletes every track row
whose file does not exist — so one tap on You → Rescan library with the
folder unreachable emptied the whole index and returned Ok(()). It now
makes the root if it is merely not there yet and refuses if it cannot be
read, and You draws the path with what to do about it.

The mock grew a weather system so the walk can drive all of this: fail,
answer, offline and clear, checked before dispatch so a plugin command
arms like any other. Nine mock steps and one on the phone — the first
steps in thirty rounds that ever made a command reject or saw the client
signed out — and the last of them puts the weather away and leaves the app
as it found it.
2026-09-14 01:23:26 -05:00
KeelrLeek 44eda8dff5 android: the room can measure the song, and a cold artist page stops waiting on one call
The tap-to-sync room draws the recording's own vocal band
(lyrics_vocal_activity, the desktop's coarse envelope) as a strip under the
head, and one button lays every line across the stretches it says are sung —
the desktop's own generator, shared and unit-tested, which refuses rather
than guesses when there is too little voice to read. The result is labelled
an estimate everywhere it shows, the cursor goes back to the top so the taps
correct it, and one more tap drops the whole thing back to an empty set,
because a rough fill stamps every line at once and leaves Undo nothing to
take back. The artist page now fires the hero seed and the overview beside
the full page, the way the desktop stages them: a hero Kyle picked on this
phone comes back off the file with no network at all, the discography paints
from the overview's first page until the exhaustive pass lands with the
classified one, and both lanes are dropped with the page when a hero
changes. ANDROID.md's "Not done yet" is rewritten as the true map of the
port — what is absent by design and why, what is waiting on Rust rather than
on a file picker, and one bullet about the artwork viewer that had been done
for rounds.
2026-09-14 00:58:14 -05:00
KeelrLeek 5738c2d7bf android: the downloads say so in the shade
A Save still running with the app in the background now posts a progress
notice — how many files, the one it is on, a bar, Stop all and Open — and
takes it down when the queue empties. It sits on its own low-importance
channel so it can be silenced without silencing the player's card, and
rides its own dataSync foreground service rather than the media one: a
mediaPlayback service stops itself when the bar empties, a download is not
playback, and nothing else was keeping the process alive for a Save with
nothing playing — which is how the first real Save died, four tracks in.
Stop all goes through the same cancel(id) a row's tap takes, and Transfers
grew the matching button beside Clear finished. The walk pushes two rows
straight into the transfer store and reads the real notification back out
of `dumpsys notification`, without fetching anything.
2026-09-14 00:44:11 -05:00
KeelrLeek 4fc6e9c630 android: what a screen reader says — names, roles, and the sheets out of the tree when they are down
Every control on the phone has a name now, in the app's own words, and the
desktop's word where it already has one; the nav is a tab set whose badge
count is part of the tab's name — "You, 3 transfers" rather than a stray 3
read out before it. The two sheets are dialogs with somewhere sensible for
the focus to land, and the half sheet's grab handle is finally a real
Close — the first dismissal a screen reader could reach, because the scrim
never was one. A sheet that is down is inert rather than sitting
off-screen in the accessibility tree with the hidden mini bar and the nav
under it, and one live region is mounted for the life of the app so that
an undo offer is actually announced; switches say on or off, chips say
pressed, and the waveform is a slider where it can seek. Nothing visible
changed. The walk counts the controls with no name, with a junk name, in a
hidden layer, or with unsaid state — all four held at zero on ten screens,
and the nameless count on a room and on both prompts besides, which is
where the six fields named here actually live.
2026-09-14 00:28:28 -05:00
KeelrLeek 9df162360e android: the phone turned sideways — the player beside itself, the chrome cut down
The phone UI was drawn portrait-first and had never been looked at in
landscape. The player sheet was the casualty: its cover is a square sized by
width, so at 915×411 it came out 879px tall and the title, the scrubber, the
transport and the face buttons all sat below the bottom of a sheet that does
not scroll — a corner of the artwork and no way to pause. Sideways the sheet
is two columns now, the cover sized by height beside the controls, with a
lyrics or queue face taking the cover's column; the nav and the mini bar come
down to 48 and 60dp, the artist hero becomes the banner the desktop's hero has
always been rather than 300px of a 411px screen, the record head puts the
cover beside the title, and the shell reads the side insets the cutout and the
gesture pill take when the phone is on its side. The walk turns the phone over
and back on its own: `{ view: [w, h] }` is a step kind now, and the steps
assert what used to be off the screen.
2026-09-14 00:14:23 -05:00
KeelrLeek aff29aba5c android: a streamed file copied into a folder, the listening port, the dev build's diagnostics
The now-playing sheet's streamed track can be copied into a folder picked
on the phone, as the file it is — the cache written out, nothing downloaded
again, no library row (copy_stream_to_folder, which the desktop has had a
command for and no UI). You → Sharing carries the listening port, what
peers dial when they download from you: unset is the client's own 50300,
the app asks the router to forward it over UPnP and falls back to the
server relaying, and a change applies on the next connect. The dev flavour
gets the desktop's debug overlay as a screen under This phone — CPU,
memory, the network, the stream and its loudness, what the pre-buffer is
fetching, the size of the play history — behind devBuild in util.js, which
is Vite's own DEV flag, so the embedded build the phone keeps has no row
to tap.
2026-09-13 23:59:47 -05:00
KeelrLeek 1737c5e1b6 android: the catalog's numbers — Popular's own lane, a count per row, the artist's photo
Popular is asked on its own as well as with the page, so the section can
paint before the full page is built and an artist whose page came back
with none still gets one. 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 deferred Last.fm pass by their own artist id. The
artist page's ⋯ is its own sheet now: choose a hero photo from this phone,
or put the catalog's own back; set_artist_hero is pick_artist_hero's
install step split out and shared, so the desktop's dialog keeps its door.
You gains the desktop's Refresh artist portraits, and a data: URL is the
image itself in the art gate — a chosen hero comes back from Rust as one,
and the thumbnail fetch used to throw it away.
2026-09-13 23:46:07 -05:00
KeelrLeek c737809325 android: a folder of audio files, and a paste of track links
The paste door now reads itself the way the desktop's does: one track link
in the paste and every line is looked up on its own public page
(playlist_import_track_links), which is the way past Spotify's 100-track
public ceiling — the wait screen counts them as they arrive. A fourth door
imports a folder of audio files off this phone, picked with the storage
plugin's own document-tree picker; playlist_import_folder grew a path
argument for it, because on Android it had no dialog to open and always
answered nothing. A YouTube playlist link needed no door at all — the link
one already dispatches it.
2026-09-13 23:32:00 -05:00
KeelrLeek 24c6857847 android: tap-to-sync, the per-track auto-sync switch, the vocal-reduced copy
The editor the desktop drives with a mouse is better under a thumb: 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. One layer the back gesture peels, and a set left mid-tap is
offered back by the row that opened it; Save writes the track's own
document, lines never tapped left out of it and the button saying how
many are kept. The lyrics sheet also gains the per-track auto-sync switch
and the desktop's reduced-vocals copy — the same title, artist and
duration, so the words keep following it, and one row puts the original
back.
2026-09-13 23:04:49 -05:00
KeelrLeek e693dfb868 android: your own Soulseek interests, and who the server says is like you
Community's fourth tab is Taste: the likes and hates this client
publishes to the Soulseek server (slsk_interests_mine, slsk_interest_set),
added and taken back as chips, and the people the server ranks as close to
them (slsk_similar_users) — a row each into the peer sheet the app already
has. Every declaration asks the server again and the chips stay quiet
until it has answered, the way the desktop does, because the ranking only
moves when its input does. The lists live in taste.svelte.js, read once
per account when the tab is opened; the mock walk declares a like, watches
the match grow, declares a hate and takes the like back.
2026-09-13 22:52:03 -05:00
KeelrLeek 7eae0ee945 android: a name of your own for anyone on the network
The desktop's local contact metadata on the phone: a peer's sheet ends in
Name and notes — a petname, a group, a private note and the Favorite and
Trusted marks, kept in the app's own config and published nowhere. 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, and clearing a person's
details drops the entry rather than storing an empty one — the backend's
own rule.
2026-09-13 22:40:17 -05:00
KeelrLeek f792d7955d android: a peer in full — their card, their interests, and what they have that you don't
The peer sheet's new "About this user" row opens a screen of its own: the
card their client serves (their own words, their picture, the upload slot
and the queue), the likes and hates they declared, and the comparison this
phone makes of their share against the library. The comparison is the
point of opening a stranger — every record they have by an artist in your
library that you don't, each row opening the search that finds it, and a
way to forget the whole comparison again. Nothing is uploaded to work it
out. Their picture opens in the artwork viewer, which now takes a data:
key beside a library path and an https URL.
2026-09-13 22:28:38 -05:00
KeelrLeek 694a29eb0d android: downloads on Wi-Fi only
One switch the desktop never needed, off by default: on mobile data every
Save the phone can start — a record, a track, a folder, a file, a
playlist's bulk, the now-playing Save — waits, with the reason on the bar;
streaming is never held back. The walk flips the switch, makes the
connection cellular and sees a download refused.
2026-09-13 22:15:07 -05:00
KeelrLeek 3c8989dfa6 android: the hub's filters on the network results, pasted lyrics
The raw results take the Soulseek hub's filters — a free slot only, FLAC
or MP3 320+ — as chips above the folders. The lyrics sheet has the
desktop's paste door: plain text, or LRC with timestamps, kept for the
track.
2026-09-13 22:03:57 -05:00
KeelrLeek 59be9f6c3e android: the nav's badges — transfers in flight on You, unread chats on Community
The desktop's title-bar download ring and its unread indicator, on the
phone's nav: a count on You while Save is fetching, a count on Community
when a chat has unread lines.
2026-09-13 21:52:53 -05:00
KeelrLeek f8c3b90665 android: hours on the library's count line, By format, Jump back in on the empty search
The desktop Library summary's hours on the phone's count line; a By format
order from the file stats (lossless by depth and rate first, then by
bitrate, the badge as the card's line); the empty Search page's Jump back
in — the last records played — as the desktop's empty search shows them.
2026-09-13 21:31:16 -05:00
KeelrLeek b50577fc81 android: a playlist's cover from a photo on the phone
The storage plugin gains pick_image — the document picker limited to
images, the picked content copied into the app's cache so the Rust side
gets a plain path. playlist_cover_set(id, path) is the desktop pick's
install step split out and shared (squared, the master cover, the row's
revision moved). A playlist's sheet offers Choose cover photo and Remove
custom cover; the mock walk exercises both.
2026-09-13 21:20:11 -05:00
KeelrLeek d95c3f48d1 android: the room list filtered by name, the rest on demand
Soulseek lists hundreds of rooms; the phone showed the first 80 and no way
to the rest. Community → Rooms now has a filter by name and extends the
list 200 at a time.
2026-09-13 21:07:11 -05:00
KeelrLeek 60303c8a95 android: reorder from the sheet — a playlist row up, down or to the top; a queue row up or down
Without a drag: a playlist row's sheet moves it up, down or to the top and
persists the whole order in one call (playlist_set_order, as the desktop's
drop does); a queue row's sheet moves it up or down, never past the track
playing. Also src-tauri/android/ship.sh — one round end to end: the mock
walk, the embedded build installed, the phone walk, the commit, each step
gated on the one before.
2026-09-13 20:55:50 -05:00
KeelrLeek 991d52d0cc android: the library's file stats on the cards, the artist's own plays, the thumbnail prewarm
The desktop Library's per-record file stats on the phone (library_album_stats,
one call for the whole library): FLAC 24/96 or MP3 320k badges on the cards,
the size on the list rows and the record page. The artist page's own
listening (artist_play_stats): the time played as a chip, the most played
track under "On this phone". The whole-library thumbnail warm a moment after
boot, at the cards' edge, one decode at a time behind the tiles' gate.
2026-09-13 20:37:51 -05:00
KeelrLeek 26928b706d android: the repair sheet, browse, every long-press sheet, You's last sections, the lyrics tools — and the walk's audio check made honest
The playlist repair (Find a local match: the library searched by the
row's name, a pick relinks it through playlist_relink_item; an unknown
row takes the track's identity). A peer's sheet on the raw results (play
or save the folder, browse their files, message, buddy) and the Browse
screen — the hub's Browse tab: a user's whole share with sizes and totals,
a folder played or saved whole; a share can hold thousands of folders, so
it filters and renders 80 at a time. "Look up a user" on Community →
Buddies. The sheets the phone lacked: a queue row's (Play now, Move to
next, Remove from queue), a catalog song's, a catalog card's and the
record page's own, a raw file's (in the results and in Browse), a room
name's, a buddy's, the now-playing Save (the desktop's Save menu for a
streamed track or record). The artwork viewer with pinch zoom, one layer
the back gesture peels. Search's recent searches and its spelling
suggestion from the names this phone knows. You → Streaming, Downloads,
Sharing, Lyrics, Album artwork, and Clear play history; a record's
Re-fetch the cover. The lyrics tools on the sheet's face: the offset
nudged and kept per track, another LRCLIB candidate chosen, the lookup
forgotten and done again (lyrics.svelte.js, shared by the face and the
lyrics sheet).

The harness: every CDP call times out and the reload step re-attaches
(a WebView that stopped answering hung a walk for ten minutes); the
audio check counts SoulStream's own tracks' underruns from the mixer dump
instead of the thread-wide counters that any app's notification moves —
the afternoon's "dropouts" were other apps' sounds, and the browse of a
49,544-file share measured at zero; __settle resets every tab to its root
through the nav; an adb step kind; the walks cover all of the above on
the mock and on the phone.
2026-09-13 19:56:20 -05:00
KeelrLeek 6e39dc43f3 android: playlists, the import doors, the share sheet, You → Sources — and three bugs the walks found
The desktop's two playlist pages on the phone: the shelf (pinned first,
mosaic covers, badges, New playlist, Import) and the page (kind, Pinned
chip, description, the meta line's warnings, Play / Shuffle / more, a
filter, rows tagged unresolved / set audio / file missing). Sheets for a
card, the page's overflow, a row, pick-a-playlist, a prompt and a confirm;
Add to playlist on every track and record sheet; a removal undoable for
seven seconds from the neutral toast (notice.svelte.js); stream rows play
through the resolver as path-less entries, a set slice starts and ends at
its cues; Add to library / Download all tracks with the desktop's bulk
rules; nav.replace for a review that became the thing it made.

The import doors that need no file — a link (YouTube · Spotify · Apple
Music · TIDAL · Deezer), a pasted tracklist, a YouTube DJ set — with the
review screen and the desktop's catalog verification pass. A share plugin
crate (crates/tauri-plugin-share): Share → SoulStream from any app keeps
the text in a ShareActivity and the WebView hands it to the import when it
comes back to the front. You → Sources: the source-quality window and the
priority per mode.

Found by the walks and fixed: set_settings takes camelCase arguments, so
the phone's normalization and library-folder saves never landed;
Cover.svelte's non-eager wrapper has no layout box, so its intersection
observer never fired and row thumbnails never loaded (the Artists tab too);
a history push while a history.go was still in flight left the nav mirror
one entry ahead and the page blank after a confirmed delete — the mirror
now applies once per microtask and never pushes during a pending traversal.

The harness names unmocked commands, carries console warnings' frames (the
smoke bundle is built unminified), probes the history mirror on every step,
mocks app_status / play_local / waveform / lyrics_get / player_state and
applies settings patches, runs adb steps on the phone (the share intent),
and walks playlists, the imports, the share and the settings on both the
mock and the phone. ANDROID.md documents all of it.
2026-09-13 17:56:52 -05:00
KeelrLeek bb828ff2dc android: the phone, on the real core — the mockup shell, playback, the media card, the catalog, the resolver, Save, the dev flavour
The same Tauri 2 project builds for Android: mobile.html as a third Vite
entry (src/mobile/, the mockup on real data), tauri.android.conf.json, the
mobile stubs (updater, lyric alignment, vocal separation), rustls reqwest
on Android, the AAudio buffer, and a hand-driven detour build
(src-tauri/android/{env,build,deploy,dev}.sh) while Developer Mode is off.
ANDROID.md is the recipe.

Playback: the media-session plugin crate (lock-screen card, MediaStyle
notification, audio focus, headset buttons, a foreground service that
promotes itself first — Android 14 kills the app otherwise), session
restore across reload and restart, solid chrome after measuring the
backdrop-filter lag.

The catalog: Search is the desktop's top bar (the top-result rule, artist
cards), the artist page and the record page are the desktop's, Home is the
desktop's landing page, Gaps per followed shelf. Playing goes through the
desktop's own source rules, moved verbatim from state.svelte.js into
src/lib/sources.js so both apps share one resolver; the phone readies the
next three tracks and crosses boundaries gapless. Save fetches a record or
a track into the library by the download rules; Transfers shows the
burst. The raw Soulseek search stays, offered on request, a peer's copy
streaming with the lane rising as the bytes arrive. Community is the
Soulseek side (rooms, chat, buddies); the private layer waits.

The opener and storage plugins (social links, the picked library folder,
all-files access), the volume normalization switch, and the harness:
tests/mobile/smoke.mjs walks the mock (a fake network behind
src/lib/events.js) and the real phone over CDP, perf.mjs measures frames.
2026-09-13 09:30:06 -05:00
KeelrLeek 972dd103cb release: 1.8.2-alpha
release / windows (push) Canceled after 0s
2026-09-10 07:19:18 -05:00
KeelrLeek 12fff9f6ad setup, updater: the installer window was a dev build with nothing inside it, and latest never matched a prerelease
Tauri decides dev-vs-production from the `custom-protocol` feature, not from
--release, and both workflows build the setup exe with a plain `cargo build`.
So cfg(dev) stayed on, the frontend was never embedded, and the window a
tester opened loaded devUrl: "localhost refused to connect" on a blank
800x580. The feature is a default of the crate now, with a build.rs guard
that fails a release build without it, and build.rs tracks setup/dist so a
changed window cannot be left out of the binary it is supposed to be in.

Behind that, the next wall: /releases/latest answers only with full
releases, and every release here is an alpha, so it 404s. The setup app read
that as a refused key and the app's Stable channel read it as a token that
cannot see the repository — both have been broken since the first alpha.
Each now lists /releases and takes the newest that is neither a draft nor
the rolling nightly, which the list really does put first.

And check_saved_key threw the saved key away on any failure at all, a
dropped connection included. The entry it deletes is the app's own updater
token, so only a refusal clears it now.

The release notes name the file to hand a tester: six assets on the page and
nothing saying which, while the bare NSIS installer is the biggest of them
and installs with no key gate and no signature check.
2026-09-10 07:19:15 -05:00
638 changed files with 63571 additions and 1149 deletions

No files matched your search

+20 -1
View File
@@ -122,6 +122,9 @@ jobs:
cp target/release/SoulStream-Setup.exe "SoulStream-Setup-$VERSION.exe"
ls -la "SoulStream-Setup-$VERSION.exe"
# Which of the files on the page a tester runs, said on the page: the
# bare NSIS installer reads like the download and installs with no key
# gate and no signature check.
- name: Publish SoulStream Setup
shell: bash
env:
@@ -129,4 +132,20 @@ jobs:
TARGET: ${{ vars.RELEASES_OWNER || github.repository_owner }}/${{ vars.RELEASES_REPO || github.event.repository.name }}
TAG: nightly
VERSION: ${{ steps.stamp.outputs.version }}
run: gh release upload "$TAG" "SoulStream-Setup-$VERSION.exe" --repo "$TARGET" --clobber
SHA: ${{ github.sha }}
run: |
gh release upload "$TAG" "SoulStream-Setup-$VERSION.exe" --repo "$TARGET" --clobber
{
echo "### Download \`SoulStream-Setup-$VERSION.exe\`"
echo
echo "It asks for your alpha key, fetches this build with it, checks the signature and installs — no admin prompt."
echo
echo "The other files are the ones Setup uses:"
echo
echo "- \`SoulStream_${VERSION}_x64-setup.exe\` — the bare installer Setup downloads. Running it yourself skips the key and the signature check."
echo "- \`SoulStream_${VERSION}_x64-setup.exe.sig\` — its signature."
echo "- \`latest.json\` — the manifest the app's Nightly channel reads."
echo
echo "Built from main at $SHA. Nightlies can break."
} > notes.md
gh release edit "$TAG" --repo "$TARGET" --notes-file notes.md
+20 -1
View File
@@ -105,6 +105,10 @@ jobs:
cp target/release/SoulStream-Setup.exe "SoulStream-Setup-$VERSION.exe"
ls -la "SoulStream-Setup-$VERSION.exe"
# Six files on the page and nothing saying which one a tester runs: the
# NSIS installer is the biggest and reads like the download, and running
# it puts the app on with no key gate and no signature check. The notes
# go on last, once the file they point at is actually up.
- name: Publish SoulStream Setup
shell: bash
env:
@@ -112,4 +116,19 @@ jobs:
TARGET: ${{ vars.RELEASES_OWNER || github.repository_owner }}/${{ vars.RELEASES_REPO || github.event.repository.name }}
TAG: v${{ steps.version.outputs.version }}
VERSION: ${{ steps.version.outputs.version }}
run: gh release upload "$TAG" "SoulStream-Setup-$VERSION.exe" --repo "$TARGET" --clobber
run: |
gh release upload "$TAG" "SoulStream-Setup-$VERSION.exe" --repo "$TARGET" --clobber
{
echo "### Download \`SoulStream-Setup-$VERSION.exe\`"
echo
echo "It asks for your alpha key, fetches this release with it, checks the signature and installs — no admin prompt."
echo
echo "The other files are the ones Setup uses:"
echo
echo "- \`SoulStream_${VERSION}_x64-setup.exe\` — the bare installer Setup downloads. Running it yourself skips the key and the signature check."
echo "- \`SoulStream_${VERSION}_x64-setup.exe.sig\` — its signature."
echo "- \`latest.json\` — the manifest the in-app updater reads."
echo
echo "See the commits since the last release."
} > notes.md
gh release edit "$TAG" --repo "$TARGET" --notes-file notes.md
+4
View File
@@ -23,3 +23,7 @@ branding/
# the setup app frontend build (vite.setup.config.js)
setup/dist/
setup/src-tauri/gen/
# the phone UI smoke test (tests/mobile/smoke.mjs) builds and captures here
dist-smoke/
tests/mobile/out/
+2641
View File
File diff suppressed because it is too large. Load diff
Generated
+247 -12
View File
@@ -160,7 +160,7 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "app"
version = "1.8.1-alpha"
version = "1.8.2-alpha"
dependencies = [
"audio-formats",
"base64 0.22.1",
@@ -170,16 +170,19 @@ dependencies = [
"flate2",
"freenet-adapter",
"image",
"jni 0.21.1",
"keyring",
"library",
"log",
"metadata",
"ndk-context",
"persona-state",
"player",
"quick-xml 0.37.5",
"rand 0.8.8",
"realfft",
"reqwest 0.12.28",
"rusqlite",
"semver",
"serde",
"serde_json",
@@ -191,6 +194,11 @@ dependencies = [
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-media-session",
"tauri-plugin-opener",
"tauri-plugin-share",
"tauri-plugin-storage",
"tauri-plugin-transfer-notice",
"tauri-plugin-updater",
"tempfile",
"tokio",
@@ -429,7 +437,7 @@ dependencies = [
[[package]]
name = "audio-formats"
version = "1.8.1-alpha"
version = "1.8.2-alpha"
[[package]]
name = "autocfg"
@@ -721,6 +729,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.9.1"
@@ -732,6 +746,17 @@ dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
@@ -739,7 +764,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"chacha20 0.9.1",
"cipher",
"poly1305",
"zeroize",
@@ -1722,7 +1747,7 @@ dependencies = [
[[package]]
name = "freenet-adapter"
version = "1.8.1-alpha"
version = "1.8.2-alpha"
dependencies = [
"bs58",
"freenet-stdlib",
@@ -1991,8 +2016,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -2014,8 +2041,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@@ -2348,6 +2378,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -2621,6 +2652,25 @@ version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -2896,7 +2946,7 @@ dependencies = [
[[package]]
name = "library"
version = "1.8.1-alpha"
version = "1.8.2-alpha"
dependencies = [
"audio-formats",
"log",
@@ -2978,6 +3028,12 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mach2"
version = "0.4.3"
@@ -3034,7 +3090,7 @@ dependencies = [
[[package]]
name = "metadata"
version = "1.8.1-alpha"
version = "1.8.2-alpha"
dependencies = [
"futures",
"httpdate",
@@ -3560,6 +3616,17 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
dependencies = [
"dunce",
"is-wsl",
"libc",
]
[[package]]
name = "openssl"
version = "0.10.81"
@@ -3701,7 +3768,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "persona-state"
version = "1.8.1-alpha"
version = "1.8.2-alpha"
dependencies = [
"bincode",
"blake3",
@@ -3799,7 +3866,7 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "player"
version = "1.8.1-alpha"
version = "1.8.2-alpha"
dependencies = [
"audio-formats",
"env_logger",
@@ -4026,6 +4093,62 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.20",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.20",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.47"
@@ -4068,6 +4191,17 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20 0.10.2",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
@@ -4106,6 +4240,21 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -4245,6 +4394,8 @@ dependencies = [
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
@@ -4252,6 +4403,7 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
@@ -4259,6 +4411,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -4462,6 +4615,7 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
]
@@ -4960,7 +5114,7 @@ dependencies = [
[[package]]
name = "soulseek-client"
version = "1.8.1-alpha"
version = "1.8.2-alpha"
dependencies = [
"audio-formats",
"bytes",
@@ -4975,7 +5129,7 @@ dependencies = [
[[package]]
name = "soulseek-proto"
version = "1.8.1-alpha"
version = "1.8.2-alpha"
dependencies = [
"bytes",
"flate2",
@@ -4985,7 +5139,7 @@ dependencies = [
[[package]]
name = "soulstream-setup"
version = "1.8.1-alpha"
version = "1.8.2-alpha"
dependencies = [
"base64 0.21.7",
"dirs 5.0.1",
@@ -5392,7 +5546,7 @@ dependencies = [
[[package]]
name = "tagger"
version = "1.8.1-alpha"
version = "1.8.2-alpha"
dependencies = [
"lofty",
"log",
@@ -5659,6 +5813,68 @@ dependencies = [
"time",
]
[[package]]
name = "tauri-plugin-media-session"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91"
dependencies = [
"dunce",
"glob",
"objc2-app-kit",
"objc2-foundation",
"open",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.20",
"url",
"windows 0.61.3",
"zbus",
]
[[package]]
name = "tauri-plugin-share"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-storage"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-transfer-notice"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.11.0"
@@ -6645,6 +6861,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.6"
@@ -6710,6 +6936,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
+5 -1
View File
@@ -12,10 +12,14 @@ members = [
"crates/audio-formats",
"crates/freenet-adapter",
"crates/persona-state",
"crates/tauri-plugin-media-session",
"crates/tauri-plugin-storage",
"crates/tauri-plugin-share",
"crates/tauri-plugin-transfer-notice",
]
[workspace.package]
version = "1.8.1-alpha"
version = "1.8.2-alpha"
edition = "2021"
rust-version = "1.77.2"
authors = ["Trifecta Solutions"]
+9 -1
View File
@@ -7,7 +7,6 @@ rust-version.workspace = true
[dependencies]
futures = "0.3"
image.workspace = true
reqwest = { version = "0.12", features = ["json"] }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
@@ -15,3 +14,12 @@ thiserror.workspace = true
log.workspace = true
# RFC-7231 Date parsing for preview-lease math (tiny, no deps of its own).
httpdate = "1"
# Desktop keeps the platform TLS; Android has no OpenSSL to build against and
# takes rustls with the bundled roots instead. The two tables are exclusive on
# purpose — an unconditional entry would unify default-tls back in.
[target.'cfg(not(target_os = "android"))'.dependencies]
reqwest = { version = "0.12", features = ["json"] }
[target.'cfg(target_os = "android")'.dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+9
View File
@@ -471,6 +471,15 @@ fn open_output_stream(device_name: Option<&str>) -> Result<(rodio::OutputStream,
let actual_name = device.name().unwrap_or_else(|_| "Unknown device".into());
let builder = rodio::OutputStreamBuilder::from_device(device)
.map_err(|e| format!("could not configure {actual_name}: {e}"))?;
// Android: a music player's buffer, not the fast path's. AAudio's default
// lands the stream on the fast mixer with a ~10 ms burst, and the mixer's
// callback decodes and reads from storage inline — a 50 ms hiccup on a
// phone's flash or a busy core is an audible dropout there (the Pixel 6a
// logged runs of ~55 ms underruns, 2026-09-12). 8192 frames is ~170 ms of
// slack at 48 kHz, which nobody hears as latency on a record. Should the
// device refuse it, open_stream_or_fallback takes the default instead.
#[cfg(target_os = "android")]
let builder = builder.with_buffer_size(rodio::cpal::BufferSize::Fixed(8192));
let stream = builder
.open_stream_or_fallback()
.map_err(|e| format!("could not open {actual_name}: {e}"))?;
@@ -0,0 +1,16 @@
[package]
name = "tauri-plugin-media-session"
version = "0.1.0"
description = "SoulStream's media session on the phone: the lock-screen card, the notification's transport, audio focus, headset buttons — a foreground service that keeps playback alive in the background"
edition = "2021"
rust-version = "1.77.2"
# required by tauri-plugin's build script; must equal the crate name
links = "tauri-plugin-media-session"
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[build-dependencies]
tauri-plugin = { version = "2", features = ["build"] }
@@ -0,0 +1,2 @@
/build
/.tauri
@@ -0,0 +1,59 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.soulstream.mediasession"
compileSdk = 36
defaultConfig {
minSdk = 26
// the plugin's own R8 keep rules, handed to whatever app links this
// module — inert unless that app minifies, which only the release
// build type does
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
// The session, the card, and a notification media3 posts and owns itself.
//
// 1.10.1 and NOT 1.11.0, which is newer (2026-08-05) and would break this
// build: media3-session-1.11.0's POM declares
// org.jetbrains.kotlin:kotlin-stdlib:2.2.10 at compile scope, this project
// compiles Kotlin with kotlin-gradle-plugin 1.9.25
// (src-tauri/gen/android/build.gradle.kts), and Gradle resolves to the
// HIGHEST version — so a 1.9 compiler would be handed Kotlin 2.2 metadata.
// Every media3 POM from 1.4.1 through 1.10.1 declares no kotlin-stdlib at
// all, and SimpleBasePlayer.java is byte for byte the same file at both
// tags, so nothing this plugin uses is newer than what is taken here.
//
// What rides along: media3-common at COMPILE scope, and with it
// com.google.guava:guava:33.3.1-android, also compile — that is where
// ListenableFuture, Futures and SettableFuture come from, which is why
// they are not named on a line of their own — plus
// androidx.annotation-experimental, which is androidx.annotation.OptIn.
// Then lifecycle-service 2.5.1, because MediaSessionService extends
// LifecycleService; media3-datasource, the default BitmapLoader that
// decodes the cover; and androidx.media:media:1.7.0 itself, still, at
// RUNTIME scope. The deprecated library has not left the APK and cannot —
// media3 uses its MediaBrowserServiceCompat. What changed is that none of
// OUR code compiles against it any more.
//
// media3 1.10.1's own minSdk is 23; this module's is 26. Guava adds about
// 2.8 MB of unminified classes to the debug APK, and minSdk 26 means
// native multidex, so there is no multiDexEnabled line to add.
implementation("androidx.media3:media3-session:1.10.1")
implementation("androidx.media3:media3-common:1.10.1")
implementation(project(":tauri-android"))
}
@@ -0,0 +1,29 @@
# Consumer R8/ProGuard rules for the media-session plugin. Inert unless the
# consuming app minifies, and only the `release` build type does, so the
# debug APK the deploy pipeline builds is untouched by this file.
#
# MediaSessionPlugin is found by the string
# "com/soulstream/mediasession/MediaSessionPlugin" (register_android_plugin →
# FindClass), constructed through "(Landroid/app/Activity;)V", and its two
# @Command methods — update and clear — are dispatched by NAME through
# PluginHandle's commands map. NowPlayingArgs is filled by Jackson field by
# field, and QueueItemArgs — one per entry of the queue window it carries —
# the same way. Rename any of it and the app still builds, installs and
# plays; the lock-screen card simply never appears.
#
# The other half of this plugin is in the app-wide file: the card's buttons
# come back as the plugin event "action", which the WebView subscribes to
# through Plugin.registerListener — declared on tauri's base class, which no
# rule of tauri's own names. See src-tauri/android/proguard-soulstream.pro.
#
# MediaService is declared in this module's AndroidManifest.xml, so AGP's
# own merged-manifest keep rules hold it by name, and SoulStreamPlayer,
# MediaHub and LibraryTree need no rule of their own — the only thing that
# reaches any of them is a direct reference from MediaService.
# androidx's MediaButtonReceiver went with the move to media3: the
# service's own filter routes media buttons now — through the
# MediaLibraryService action, which MediaSessionLegacyStub queries first —
# and two receivers would have fought over them.
-keep class com.soulstream.mediasession.MediaSessionPlugin { *; }
-keep class com.soulstream.mediasession.NowPlayingArgs { *; }
-keep class com.soulstream.mediasession.QueueItemArgs { *; }
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- the foreground service that carries the session while the app is in
the background (mediaPlayback is required from Android 14) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- the card is a notification; asked for at runtime on Android 13+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- The app's own manifest lives in gitignored gen/ and declares no
android:appCategory, so the merger takes this one's: an application
attribute from a library merges up when the app does not set it.
API 26+, and minSdk here is 26. -->
<application android:appCategory="audio">
<service
android:name="com.soulstream.mediasession.MediaService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<!-- Three actions, one component, and all three are safe.
Android Auto binds the LEGACY one
(android.media.browse.MediaBrowserService), which media3
still serves: MediaSessionService.onBind answers it with
the session's legacy browser binder. media3's own two are
how a MediaController and a SessionToken find this
service; SessionToken(Context, ComponentName) tests
MediaLibraryService FIRST, so the component is one
TYPE_LIBRARY_SERVICE token however many actions it wears,
and getAllServiceTokens collects into a Set.
The MediaSessionService action is KEPT rather than
replaced because the media-button path already verified on
this phone hangs off it: MediaSessionLegacyStub looks for
an ACTION_MEDIA_BUTTON receiver, finds none (there is no
MediaButtonReceiver any more, and media3's own is
deliberately not in its place — its docs make it
conditional on MediaSession.Callback.onPlaybackResumption(),
which this app cannot implement because playback lives in
Rust behind a WebView round trip), and falls to a service
declared under MediaLibraryService and then
MediaSessionService. Either way it lands here.
android:exported is true above for the same reason the
filters are here: a browse service Auto cannot bind is a
browse service that does not exist. -->
<intent-filter>
<action android:name="androidx.media3.session.MediaLibraryService" />
<action android:name="androidx.media3.session.MediaSessionService" />
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<!-- media3's own media-button receiver, and the reason the app can
start COLD from a PLAY key: a Bluetooth head unit's play button,
the Quick Settings "resume" card after a reboot, and Android
Auto's autoplay on connection are all KEYCODE_MEDIA_PLAY arriving
at a process that does not exist. The receiver starts the
service in the foreground (MediaService.onStartCommand promotes
at once, see holdForeground) and media3 then asks
LibraryTree.onPlaybackResumption what to play. It forwards
ONLY play / play-pause / headset-hook into a dead app — a next or
a pause into nothing is dropped, not started. While the app is
alive the framework talks to the session directly and the
receiver is not involved. -->
<receiver
android:name="androidx.media3.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- What puts SoulStream in Android Auto's app list at all.
The resource is res/xml/automotive_app_desc.xml and the
reference is resolved at LINK time, so a missing file fails
the build rather than shipping a car-invisible app. -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<!-- The one colour pair Android Auto gives a media app, chosen at
build time. It cannot follow the artwork the way the phone's
accent does, so it is the brand's constant instead. See
res/values/car_theme.xml. -->
<meta-data
android:name="com.google.android.gms.car.application.theme"
android:resource="@style/SoulStreamCarTheme" />
</application>
</manifest>
@@ -0,0 +1,181 @@
package com.soulstream.mediasession
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
/** What the card shows — the Rust `NowPlaying`, camelCase. */
@InvokeArg
class NowPlayingArgs {
var title: String? = null
var artist: String? = null
var album: String? = null
var playing: Boolean = false
var position: Double = 0.0
var duration: Double? = null
/** identifies the cover; the bytes only come along when it changes */
var artKey: String? = null
/** the cover, base64 image bytes */
var art: String? = null
var hasNext: Boolean = true
var hasPrev: Boolean = true
/** the bytes are still on their way (a cold fetch from the network);
* the car shows a spinner rather than a position. Never sent by the
* page — its own player folds buffering into `playing`. */
var buffering: Boolean = false
/** what the engine is doing while `buffering` — "Searching Soulseek…",
* "3 copies · connecting to <peer>…", "Streaming from <peer>…" — shown
* on the card's artist line until the bytes sound. A card that went
* to the next title and sat at 0:00 read as the player freezing
* (2026-09-18); this is the difference. */
var status: String? = null
/** the queue around the current item — it and up to ten either side.
* Since round forty-eight SoulStreamPlayer.getState() turns this into
* the media3 playlist the card is built from, and the index into it.
* Nullable because a non-null Kotlin property whose setter Jackson
* hands a null throws, and the only thing between here and the JS that
* builds it is two JSON hops. */
var queue: List<QueueItemArgs>? = null
/** where the current item sits in `queue` — not in the whole queue;
* null when the window is empty */
var queueIndex: Int? = null
/** where the window starts in the WHOLE queue. A stable media3 uid is
* built out of it, so an entry keeps its identity as the window slides
* past it; it is also half of the row -> playQueueAt() mapping Auto
* will want. Null when the window is empty. */
var queueBase: Int? = null
/** Repeat, in the APP's numbering: 0 off, 1 all, 2 one. media3 numbers
* them 0 OFF, 1 ONE, 2 ALL — SWAPPED — and SoulStreamPlayer maps across
* rather than casting. */
var repeat: Int = 0
}
/** One entry of the queue window. Flat and small: it crosses the bridge on
* every update, so it holds what a media3 MediaItem is built from and no
* more. `artKey` names the cover the way NowPlayingArgs.artKey does; the
* bytes never ride along here. */
@InvokeArg
class QueueItemArgs {
var id: String? = null
var title: String? = null
var artist: String? = null
var album: String? = null
var duration: Double? = null
var artKey: String? = null
}
/** Why a row the car handed to the page did not play — the Rust
* `RemoteFailure`, camelCase. */
@InvokeArg
class RemoteFailureArgs {
var message: String? = null
}
/**
* The bridge between the WebView and the service that owns the session.
* `update` mirrors the track on the bar (and starts the service the first
* time); `clear` takes the card down. The session's buttons come back as
* the plugin event `action` with `{ action, position }`.
*/
@TauriPlugin
class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) {
private var askedForNotifications = false
init {
MediaHub.onAction = { action, position ->
val payload = JSObject()
payload.put("action", action)
payload.put("position", position)
trigger("action", payload)
}
/* The car asking the APP to play a row — any row, while the page is
up and owns the queue. Setting this is also what tells the media
service the app is warm — it is null whenever there is no WebView. */
android.util.Log.i("SoulStream", "auto plugin init — warm hand-off armed")
MediaHub.onPlay = { desc, startMs ->
val payload = JSObject()
payload.put("action", "playMediaId")
payload.put("desc", desc)
payload.put("startMs", startMs)
trigger("action", payload)
}
}
/** The page is going away — the app swiped out of recents, say — while
* the media service may keep this process alive for the car. From here
* there is nobody behind `trigger`, so the hand-off must stop believing
* the app is warm: a tap in the car would otherwise be posted to a
* WebView that no longer exists and answered by nothing. Null means the
* engine in this process plays what it can and the car is told about the
* rest, exactly as on a cold start. A new Activity builds a new plugin,
* whose init arms both again. */
override fun onDestroy() {
MediaHub.onPlay = null
MediaHub.onAction = null
android.util.Log.i("SoulStream", "auto plugin destroyed — hand-off disarmed")
super.onDestroy()
}
@Command
fun update(invoke: Invoke) {
val args = invoke.parseArgs(NowPlayingArgs::class.java)
/* The window is on no screen anywhere — the card shows ONE track —
so logcat is the only place on the device the whole of it can be
read back, and tests/mobile/smoke.mjs reads it from exactly here.
One line per update, and an update is a track change, a transport
change or the thirty-second top-up, never a position tick.
`base` and `rep` are here for the same reason and a sharper one:
since round forty-eight they steer the media3 playlist and what
a controller is told about repeat, they appear on no screen, and a
camelCase key spelled snake_case arrives as nothing on either hop
of this bridge without an error anywhere. `at=` goes LAST because
a title may hold anything, spaces and `=` included. */
val window = args.queue ?: emptyList()
android.util.Log.i(
"SoulStream",
"media window n=" + window.size + " i=" + (args.queueIndex ?: -1) +
" art=" + window.count { !it.artKey.isNullOrEmpty() } +
" base=" + (args.queueBase ?: -1) + " rep=" + args.repeat +
" at=" + (window.getOrNull(args.queueIndex ?: -1)?.title ?: ""),
)
ensureNotificationPermission()
MediaHub.update(activity.applicationContext, args)
invoke.resolve()
}
@Command
fun clear(invoke: Invoke) {
MediaHub.clear(activity.applicationContext)
invoke.resolve()
}
/** The page could not play a row the car handed it. The session says
* why, on the car screen, instead of leaving the driver in silence. */
@Command
fun remoteFailed(invoke: Invoke) {
val args = invoke.parseArgs(RemoteFailureArgs::class.java)
MediaHub.fail(activity.applicationContext, args.message ?: "SoulStream could not play that")
invoke.resolve()
}
/** Android 13+: the card is a notification and needs the permission; asked
* once, the first time something plays. Media controls in the shade and
* on the lock screen follow the session either way. */
private fun ensureNotificationPermission() {
if (Build.VERSION.SDK_INT < 33 || askedForNotifications) return
askedForNotifications = true
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED
) return
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4711)
}
}
@@ -0,0 +1,572 @@
package com.soulstream.mediasession
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.SimpleBasePlayer
import androidx.media3.common.util.UnstableApi
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import java.io.File
/**
* The `Player` media3 shows the world, over an engine it cannot see.
*
* rodio owns playback, in Rust, and Kotlin has never touched a sample — so
* there is no `ExoPlayer` here and there never will be. `SimpleBasePlayer`
* is media3's supported answer to exactly that shape: implement
* [getState] as a pure read of whatever the real engine last said, route
* every transport call into the engine, and call [invalidateState] when the
* engine answers. `MediaHub.state` is that "whatever it last said" — the
* last `NowPlayingArgs` the WebView pushed across the bridge — and
* `MediaHub.act` is the way back out to it.
*
* The opt-in is `androidx.annotation.OptIn`, imported by name because
* `kotlin.OptIn` is in every file's default scope and would otherwise win —
* and written `markerClass = [...]` because that is the attribute's real
* name in either reading of it. `@UnstableApi` is `RetentionPolicy.CLASS`
* plus androidx's own `RequiresOptIn`, which makes it a LINT error and not a
* compiler one: the debug build this project has always made never runs
* lint, `lintVitalArm64Release` would, and the annotation keeps that honest
* either way.
*/
@OptIn(markerClass = [UnstableApi::class])
class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMainLooper()) {
companion object {
/** How long a transport command may wait for the WebView before the
* card is allowed to snap back to the truth. See [awaitUpdate]. */
const val ACT_TIMEOUT_MS = 2500L
}
private val main = Handler(Looper.getMainLooper())
/** Commands sent out to the WebView and not yet answered. Main thread only. */
private val pending = ArrayList<SettableFuture<Void>>()
private val expire = Runnable { settle() }
private var released = false
/* ── the state media3 reads ──────────────────────────────────────── */
override fun getState(): SimpleBasePlayer.State {
/* Something asked for could not play, and the reason is what the
car should show rather than a blank player or the last track's
card. A player error is only legal in STATE_IDLE, and media3's
legacy stub turns exactly that into PlaybackStateCompat's
STATE_ERROR with the message — which Android Auto draws as a card
over the browse list. The playlist is kept (if there was one) so
the transport does not lose its shape; the next update clears it. */
MediaHub.error?.let { why ->
val err = androidx.media3.common.PlaybackException(
why, null, androidx.media3.common.PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
)
return SimpleBasePlayer.State.Builder()
.setAvailableCommands(commands(null))
.setPlaybackState(Player.STATE_IDLE)
.setPlayerError(err)
.setPlaylist(emptyList<SimpleBasePlayer.MediaItemData>())
.build()
}
val np = MediaHub.state
?: return SimpleBasePlayer.State.Builder()
.setAvailableCommands(commands(null))
.setPlaybackState(Player.STATE_IDLE)
.setPlaylist(emptyList<SimpleBasePlayer.MediaItemData>())
.build()
/* THE PLAYLIST MUST NEVER BE EMPTY WHILE A TRACK EXISTS.
MediaNotificationManager.shouldShowNotification returns false the
moment `controller.getCurrentTimeline().isEmpty()`, and it says
nothing anywhere when it does — the card simply never appears.
Today's frontend cannot send an empty window with a track on it:
media.svelte.js builds `win = []` only when `player.qi < 0`, and
`player.track` is `queue[qi]`, so that same state sends `clear`
instead. The one-item fallback is here because the failure it
guards is invisible and the guard is three lines: a frontend older
or newer than this plugin, or a `queue` that did not survive the
two JSON hops, would otherwise take the card down silently. */
val window = np.queue ?: emptyList()
var at = np.queueIndex ?: -1
val items = ArrayList<SimpleBasePlayer.MediaItemData>()
if (window.isEmpty() || at < 0 || at >= window.size) {
at = 0
items.add(item("solo", null, np.title, np.artist, np.album, np.duration, null, true))
} else {
for (i in window.indices) {
val q = window[i]
/* A STABLE uid. `queueBase` is where the window starts in the
whole queue, so an entry keeps its identity as the window
slides past it and media3 reports one media-item transition
per track change rather than re-labelling all twenty-one.
Without it (an older frontend across the bridge) fall back
to the slot, which is unique within one playlist — that is
all setPlaylist demands — at the cost of a rebuild. */
val base = np.queueBase
val uid = if (base != null) "q" + (base + i) else "w" + i + ":" + (q.id ?: "")
items.add(item(uid, q.id, q.title, q.artist, q.album, q.duration, q.artKey, i == at))
}
}
/* THE POSITION COMES FROM THE ENGINE, not from the card.
`np.position` is whatever the state was BUILT with, and on the
headless path that is zero for the life of the track — nothing
writes it again. media3 extrapolates while playing, so the bar
moved and looked correct, but every rebuild snapped back to zero:
pausing read as the track restarting, and a seek that genuinely
happened showed as nothing. Asking the engine costs one JNI call
per rebuild and is always true. */
var posMs = if (np.position > 0) (np.position * 1000).toLong() else 0L
var sounding = np.playing
var buffering = false
if (headless()) {
posMs = AutoNative.positionMs()
sounding = AutoNative.sounding()
/* a cold fetch whose first bytes are still on their way: the car
draws a spinner, and media3 does not extrapolate a position
that is not moving */
buffering = np.buffering && !sounding
}
return SimpleBasePlayer.State.Builder()
.setAvailableCommands(commands(np))
/* STATE_READY even when paused. shouldShowNotification treats
STATE_IDLE as "nothing to show" unless it has been prepared and
stopped, so a paused player reporting IDLE loses its card.
The page's own player folds buffering into `playing`; only the
headless fetch reports BUFFERING, above. */
.setPlaybackState(if (buffering) Player.STATE_BUFFERING else Player.STATE_READY)
.setPlayWhenReady(sounding || buffering, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
.setPlaylist(items)
.setCurrentMediaItemIndex(at)
/* The long overload, not a PositionSupplier: State's own
constructor turns it into PositionSupplier.getExtrapolating
when playWhenReady and STATE_READY, and getConstant otherwise —
which is precisely "the card ticks while playing and holds
while paused", for free and in one place. */
.setContentPositionMs(posMs)
/* The app's numbering is 0 off · 1 ALL · 2 ONE; media3's is
0 OFF · 1 ONE · 2 ALL. They are SWAPPED, and mapping them by
value would silently turn repeat-all into repeat-one. It is
mirrored at all so the player's state is not a lie to whatever
reads it — the system's own media controls now, Android Auto
later — and so media3's view of "is there a next item" agrees
with the hasNext the next button is advertised on. The BUTTON
does not hang on it: BasePlayer forwards a seek it cannot
resolve rather than dropping it (ignoreSeek -> handleSeek with
C.INDEX_UNSET), so an advertised command always arrives. */
.setRepeatMode(
when (np.repeat) {
1 -> Player.REPEAT_MODE_ALL
2 -> Player.REPEAT_MODE_ONE
else -> Player.REPEAT_MODE_OFF
},
)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
)
.build()
}
/** One row of the window as media3 wants it. `current` is the one whose
* cover rides along as bytes; the rest name a file and media3's own
* BitmapLoader reads it if anything ever asks. */
private fun item(
uid: String,
id: String?,
title: String?,
artist: String?,
album: String?,
duration: Double?,
artKey: String?,
current: Boolean,
): SimpleBasePlayer.MediaItemData {
/* while the engine is finding the current row's bytes, its status
line rides the artist line — the one line the car shows under
the title */
val status = if (current) MediaHub.state?.let { s -> if (s.buffering) s.status else null } else null
val md = MediaMetadata.Builder()
.setTitle(if (current) (title ?: "SoulStream") else (title ?: ""))
.setArtist(status?.takeIf { it.isNotEmpty() } ?: (artist ?: ""))
.setAlbumTitle(album ?: "")
.setIsBrowsable(false)
.setIsPlayable(true)
if (current) {
/* The cover, or the app's own icon. `artBytes` is cleared the
moment `artKey` changes and only refilled once the frontend has
decoded one — and a track with no cover never refills it — so
without the fallback every track change has a window where the
lock screen shows a grey hole. media3 decodes these bytes
itself (CacheBitmapLoader over DataSourceBitmapLoader), which
is one BitmapFactory call this file no longer makes. */
val bytes = MediaHub.artBytes
val remote = MediaHub.artKey?.takeIf { it.startsWith("http://") || it.startsWith("https://") }
if (bytes != null) {
md.setArtworkData(bytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
} else if (remote != null) {
/* A streamed record's cover is a URL and no bytes ever arrive
for it on the headless path — there is no page to decode
one. media3's own loader fetches a URI, so the car gets
the sleeve and the colour it washes the screen with,
instead of the app icon's blue. */
md.setArtworkUri(Uri.parse(remote))
} else {
MediaHub.appIcon(ctx)?.let { md.setArtworkData(it, MediaMetadata.PICTURE_TYPE_FRONT_COVER) }
}
} else if (!artKey.isNullOrEmpty()) {
/* A LOCAL path only. A catalog record's art key is an http URL,
and media3's default loader would happily fetch it — twenty
times, off a queue nothing is looking at. */
val f = File(artKey)
if (f.isAbsolute) md.setArtworkUri(Uri.fromFile(f))
}
val meta = md.build()
val durUs = if (duration != null && duration > 0) (duration * 1_000_000).toLong() else C.TIME_UNSET
return SimpleBasePlayer.MediaItemData.Builder(uid)
.setMediaItem(MediaItem.Builder().setMediaId(id ?: uid).setMediaMetadata(meta).build())
.setMediaMetadata(meta)
.setIsSeekable(true)
.setIsDynamic(false)
.setDurationUs(durUs)
.build()
}
/** What the card, the lock screen and a headset are allowed to ask for.
*
* COMMAND_GET_TIMELINE and COMMAND_GET_CURRENT_MEDIA_ITEM go together
* and neither is optional. The media notification controller reads the
* playlist THROUGH the session, and PlayerInfo.filterByAvailableCommands
* cuts what it may see down to what its commands allow: one window
* without COMMAND_GET_TIMELINE, and Timeline.EMPTY without either. An
* empty timeline on the far side is shouldShowNotification refusing the
* card — the same silent failure as an empty playlist, from the other
* direction.
*
* Both spellings of next and previous, because
* DefaultMediaNotificationProvider.getMediaButtons SHOWS the button on
* containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) and
* then sets the button's command to the _MEDIA_ITEM one: advertise only
* the plain one and the button appears and does nothing.
*
* COMMAND_SET_MEDIA_ITEM arrived with the round that plays from the car:
* it is what lets a controller hand us a media id at all, and without it
* a browse row is drawn as playable and does nothing when tapped. It is
* safe to advertise unconditionally because [handleSetMediaItems]
* resolves the id against the library itself rather than trusting a URI.
*
* Still not here, on purpose: COMMAND_CHANGE_MEDIA_ITEMS and
* COMMAND_SEEK_TO_MEDIA_ITEM. Tapping a row of the QUEUE is a different
* thing from tapping a row of the library — it needs a window-index →
* queue-index mapping, and `queueBase` is only half of that. */
private fun commands(np: NowPlayingArgs?): Player.Commands {
val b = Player.Commands.Builder()
.add(Player.COMMAND_PLAY_PAUSE)
.add(Player.COMMAND_PREPARE)
.add(Player.COMMAND_STOP)
.add(Player.COMMAND_RELEASE)
.add(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
.add(Player.COMMAND_SEEK_TO_DEFAULT_POSITION)
.add(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)
.add(Player.COMMAND_GET_TIMELINE)
.add(Player.COMMAND_GET_METADATA)
.add(Player.COMMAND_GET_AUDIO_ATTRIBUTES)
.add(Player.COMMAND_SET_AUDIO_ATTRIBUTES)
.add(Player.COMMAND_SET_MEDIA_ITEM)
if (np != null) {
b.addIf(Player.COMMAND_SEEK_TO_NEXT, np.hasNext)
b.addIf(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, np.hasNext)
b.add(Player.COMMAND_SEEK_TO_PREVIOUS)
b.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
}
return b.build()
}
/* ── the transport, back out to the WebView ──────────────────────── */
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
android.util.Log.i(
"SoulStream",
"auto playWhenReady=" + playWhenReady + " headless=" + headless(),
)
if (headless() || !MediaHub.warm()) {
/* the engine's own queue, or a page that is gone (the app swiped
away under live audio): pause and resume belong to the engine
either way, and a message to a WebView that no longer exists
is the button doing nothing */
AutoNative.transport(if (playWhenReady) "resume" else "pause")
return Futures.immediateVoidFuture()
}
MediaHub.act(if (playWhenReady) "play" else "pause")
return awaitUpdate()
}
/**
* True when the engine in this process is the thing playing and there is
* no WebView to send a transport command to.
*
* Every control here used to be a round trip out to the JS. In a car that
* started a cold process there is no JS, so pause, seek, next and previous
* were all messages to nobody — the buttons drew, moved, and did nothing.
* `driving()` is true only once the headless queue holds something, so a
* normal phone session is untouched and still goes through the WebView,
* which owns shuffle, repeat, `alts` failover and the real queue.
*/
private fun headless(): Boolean =
try {
AutoNative.driving()
} catch (e: Throwable) {
false
}
/** Nothing to prepare: the Rust side sources a track on its own, and
* `getState` never reports STATE_IDLE while there is one. Answered
* rather than left to throw, because the default body of every
* `handle*` is `throw IllegalStateException`. */
override fun handlePrepare(): ListenableFuture<*> = Futures.immediateVoidFuture()
/**
* A row tapped in the car.
*
* The id is resolved against the library IN RUST, not from a URI on the
* item: the browse tree hands out `ss:track:<id>` and nothing else, so
* there is no file path to trust and no way for a controller to name a
* file we did not publish.
*
* It runs off the caller's thread because the first tap in a cold process
* does two slow things — opens the database and starts the audio engine —
* and this is called on the application thread, where ten seconds is an
* ANR. The future resolving is what tells media3 the command took.
*
* `startIndex` can be `INDEX_UNSET`; that means "play the list from the
* top", which for a single tapped row is the row itself.
*/
override fun handleSetMediaItems(
mediaItems: MutableList<MediaItem>,
startIndex: Int,
startPositionMs: Long,
): ListenableFuture<*> {
val i = if (startIndex == androidx.media3.common.C.INDEX_UNSET) 0 else startIndex
val id = mediaItems.getOrNull(i)?.mediaId
if (id.isNullOrEmpty()) return Futures.immediateVoidFuture()
// The position media3 asks for: a resumption carries the saved one, a
// tapped row carries TIME_UNSET. Below zero tells Rust "where it was
// left", which only the resume row has an answer to.
val startMs = if (startPositionMs == C.TIME_UNSET || startPositionMs < 0) -1L else startPositionMs
return Futures.submit<Unit>(
{
// THE APP IS UP: the page owns the queue, so the tap is the
// page's — whatever the row is. Starting it on the engine
// underneath the page instead (which is what this did for a
// library album while the page had a queue of its own) left
// two drivers on one engine: the bar naming one track while
// another played, and the next boundary decided by whichever
// got there first. The page asks for focus itself when it
// plays, and reports back through remote_failed if it cannot.
if (MediaHub.warm()) {
val desc = AutoNative.describe(ctx.dataDir.absolutePath, id)
android.util.Log.i("SoulStream", "auto hand-off to the app: $id -> $desc")
MediaHub.play(desc, startMs)
return@submit
}
// FOCUS FIRST, and it is not optional. Without it the track
// opens into a port the framework keeps muted: the first run
// of this path showed a live AAudio track on our uid with
// PortMuted=true and the gain at -inf — decoding perfectly,
// audible to nobody. On the WebView path the JS asks for
// focus; a car tapping a cold process has no JS to do it.
MediaHub.requestFocus(ctx.applicationContext)
val reply = AutoNative.play(ctx.dataDir.absolutePath, id, startMs)
val o = try { org.json.JSONObject(reply) } catch (e: Throwable) { org.json.JSONObject() }
val queued = o.optInt("queued", 0)
android.util.Log.i("SoulStream", "auto play $id queued=$queued start=$startMs")
if (queued == 0) {
// Nothing on this phone can play it, and the process is
// cold: the focus goes back rather than being held for
// silence — and the car is TOLD, which it never was
// before. A streamed row is fetched from the network by
// this process now (headless_slsk.rs); `error` is its
// reason when that could not even start.
MediaHub.dropFocus(ctx.applicationContext)
val why = if (o.isNull("error")) null else o.optString("error").ifEmpty { null }
if (why != null) {
android.util.Log.i("SoulStream", "auto cold fetch refused: $why")
MediaHub.fail(ctx.applicationContext, why)
} else if (o.optBoolean("remote", false)) {
android.util.Log.i("SoulStream", "auto cold, cannot resolve: $id")
MediaHub.fail(
ctx.applicationContext,
"This track streams from the network. Open SoulStream on your phone to play it here.",
)
} else if (id == "ss:resume") {
MediaHub.fail(ctx.applicationContext, "Nothing to continue yet.")
} else {
MediaHub.fail(ctx.applicationContext, "That track is not on this phone any more.")
}
} else {
// Write the card ourselves. `getState()` mirrors
// MediaHub.state, and the only thing that ever fills it is
// the WebView — so a cold car tap played audio while the
// session still said STATE_NONE with no title, which is a
// head unit showing a blank player over music. The engine
// knows what it started; this is that, in the shape the
// card already reads.
val np = NowPlayingArgs()
np.title = o.optString("title").ifEmpty { null }
np.artist = o.optString("artist").ifEmpty { null }
np.album = o.optString("album").ifEmpty { null }
np.duration = if (o.isNull("duration")) null else o.optDouble("duration")
/* a resumed track starts where it was left; the engine's
own position takes over from the first tick */
np.position = o.optLong("positionMs", 0L) / 1000.0
np.playing = true
/* a cold fetch: the title now, a spinner until the first
bytes play (headlessState turns it off) */
np.buffering = o.optBoolean("resolving", false)
/* the engine narrated inside the call that just returned
(headlessState ran before this line): keep its line,
or the first one, rather than the artist for a beat */
np.status = if (np.buffering) (MediaHub.state?.status ?: "Finding a copy…") else null
np.hasNext = o.optBoolean("hasNext", queued > 1)
np.hasPrev = false
// The cover, which is also the colour: Android Auto washes
// the playback screen with whatever artwork it is handed,
// so shipping no art tinted every record with the app
// icon's blue.
val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null }
np.artKey = art
np.art = AutoNative.artB64(art)
MediaHub.update(ctx.applicationContext, np)
}
},
playIo,
)
}
/** One thread, for the tap that opens a database and starts an engine. */
private val playIo =
java.util.concurrent.Executors.newSingleThreadExecutor { r ->
Thread(r, "soulstream-auto-play").apply { isDaemon = true }
}
override fun handleStop(): ListenableFuture<*> {
if (headless() || !MediaHub.warm()) {
AutoNative.transport("stop")
return Futures.immediateVoidFuture()
}
MediaHub.act("stop")
return awaitUpdate()
}
override fun handleRelease(): ListenableFuture<*> {
released = true
main.removeCallbacks(expire)
val batch = ArrayList(pending)
pending.clear()
/* posted, not completed here: finishing a future re-enters media3's
own listener, and this one is already inside a player call */
main.post { for (f in batch) f.set(null) }
return Futures.immediateVoidFuture()
}
/** A no-op that must exist. `setAudioAttributes(attrs, handleAudioFocus)`
* is an ExoPlayer convenience: on SimpleBasePlayer it lands HERE, whose
* default body throws, and media3 does none of the focus work for a
* custom player — `AudioFocusManager` is inside media3-exoplayer and is
* not public API. Focus is still ours, requested and abandoned by hand
* in MediaService, exactly as it was before the migration. */
override fun handleSetAudioAttributes(
audioAttributes: AudioAttributes,
handleAudioFocus: Boolean,
): ListenableFuture<*> = Futures.immediateVoidFuture()
/** Every seek, next and previous arrives here — there is no
* `handleSeekToNext` and never was — distinguished by the command that
* triggered it. The index is ignored on purpose: the app's own `next()`
* and `prev()` know about repeat, about the five-second restart rule and
* about the end of the queue, and the window is only ever a view.
*
* It arrives even when media3 has nothing to seek TO: `BasePlayer`
* forwards such a press rather than dropping it, as
* `ignoreSeek` -> `handleSeek(C.INDEX_UNSET, C.TIME_UNSET, command)`.
* Which is why the previous button still works at the very head of a
* queue, where the timeline has no previous item but the app's own
* `prev()` seeks to zero. The gate is the COMMAND being advertised,
* and nothing else. */
override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> {
val secs = if (positionMs == C.TIME_UNSET) 0.0 else positionMs / 1000.0
if (headless() || !MediaHub.warm()) {
when (seekCommand) {
Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM ->
AutoNative.transport("next")
Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM ->
AutoNative.transport("prev")
else -> AutoNative.seek(secs)
}
return Futures.immediateVoidFuture()
}
when (seekCommand) {
Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> MediaHub.act("next")
Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> MediaHub.act("prev")
else -> MediaHub.act("seek", secs)
}
return awaitUpdate()
}
/* ── the round trip ──────────────────────────────────────────────── */
/**
* A transport command is a round trip: out through `Plugin.trigger` to
* the WebView, into the player, down to Rust, and back as the next
* `update`. Answering media3 immediately would have it call [getState]
* on the spot — which still reports the OLD state — and the lock screen
* flips back before it flips forward.
*
* So hand it a future nobody has completed. While one is outstanding
* media3 shows its own suggested placeholder (play-pause flipped, the
* seek applied), which is exactly the optimistic answer we want, and
* [invalidateState] deliberately does nothing until the last one
* resolves. `MediaHub.update` resolves them; [expire] resolves them
* anyway after [ACT_TIMEOUT_MS], because a future that is never
* completed does not degrade the card — it FREEZES it.
*/
private fun awaitUpdate(): ListenableFuture<*> {
if (released) return Futures.immediateVoidFuture()
val f = SettableFuture.create<Void>()
pending.add(f)
main.removeCallbacks(expire)
main.postDelayed(expire, ACT_TIMEOUT_MS)
return f
}
/** The engine answered (or stopped answering). Main thread only — every
* caller is `MediaHub`'s own post to the main looper, or [expire]. */
fun settle() {
if (pending.isEmpty()) return
main.removeCallbacks(expire)
val batch = ArrayList(pending)
pending.clear()
/* completing these re-reads getState() on this same thread, inline */
for (f in batch) f.set(null)
}
/** `invalidateState` is `protected final`; this is the door MediaHub
* uses. A no-op once released, and a no-op inside media3 anyway while
* anything is still pending. */
fun refresh() {
if (released) return
invalidateState()
}
fun markReleased() {
released = true
main.removeCallbacks(expire)
pending.clear()
}
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Shuffle, for the car's playback actions. White and flat on purpose: Android
Auto tints these itself and draws them at one size, and anything with its own
colour or gradient comes out wrong against the car's day/night switch. The
app's own animated glyphs must never reach this surface — SA-1 in the quality
checklist forbids animation on a car screen.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A root tab's icon. Flat white; Android Auto tints these for day/night. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp" android:height="36dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6zM10,19c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A root tab's icon. Flat white; Android Auto tints these for day/night. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp" android:height="36dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A root tab's icon. Flat white; Android Auto tints these for day/night. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp" android:height="36dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8zM12.5,7H11v6l5.25,3.15 0.75,-1.23 -4.5,-2.67z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A root tab's icon. Flat white; Android Auto tints these for day/night. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp" android:height="36dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M17,3H7c-1.1,0 -1.99,0.9 -1.99,2L5,21l7,-3 7,3V5c0,-1.1 -0.9,-2 -2,-2z" />
</vector>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- the status-bar glyph: the falcon, white on transparent, as the shade
wants — Android composites a notification's small icon from its ALPHA
CHANNEL and tints it itself, so a coloured export renders as a white blob
and only a screenshot can tell the difference.
The path is the logo kit's monochrome layer verbatim
(branding/soulstream-logo-kit/android/adaptive/ic_launcher_monochrome.svg),
drawn in its own large source space. An Android path element has no
transform attribute, so the SVG's
translate(9.814 9.814) scale(0.0863) translate(-40.740741 20.370370)
is folded into the group below and re-framed for a 24dp box: the falcon
lands x 4.91 to 19.09, y 2 to 22. It is a narrow mark (0.709:1), so the
side margins are generous and the top and bottom are not, which is what a
status bar wants. The transfers notice carries the same falcon. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:translateX="-1.6606"
android:translateY="-0.7287"
android:scaleX="0.026185"
android:scaleY="0.026185">
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M 471.889 863.531 C 470.033 861.073 467.485 856.812 466.226 854.062 C 464.398 850.070 463.857 847.418 463.544 840.920 C 463.249 834.800 463.512 831.540 464.601 827.793 C 466.399 821.609 469.407 815.555 473.409 810.066 C 476.304 806.095 476.482 805.474 476.562 799.067 C 476.630 793.639 477.162 790.964 479.217 785.732 C 483.406 775.062 489.001 766.958 499.269 756.689 C 504.437 751.520 515.049 742.428 522.967 736.383 C 530.854 730.363 540.573 722.201 544.565 718.246 C 548.557 714.290 553.986 707.799 556.630 703.821 C 566.285 689.294 570.061 677.926 570.109 663.251 C 570.167 645.247 564.288 631.978 550.406 618.778 C 543.711 612.413 537.141 607.939 527.046 602.871 C 511.185 594.907 499.224 590.830 464.632 581.594 C 429.650 572.254 407.140 565.108 384.291 556.087 C 369.696 550.324 349.615 540.740 338.462 534.213 C 320.047 523.435 302.208 509.772 290.291 497.319 C 282.487 489.163 272.391 476.452 268.043 469.309 C 260.079 456.225 252.284 438.708 251.281 431.640 C 250.907 429.006 251.062 428.044 251.860 428.044 C 252.460 428.044 257.063 430.702 262.090 433.950 C 279.955 445.495 298.689 456.033 313.781 463.025 L 321.990 466.828 L 315.175 459.916 C 306.482 451.099 297.522 440.054 290.373 429.342 C 283.846 419.562 275.156 402.180 271.326 391.240 C 268.303 382.605 266.682 375.526 264.934 363.320 C 263.614 354.107 263.278 340.053 264.377 340.053 C 264.755 340.053 271.343 346.050 279.018 353.379 C 305.154 378.337 323.767 394.790 344.545 411.305 C 357.025 421.223 379.468 437.524 391.447 445.369 C 421.469 465.031 452.755 481.034 483.387 492.398 C 493.465 496.137 514.890 502.670 542.833 510.526 C 553.736 513.591 567.823 518.023 574.138 520.375 C 589.578 526.124 605.702 534.093 616.621 541.371 C 626.525 547.973 639.041 559.257 644.889 566.856 C 650.348 573.951 654.995 583.053 657.601 591.759 C 659.560 598.303 659.892 600.978 659.957 610.795 C 660.037 622.827 659.257 627.864 655.632 638.715 C 653.007 646.577 649.606 653.442 644.525 661.136 C 642.375 664.394 640.265 667.630 639.838 668.328 C 639.410 669.026 642.266 666.701 646.183 663.161 C 658.976 651.602 669.377 635.863 674.360 620.525 C 680.887 600.431 681.418 582.436 676.073 562.442 C 672.127 547.683 662.390 530.337 650.734 517.305 C 640.568 505.936 625.125 494.022 607.956 484.301 C 589.833 474.040 573.381 467.503 530.216 453.412 C 512.773 447.718 493.356 440.996 487.066 438.473 C 474.757 433.536 464.611 428.344 455.790 422.469 C 449.119 418.025 438.217 408.277 432.454 401.601 C 422.380 389.933 413.615 372.247 410.336 356.975 C 409.168 351.535 408.804 346.280 408.840 335.400 C 408.881 323.303 409.202 319.739 410.863 312.979 C 414.453 298.361 419.429 287.287 428.148 274.505 C 434.444 265.277 446.706 252.323 456.519 244.535 C 483.937 222.774 516.119 207.844 564.045 194.653 C 579.471 190.408 592.384 187.409 620.816 181.469 C 654.191 174.497 674.652 168.373 699.416 157.943 C 728.416 145.729 764.550 125.102 787.619 107.593 C 791.507 104.642 792.482 104.208 792.476 105.431 C 792.471 106.273 791.534 108.965 790.394 111.414 C 785.297 122.364 772.485 139.362 759.953 151.803 C 750.130 161.555 738.729 170.675 725.744 179.167 C 715.779 185.684 694.819 196.809 683.162 201.768 C 680.040 203.097 677.586 204.285 677.710 204.409 C 678.135 204.834 706.608 197.159 714.223 194.567 C 728.618 189.666 742.169 183.962 756.021 176.972 C 763.653 173.121 770.077 170.148 770.294 170.366 C 771.203 171.274 761.797 182.974 752.317 192.729 C 741.008 204.364 729.933 213.624 716.943 222.304 C 698.650 234.528 680.678 243.412 657.959 251.461 C 642.229 257.034 624.511 261.607 598.311 266.858 C 587.608 269.003 574.363 271.878 568.878 273.248 C 555.300 276.639 537.612 282.530 526.395 287.397 C 507.859 295.440 490.988 307.165 482.040 318.224 C 479.729 321.080 476.651 325.830 475.199 328.779 C 473.747 331.728 471.958 337.085 471.225 340.682 C 470.121 346.096 470.058 348.298 470.860 353.458 C 472.774 365.768 478.100 375.203 489.472 386.432 C 502.963 399.753 527.921 414.250 558.969 426.799 C 566.647 429.902 581.496 435.667 591.966 439.609 C 638.284 457.049 660.959 467.018 682.072 479.225 C 693.287 485.709 708.151 495.840 717.893 503.640 C 729.475 512.914 743.275 526.023 750.422 534.542 C 762.716 549.194 773.237 567.193 778.922 583.298 C 782.840 594.397 785.607 607.564 786.657 620.110 C 787.335 628.206 787.324 633.770 786.618 641.059 C 783.472 673.507 771.319 700.824 748.857 725.937 C 745.773 729.385 740.336 734.824 736.774 738.023 C 710.587 761.547 675.649 780.088 633.589 792.780 C 625.820 795.125 606.899 800.277 591.543 804.231 C 558.928 812.627 544.532 816.705 530.869 821.417 C 510.985 828.275 497.176 835.191 487.628 843.074 C 480.759 848.745 477.646 854.394 477.088 862.205 C 476.860 865.392 476.356 868.000 475.968 868.000 C 475.580 868.000 473.745 865.989 471.889 863.531 Z M 507.752 803.633 C 514.224 802.436 520.624 799.550 524.691 795.993 C 526.496 794.414 530.427 789.316 533.427 784.662 C 536.427 780.009 539.690 775.298 540.676 774.193 L 542.471 772.184 L 540.355 772.833 C 535.533Line truncated
</group>
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Named by `com.google.android.gms.car.application.theme` in this module's
AndroidManifest.xml. Android Auto reads the colour attributes out of it
at connection time; nothing else in the app uses this style, and the car
never sees the phone's theme.
The parent is a framework theme on purpose: this is resolved inside the
car's process against the platform, so an AppCompat or Material parent is
not guaranteed to be there.
-->
<style name="SoulStreamCarTheme" parent="android:Theme.DeviceDefault">
<item name="android:colorPrimary">@color/ss_car_ground</item>
<item name="android:colorPrimaryDark">@color/ss_car_ground</item>
<item name="android:colorAccent">@color/ss_car_accent</item>
</style>
</resources>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
The falcon's teal, from the desktop icon.
Android Auto allows a media app exactly ONE colour pair, chosen at build
time, and it cannot follow the artwork — so where the app derives an
accent per cover, the car has to have a constant, and the constant is the
brand. It tints the FAB and the ring around play/pause, and nothing else.
-->
<color name="ss_car_accent">#31C0CB</color>
<!-- The app's own ground, so the car's chrome sits on SoulStream's dark
rather than the framework's default grey. -->
<color name="ss_car_ground">#05080B</color>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- The channel the card sits on. Its ID stays literally "playback":
tests/mobile/smoke.mjs:2448 finds the card by that id, and the
channel already on the phone — IMPORTANCE_LOW, lockscreen
PUBLIC, no badge — is left exactly as it is. -->
<string name="ss_playback_channel">Playback</string>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- What Android Auto reads to decide this is a media app it can host.
Named by the <meta-data android:name="com.google.android.gms.car.
application"> in this module's AndroidManifest.xml; "media" is the
whole declaration (the other value, "template", is for a car app
built on the Car App Library, which this is not). -->
<automotiveApp>
<uses name="media" />
</automotiveApp>
@@ -0,0 +1,18 @@
// registerListener / removeListener are the mobile plugin runtime's own
// commands, the ones `addPluginListener` invokes to hear the `action`
// event. They are commands like any other to the ACL: without them here the
// listener was refused ("media-session.registerListener not allowed") and
// the card's buttons went nowhere.
// `remote_failed` is the RUST command's name, which is what the ACL keys on
// (the invoke is `plugin:media-session|remote_failed`); the Kotlin side's
// `remoteFailed` is reached through run_mobile_plugin and never appears here.
const COMMANDS: &[&str] = &["update", "clear", "remote_failed", "registerListener", "removeListener"];
fn main() {
// Registers the command permissions (permissions/autogenerated) and, on
// Android, hands the Gradle module under android/ to tauri-build, which
// includes it in the app project (tauri.settings.gradle).
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.build();
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-clear"
description = "Enables the clear command without any pre-configured scope."
commands.allow = ["clear"]
[[permission]]
identifier = "deny-clear"
description = "Denies the clear command without any pre-configured scope."
commands.deny = ["clear"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-registerListener"
description = "Enables the registerListener command without any pre-configured scope."
commands.allow = ["registerListener"]
[[permission]]
identifier = "deny-registerListener"
description = "Denies the registerListener command without any pre-configured scope."
commands.deny = ["registerListener"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-remote-failed"
description = "Enables the remote_failed command without any pre-configured scope."
commands.allow = ["remote_failed"]
[[permission]]
identifier = "deny-remote-failed"
description = "Denies the remote_failed command without any pre-configured scope."
commands.deny = ["remote_failed"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-removeListener"
description = "Enables the removeListener command without any pre-configured scope."
commands.allow = ["removeListener"]
[[permission]]
identifier = "deny-removeListener"
description = "Denies the removeListener command without any pre-configured scope."
commands.deny = ["removeListener"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-update"
description = "Enables the update command without any pre-configured scope."
commands.allow = ["update"]
[[permission]]
identifier = "deny-update"
description = "Denies the update command without any pre-configured scope."
commands.deny = ["update"]
@@ -0,0 +1,154 @@
## Default Permission
The phone's media session: the lock-screen card, the notification's
transport, audio focus. `update` mirrors the track on the bar, `clear`
takes the card down, and the two listener commands are what lets the page
hear the card's buttons (`addPluginListener`).
#### This default permission set includes the following:
- `allow-update`
- `allow-clear`
- `allow-remote-failed`
- `allow-registerListener`
- `allow-removeListener`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`media-session:allow-clear`
</td>
<td>
Enables the clear command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:deny-clear`
</td>
<td>
Denies the clear command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:allow-registerListener`
</td>
<td>
Enables the registerListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:deny-registerListener`
</td>
<td>
Denies the registerListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:allow-remote-failed`
</td>
<td>
Enables the remote_failed command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:deny-remote-failed`
</td>
<td>
Denies the remote_failed command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:allow-removeListener`
</td>
<td>
Enables the removeListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:deny-removeListener`
</td>
<td>
Denies the removeListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:allow-update`
</td>
<td>
Enables the update command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:deny-update`
</td>
<td>
Denies the update command without any pre-configured scope.
</td>
</tr>
</table>
@@ -0,0 +1,10 @@
"$schema" = "schemas/schema.json"
[default]
description = """
The phone's media session: the lock-screen card, the notification's
transport, audio focus. `update` mirrors the track on the bar, `clear`
takes the card down, and the two listener commands are what lets the page
hear the card's buttons (`addPluginListener`).
"""
permissions = ["allow-update", "allow-clear", "allow-remote-failed", "allow-registerListener", "allow-removeListener"]
@@ -0,0 +1,366 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the clear command without any pre-configured scope.",
"type": "string",
"const": "allow-clear",
"markdownDescription": "Enables the clear command without any pre-configured scope."
},
{
"description": "Denies the clear command without any pre-configured scope.",
"type": "string",
"const": "deny-clear",
"markdownDescription": "Denies the clear command without any pre-configured scope."
},
{
"description": "Enables the registerListener command without any pre-configured scope.",
"type": "string",
"const": "allow-registerListener",
"markdownDescription": "Enables the registerListener command without any pre-configured scope."
},
{
"description": "Denies the registerListener command without any pre-configured scope.",
"type": "string",
"const": "deny-registerListener",
"markdownDescription": "Denies the registerListener command without any pre-configured scope."
},
{
"description": "Enables the remote_failed command without any pre-configured scope.",
"type": "string",
"const": "allow-remote-failed",
"markdownDescription": "Enables the remote_failed command without any pre-configured scope."
},
{
"description": "Denies the remote_failed command without any pre-configured scope.",
"type": "string",
"const": "deny-remote-failed",
"markdownDescription": "Denies the remote_failed command without any pre-configured scope."
},
{
"description": "Enables the removeListener command without any pre-configured scope.",
"type": "string",
"const": "allow-removeListener",
"markdownDescription": "Enables the removeListener command without any pre-configured scope."
},
{
"description": "Denies the removeListener command without any pre-configured scope.",
"type": "string",
"const": "deny-removeListener",
"markdownDescription": "Denies the removeListener command without any pre-configured scope."
},
{
"description": "Enables the update command without any pre-configured scope.",
"type": "string",
"const": "allow-update",
"markdownDescription": "Enables the update command without any pre-configured scope."
},
{
"description": "Denies the update command without any pre-configured scope.",
"type": "string",
"const": "deny-update",
"markdownDescription": "Denies the update command without any pre-configured scope."
},
{
"description": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-remote-failed`\n- `allow-registerListener`\n- `allow-removeListener`",
"type": "string",
"const": "default",
"markdownDescription": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-remote-failed`\n- `allow-registerListener`\n- `allow-removeListener`"
}
]
}
}
}
@@ -0,0 +1,191 @@
//! The media session on the phone.
//!
//! Android shows a playing app on the lock screen and in the shade through a
//! `MediaSession` carried by a foreground service, and it is that service
//! that keeps the process alive — and un-frozen — once the app is in the
//! background. The audio itself stays where it is, on the Rust audio thread;
//! the Kotlin side only mirrors what is playing and hands the card's
//! buttons, the headset's buttons and audio-focus changes back as events.
//!
//! Two commands, both fed by the phone UI (src/mobile/media.svelte.js):
//! `update` with the track on the bar, `clear` when the bar empties. The
//! session's actions arrive as the plugin event `action`
//! (`addPluginListener("media-session", "action", …)`).
//!
//! On the desktop the plugin is registered too, so the permission set
//! resolves on every platform, but its commands answer "not here": the
//! desktop has its own card (src-tauri/src/smtc.rs).
use serde::{Deserialize, Serialize};
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
};
/// What the card shows. `art` carries the cover's bytes (base64) only when
/// `art_key` changed since the last update — the card keeps the bitmap.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct NowPlaying {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
#[serde(default)]
pub playing: bool,
#[serde(default)]
pub position: f64,
pub duration: Option<f64>,
pub art_key: Option<String>,
pub art: Option<String>,
#[serde(default = "yes")]
pub has_next: bool,
#[serde(default = "yes")]
pub has_prev: bool,
/// The queue around what is playing: the current item and up to ten
/// either side, rebuilt on every update.
///
/// Round forty-eight is where this BECAME the card. `SoulStreamPlayer`
/// is a media3 `SimpleBasePlayer`, its `getState()` wants a playlist and
/// an index into one, and this window and `queue_index` are exactly that
/// — so what the lock screen shows is built out of these fields rather
/// than beside them.
///
/// A CAP, not a length: a two-thousand-track queue crosses the bridge as
/// twenty-one entries exactly like a twenty-one-track one does.
#[serde(default)]
pub queue: Vec<QueueItem>,
/// Where the current item sits IN `queue` — not in the whole queue.
/// `None` when the window is empty.
pub queue_index: Option<u32>,
/// Where `queue[0]` sits in the WHOLE queue. A media3 `MediaItemData`
/// uid is built from it, so an entry keeps its identity as the window
/// slides past it and a track change is ONE media-item transition
/// rather than twenty-one re-labelled rows; it is also half of the
/// mapping Android Auto needs to turn a tapped queue row back into
/// `playQueueAt()`. `None` when the window is empty.
pub queue_base: Option<u32>,
/// Repeat, in the app's own numbering: 0 off, 1 all, 2 one. media3
/// numbers them 0 OFF, 1 ONE, 2 ALL — SWAPPED — and the Kotlin side
/// maps across rather than casting, which would turn repeat-all into
/// repeat-one. It crosses at all so the player's state is not a lie to
/// whatever reads it — the system's own media controls now, Android
/// Auto later — and so media3's view of "is there a next item" agrees
/// with the `has_next` the card's next button is advertised on.
#[serde(default)]
pub repeat: u8,
}
fn yes() -> bool {
true
}
/// One entry of the queue window. Deliberately flat and small: this crosses
/// two hops of IPC on every update — the WebView to Rust, Rust to Kotlin —
/// so it carries what a media3 `MediaItem` is built from and nothing else.
/// `art_key` is the same kind of key `NowPlaying::art_key` is, the cover's
/// path, so whatever decodes a bitmap for the card can key the window's
/// covers off the same thing; no bytes ride along here.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct QueueItem {
/// The track's own id as a string — a library track's is a number, a
/// catalog track's is `cat:<n>`, a playlist row's is `pl<n>`. NOT a
/// unique key: one queue may hold the same track twice, and a window
/// entry is identified by where it sits, not by this.
pub id: Option<String>,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub duration: Option<f64>,
pub art_key: Option<String>,
}
#[cfg(mobile)]
pub struct MediaSession<R: Runtime>(tauri::plugin::PluginHandle<R>);
#[tauri::command]
fn update<R: Runtime>(app: tauri::AppHandle<R>, state: NowPlaying) -> Result<(), String> {
#[cfg(mobile)]
{
use tauri::Manager;
let session = app.state::<MediaSession<R>>();
session
.0
.run_mobile_plugin::<serde_json::Value>("update", state)
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = (app, state);
Err("the media session is a phone feature".into())
}
}
#[tauri::command]
fn clear<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(), String> {
#[cfg(mobile)]
{
use tauri::Manager;
let session = app.state::<MediaSession<R>>();
session
.0
.run_mobile_plugin::<serde_json::Value>("clear", ())
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = app;
Err("the media session is a phone feature".into())
}
}
/// What the page sends when a row the CAR handed it could not be played:
/// the resolver found nothing, or the network is signed out. The session
/// turns it into an error state the head unit can show, with the reason,
/// instead of the silence a driver was getting.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct RemoteFailure {
pub message: String,
}
#[tauri::command]
fn remote_failed<R: Runtime>(app: tauri::AppHandle<R>, failure: RemoteFailure) -> Result<(), String> {
#[cfg(mobile)]
{
use tauri::Manager;
let session = app.state::<MediaSession<R>>();
session
.0
.run_mobile_plugin::<serde_json::Value>("remoteFailed", failure)
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = (app, failure);
Err("the media session is a phone feature".into())
}
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("media-session")
.invoke_handler(tauri::generate_handler![update, clear, remote_failed])
.setup(|app, api| {
#[cfg(target_os = "android")]
{
use tauri::Manager;
let handle =
api.register_android_plugin("com.soulstream.mediasession", "MediaSessionPlugin")?;
app.manage(MediaSession(handle));
}
#[cfg(not(target_os = "android"))]
{
let _ = (app, api);
}
Ok(())
})
.build()
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "tauri-plugin-share"
version = "0.1.0"
description = "SoulStream on the phone's share sheet: a link or a tracklist sent from another app lands in the playlist import"
edition = "2021"
rust-version = "1.77.2"
# required by tauri-plugin's build script; must equal the crate name
links = "tauri-plugin-share"
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[build-dependencies]
tauri-plugin = { version = "2", features = ["build"] }
@@ -0,0 +1,2 @@
/build
/.tauri
@@ -0,0 +1,30 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.soulstream.share"
compileSdk = 36
defaultConfig {
minSdk = 26
// the plugin's own R8 keep rules, handed to whatever app links this
// module — inert unless that app minifies, which only the release
// build type does
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation(project(":tauri-android"))
}
@@ -0,0 +1,17 @@
# Consumer R8/ProGuard rules for the share plugin. Inert unless the consuming
# app minifies, and only the `release` build type does, so the debug APK the
# deploy pipeline builds is untouched by this file.
#
# SharePlugin is found by the string "com/soulstream/share/SharePlugin"
# (register_android_plugin → FindClass), constructed through
# "(Landroid/app/Activity;)V", and its one @Command method — pendingShare —
# is dispatched by NAME through PluginHandle's commands map. It takes no
# arguments, so there is no @InvokeArg class here. Rename it and text shared
# to SoulStream from another app arrives nowhere: the activity still runs and
# still brings the app to the front, and the WebView is never handed what it
# carried.
#
# ShareActivity is declared in this module's AndroidManifest.xml, so AGP's
# own merged-manifest keep rules hold it by name. Not repeated here: one
# place decides it.
-keep class com.soulstream.share.SharePlugin { *; }
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- SoulStream on the share sheet: plain text — a playlist link, a
pasted tracklist — from any app. The activity keeps the text
for the WebView (pending_share), brings the app to the front and
is gone; it never shows a screen of its own. -->
<activity
android:name="com.soulstream.share.ShareActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,27 @@
package com.soulstream.share
import android.app.Activity
import android.content.Intent
import android.os.Bundle
/**
* The share sheet's target. Takes the text (Spotify sends the link inside a
* sentence; the WebView finds the URL in it), keeps it for the plugin,
* brings the app's own task to the front through its launcher intent — the
* running one if there is one, so the page and its player are untouched —
* and finishes without ever drawing.
*/
class ShareActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val text = intent?.getStringExtra(Intent.EXTRA_TEXT)
?: intent?.getStringExtra(Intent.EXTRA_SUBJECT)
if (!text.isNullOrBlank()) SharePlugin.pending = text
val launch = packageManager.getLaunchIntentForPackage(packageName)
if (launch != null) {
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(launch)
}
finish()
}
}
@@ -0,0 +1,32 @@
package com.soulstream.share
import android.app.Activity
import app.tauri.annotation.Command
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
/**
* What another app shared to SoulStream, held until the WebView asks.
* ShareActivity puts it here (the two live in one process, so a static
* holds across the hand-off, a cold start included); `pendingShare` gives
* it out once — the second ask answers nothing.
*/
@TauriPlugin
class SharePlugin(private val activity: Activity) : Plugin(activity) {
companion object {
@Volatile
var pending: String? = null
}
@Command
fun pendingShare(invoke: Invoke) {
val out = JSObject()
val text = pending
pending = null
if (text != null) out.put("text", text)
invoke.resolve(out)
}
}
+13
View File
@@ -0,0 +1,13 @@
// The one command, as the Rust function is named; the ACL slugs it
// (`allow-pending-share`) into permissions/autogenerated.
const COMMANDS: &[&str] = &["pending_share"];
fn main() {
// Registers the command permission and, on Android, hands the Gradle
// module under android/ to tauri-build (only with
// TAURI_ANDROID_PROJECT_PATH set, see ANDROID.md). The module carries
// the ShareActivity and its intent filter, merged into the app's manifest.
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.build();
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-pending-share"
description = "Enables the pending_share command without any pre-configured scope."
commands.allow = ["pending_share"]
[[permission]]
identifier = "deny-pending-share"
description = "Denies the pending_share command without any pre-configured scope."
commands.deny = ["pending_share"]
@@ -0,0 +1,44 @@
## Default Permission
The share sheet: what another app sent to SoulStream (a playlist link, a
pasted tracklist), read once by the import.
#### This default permission set includes the following:
- `allow-pending-share`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`share:allow-pending-share`
</td>
<td>
Enables the pending_share command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`share:deny-pending-share`
</td>
<td>
Denies the pending_share command without any pre-configured scope.
</td>
</tr>
</table>
@@ -0,0 +1,8 @@
"$schema" = "schemas/schema.json"
[default]
description = """
The share sheet: what another app sent to SoulStream (a playlist link, a
pasted tracklist), read once by the import.
"""
permissions = ["allow-pending-share"]
@@ -0,0 +1,318 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the pending_share command without any pre-configured scope.",
"type": "string",
"const": "allow-pending-share",
"markdownDescription": "Enables the pending_share command without any pre-configured scope."
},
{
"description": "Denies the pending_share command without any pre-configured scope.",
"type": "string",
"const": "deny-pending-share",
"markdownDescription": "Denies the pending_share command without any pre-configured scope."
},
{
"description": "The share sheet: what another app sent to SoulStream (a playlist link, a\npasted tracklist), read once by the import.\n\n#### This default permission set includes:\n\n- `allow-pending-share`",
"type": "string",
"const": "default",
"markdownDescription": "The share sheet: what another app sent to SoulStream (a playlist link, a\npasted tracklist), read once by the import.\n\n#### This default permission set includes:\n\n- `allow-pending-share`"
}
]
}
}
}
+61
View File
@@ -0,0 +1,61 @@
//! The share sheet, on the phone.
//!
//! Another app's Share → SoulStream (a Spotify playlist, a YouTube set, a
//! pasted tracklist) starts the plugin's `ShareActivity`, which keeps the
//! text and brings the app to the front. The WebView asks for it with
//! `pending_share` → `{ text }` — at boot, and whenever the app comes back
//! to the front — and hands it to the playlist import. Read once: the
//! second ask answers nothing. On the desktop the plugin is registered so
//! the permission set resolves, and the command answers nothing.
use serde::{Deserialize, Serialize};
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct Shared {
pub text: Option<String>,
}
#[cfg(mobile)]
pub struct Share<R: Runtime>(tauri::plugin::PluginHandle<R>);
#[tauri::command]
fn pending_share<R: Runtime>(app: tauri::AppHandle<R>) -> Result<Shared, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let share = app.state::<Share<R>>();
share
.0
.run_mobile_plugin::<Shared>("pendingShare", ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = app;
Ok(Shared::default())
}
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("share")
.invoke_handler(tauri::generate_handler![pending_share])
.setup(|app, api| {
#[cfg(target_os = "android")]
{
use tauri::Manager;
let handle = api.register_android_plugin("com.soulstream.share", "SharePlugin")?;
app.manage(Share(handle));
}
#[cfg(not(target_os = "android"))]
{
let _ = (app, api);
}
Ok(())
})
.build()
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "tauri-plugin-storage"
version = "0.1.0"
description = "SoulStream's storage on the phone: the folder picker (a Storage Access Framework tree, resolved to a path when the volume allows), and Android's all-files access — checked and requested — so the library can live outside the app's private folder"
edition = "2021"
rust-version = "1.77.2"
# required by tauri-plugin's build script; must equal the crate name
links = "tauri-plugin-storage"
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[build-dependencies]
tauri-plugin = { version = "2", features = ["build"] }
@@ -0,0 +1,2 @@
/build
/.tauri
@@ -0,0 +1,32 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.soulstream.storage"
compileSdk = 36
defaultConfig {
minSdk = 26
// the plugin's own R8 keep rules, handed to whatever app links this
// module — inert unless that app minifies, which only the release
// build type does
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
// the picker's ActivityResult
implementation("androidx.activity:activity-ktx:1.9.3")
implementation(project(":tauri-android"))
}
@@ -0,0 +1,38 @@
# Consumer R8/ProGuard rules for the storage plugin. Gradle hands a consumer
# file to whatever app links this module, and it is INERT unless that app
# minifies: src-tauri/gen/android/app/build.gradle.kts sets
# isMinifyEnabled = true for the `release` build type and false for `debug`,
# and the debug type names no proguard files at all. The deploy pipeline
# builds debug, so nothing in this file reaches it.
#
# Nothing below is called from Kotlin, which is the whole problem. Tauri
# reaches this plugin by STRING, at three layers:
#
# 1. register_android_plugin("com.soulstream.storage", "StoragePlugin") is
# turned into "com/soulstream/storage/StoragePlugin" and handed to
# FindClass; the instance is built through the JNI signature
# "(Landroid/app/Activity;)V" (tauri/src/plugin/mobile.rs).
# 2. PluginHandle.indexMethods() files every @Command under
# commands[method.name] and every @ActivityCallback under
# startActivityCallbackMethods[method.name]; invoke() looks up the name
# Rust asked for and throws "No command pickFolder found for plugin ..."
# when it is not there.
# 3. Jackson fills the @InvokeArg classes FIELD by field — PluginManager
# sets FIELD visibility to ANY — so field names weigh the same as
# method names.
#
# Six commands (pickFolder, allFilesAccess, requestAllFilesAccess, pickImage,
# pickFile, createDocument) and four activity callbacks (folderPicked,
# imagePicked, filePicked, documentCreated). createDocument is the easy one
# to miss by hand: it has no JS-facing command at all, it is called from Rust
# by playlist export.
#
# Tauri's own consumer rules already match all of this by annotation
# (-keep @TauriPlugin public class * { @Command public <methods>; ... }).
# These name it outright anyway: theirs are generic, ours are specific, the
# failure is silent and user-facing, and nobody has built a release APK of
# this app yet to find out which of us was needed. Narrow them once a release
# build has actually been smoke-tested command by command.
-keep class com.soulstream.storage.StoragePlugin { *; }
-keep class com.soulstream.storage.CreateDocumentArgs { *; }
-keep class com.soulstream.storage.PickFileArgs { *; }
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- A library folder in shared storage (the phone's Music folder): the
Rust scanner and the download pipeline work on real paths, which
under scoped storage needs all-files access. Declared here; granted
only by the user, on the system screen `request_all_files_access`
opens. Nothing is read outside the folder the user picked. -->
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<!-- reading media by path on Android 13+, and the older storage read on
Android 12 and below -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
</manifest>
@@ -0,0 +1,394 @@
package com.soulstream.storage
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.provider.Settings
import androidx.activity.result.ActivityResult
import app.tauri.annotation.ActivityCallback
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
/** What `createDocument` writes and where it offers to put it — the Rust
* `CreateDocumentArgs`, camelCase. */
@InvokeArg
class CreateDocumentArgs {
/** the name the sheet opens with, its extension included */
var name: String? = null
/** the type Android files it under, and picks the sheet's apps by */
var mime: String? = null
/** the whole file, built in Rust — the WebView never sees it */
var text: String? = null
}
/** What a pick may offer and what it may copy. The filter is the document
* sheet's own, comma-separated ("audio/mpeg,audio/flac"); blank offers
* every type. `maxCopyBytes` is how big a document may be COPIED into the
* app's cache when it has no path of its own on this phone — 0 refuses the
* copy, which is what an audio-sized pick wants. Both are plain scalars on
* purpose: String / Int / Boolean is what the other plugins' args prove. */
@InvokeArg
class PickFileArgs {
var mimeTypes: String? = null
var maxCopyBytes: Int = 0
}
/** The copy walked past what the caller allowed. Its own exception, so the
* partial file can be deleted and the caller told in megabytes rather than
* in "could not be read". */
private class TooBig(message: String) : Exception(message)
/**
* The phone's storage for the library: a folder picked through Android's
* document tree picker (its content URI kept with a persistable grant, and
* resolved to a real path when the tree is on a volume that has one), and
* all-files access — the permission that lets the Rust side read and write
* that path under scoped storage — checked, and requested on the system
* screen that grants it.
*/
@TauriPlugin
class StoragePlugin(private val activity: Activity) : Plugin(activity) {
@Command
fun pickFolder(invoke: Invoke) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
)
startActivityForResult(invoke, intent, "folderPicked")
}
@ActivityCallback
fun folderPicked(invoke: Invoke, result: ActivityResult) {
when (result.resultCode) {
Activity.RESULT_OK -> {
val uri = result.data?.data
if (uri == null) { invoke.reject("no folder came back"); return }
try {
activity.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} catch (_: Exception) { /* the grant is a courtesy; the path is what counts */ }
val out = JSObject()
out.put("uri", uri.toString())
out.put("path", pathOf(uri))
invoke.resolve(out)
}
Activity.RESULT_CANCELED -> invoke.reject("cancelled")
else -> invoke.reject("the picker failed")
}
}
/** "primary:Music/Flac" → /storage/emulated/0/Music/Flac; an SD card's
* "1234-ABCD:Music" → /storage/1234-ABCD/Music; anything else (a cloud
* provider's tree) has no path. */
private fun pathOf(uri: Uri): String? {
val id = try { DocumentsContract.getTreeDocumentId(uri) } catch (_: Exception) { return null }
val i = id.indexOf(':')
if (i < 0) return null
val volume = id.substring(0, i)
val rel = id.substring(i + 1).trimEnd('/')
if (uri.authority != "com.android.externalstorage.documents") return null
val root = if (volume == "primary") Environment.getExternalStorageDirectory().absolutePath else "/storage/$volume"
return if (rel.isEmpty()) root else "$root/$rel"
}
@Command
fun allFilesAccess(invoke: Invoke) {
val out = JSObject()
out.put("granted", Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && Environment.isExternalStorageManager())
invoke.resolve(out)
}
@Command
fun requestAllFilesAccess(invoke: Invoke) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val intent = Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:" + activity.packageName)
)
activity.startActivity(intent)
}
invoke.resolve()
}
/** A file written wherever the user points it: Android's create-document
* sheet, and the bytes through the ContentResolver — a content URI is
* not a path, so `std::fs` on the Rust side could never have written
* this one. The file arrives whole (the playlist export builds it with
* the desktop's own writers), so nothing here knows a format. */
@Command
fun createDocument(invoke: Invoke) {
val args = invoke.parseArgs(CreateDocumentArgs::class.java)
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = args.mime ?: "application/octet-stream"
intent.putExtra(Intent.EXTRA_TITLE, args.name ?: "playlist")
startActivityForResult(invoke, intent, "documentCreated")
}
@ActivityCallback
fun documentCreated(invoke: Invoke, result: ActivityResult) {
when (result.resultCode) {
Activity.RESULT_OK -> {
val uri = result.data?.data
if (uri == null) { invoke.reject("no file came back"); return }
try {
/* The file comes off the SAME invoke, read a second time
rather than parked in a field of ours: PluginHandle's
activity-result closure holds the Invoke, so its
arguments are still here when the sheet comes back, and
no call of this can hand its bytes to another's file. */
val text = invoke.parseArgs(CreateDocumentArgs::class.java).text ?: ""
/* "wt" truncates: without it, writing a short file over a
longer one leaves the old tail behind the new bytes. */
val out = activity.contentResolver.openOutputStream(uri, "wt")
if (out == null) { invoke.reject("the file could not be opened for writing"); return }
out.use { o -> o.write(text.toByteArray(Charsets.UTF_8)) }
val res = JSObject()
res.put("uri", uri.toString())
res.put("name", displayName(uri))
invoke.resolve(res)
} catch (e: Exception) {
invoke.reject("the file could not be written: " + e.message)
}
}
/* Deliberately NOT a rejection, where pickFolder and pickImage
both reject "cancelled": a dismissed save is `Ok(None)` on the
desktop and the export says nothing at all for it. An error
here would paint a failure over a choice. */
Activity.RESULT_CANCELED -> invoke.resolve(JSObject())
else -> invoke.reject("the file could not be created")
}
}
/** The name the document actually got — the sheet lets it be changed on
* the way past. The URI's last segment when the provider will not say. */
private fun displayName(uri: Uri): String? {
try {
activity.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c ->
val i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (i >= 0 && c.moveToFirst()) {
val n = c.getString(i)
if (!n.isNullOrEmpty()) return n
}
}
} catch (_: Exception) { /* the name is a courtesy; the bytes are written */ }
return uri.lastPathSegment
}
/** A photo from the phone: the document picker limited to images, the
* picked content copied into the app's cache so the Rust side gets a
* plain path (a content URI is not one). */
@Command
fun pickImage(invoke: Invoke) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "image/*"
startActivityForResult(invoke, intent, "imagePicked")
}
@ActivityCallback
fun imagePicked(invoke: Invoke, result: ActivityResult) {
when (result.resultCode) {
Activity.RESULT_OK -> {
val uri = result.data?.data
if (uri == null) { invoke.reject("no image came back"); return }
try {
val target = java.io.File(activity.cacheDir, "picked-cover-" + System.currentTimeMillis() + ".img")
val input = activity.contentResolver.openInputStream(uri)
if (input == null) { invoke.reject("the image could not be read"); return }
input.use { i -> target.outputStream().use { o -> i.copyTo(o) } }
val out = JSObject()
out.put("path", target.absolutePath)
invoke.resolve(out)
} catch (e: Exception) {
invoke.reject("the image could not be read: " + e.message)
}
}
Activity.RESULT_CANCELED -> invoke.reject("cancelled")
else -> invoke.reject("the picker failed")
}
}
/** A FILE from the phone: the document picker with the caller's own
* filter. A document that is already on this phone's storage is handed
* over as it lies — nothing is copied, so a 300 MB pick costs nothing
* — and one that has no path of its own is copied into cacheDir/picked
* under its own display name, as far as `maxCopyBytes` allows and no
* further. Zero refuses the copy outright.
*
* The copies are swept at the start of every pick: pickImage's own
* copies live in the cache for ever, and an audio file is not an
* image. So a path handed out by one pick is gone by the next — read
* it in the same breath.
*/
@Command
fun pickFile(invoke: Invoke) {
val args = invoke.parseArgs(PickFileArgs::class.java)
sweepPicked()
val types = (args.mimeTypes ?: "").split(',').map { it.trim() }.filter { it.isNotEmpty() }
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
if (types.size == 1) {
intent.type = types[0]
} else {
intent.type = "*/*"
if (types.isNotEmpty()) intent.putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray())
}
startActivityForResult(invoke, intent, "filePicked")
}
@ActivityCallback
fun filePicked(invoke: Invoke, result: ActivityResult) {
when (result.resultCode) {
Activity.RESULT_OK -> {
val uri = result.data?.data
if (uri == null) { invoke.reject("no file came back"); return }
val info = openableInfo(uri)
val name = info.first ?: "picked"
val stated = info.second
val out = JSObject()
out.put("name", name)
/* the document's own path first, and nothing copied: a row
naming a file beside it still resolves against the real
folder, and the size stops mattering */
val here = documentPath(uri)
if (here != null) {
out.put("path", here)
out.put("copied", false)
if (stated != null) out.put("size", stated)
invoke.resolve(out)
return
}
/* the same Invoke the pick was made with — PluginHandle
holds it in the result closure — so the cap is read back
off the caller's own arguments rather than stashed in a
field a recreated activity would have reset to zero */
val cap = invoke.parseArgs(PickFileArgs::class.java).maxCopyBytes.toLong()
if (cap <= 0L) { invoke.reject("that file is not on this phone's storage"); return }
if (stated != null && stated > cap) {
invoke.reject("that file is " + (stated / (1024 * 1024)) + " MB — too large to copy here")
return
}
try {
val copy = copyInto(uri, name, cap)
if (copy == null) { invoke.reject("that file could not be read"); return }
out.put("path", copy.absolutePath)
out.put("copied", true)
out.put("size", copy.length())
invoke.resolve(out)
} catch (e: TooBig) {
invoke.reject(e.message ?: "that file is too large to copy here")
} catch (e: Exception) {
invoke.reject("that file could not be read: " + e.message)
}
}
Activity.RESULT_CANCELED -> invoke.reject("cancelled")
else -> invoke.reject("the picker failed")
}
}
/** DISPLAY_NAME and SIZE off the provider. The name is load-bearing:
* the Rust parser picks the format off the extension and titles the
* draft from the stem, so a copy under an invented name would read
* every playlist as plain text. SIZE is often absent, and absent is
* not zero — the copy counts for itself. */
private fun openableInfo(uri: Uri): Pair<String?, Long?> {
var name: String? = null
var size: Long? = null
try {
activity.contentResolver.query(uri, null, null, null, null)?.use { c ->
if (c.moveToFirst()) {
val ni = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (ni >= 0 && !c.isNull(ni)) name = c.getString(ni)
val si = c.getColumnIndex(OpenableColumns.SIZE)
if (si >= 0 && !c.isNull(si)) size = c.getLong(si)
}
}
} catch (_: Exception) { /* a provider that answers nothing still has a document */ }
return Pair(name, size)
}
/** The document's own path, when this phone has one for it: the
* Downloads provider's "raw:/storage/…", and the external-storage
* provider's "primary:Music/set.m3u" — the DOCUMENT sibling of the
* tree id pathOf resolves. Null when it names no path, and null when
* this app may not read the one it names (all-files access is not
* always granted, and a path Rust cannot open is worse than none). */
private fun documentPath(uri: Uri): String? {
val id = try { DocumentsContract.getDocumentId(uri) } catch (_: Exception) { return null }
val path = if (id.startsWith("raw:")) {
id.substring(4)
} else if (uri.authority == "com.android.externalstorage.documents") {
val i = id.indexOf(':')
if (i < 0) return null
val volume = id.substring(0, i)
val rel = id.substring(i + 1).trimEnd('/')
val root = if (volume == "primary") Environment.getExternalStorageDirectory().absolutePath else "/storage/$volume"
if (rel.isEmpty()) root else "$root/$rel"
} else {
return null
}
return if (java.io.File(path).canRead()) path else null
}
private fun pickedDir(): java.io.File {
val dir = java.io.File(activity.cacheDir, "picked")
if (!dir.isDirectory) dir.mkdirs()
return dir
}
/** The last pick's copy, gone. A playlist is kilobytes and a DJ set is
* not, and nothing here is worth keeping once the caller has read it. */
private fun sweepPicked() {
try { pickedDir().listFiles()?.forEach { it.delete() } } catch (_: Exception) { /* the copy is a courtesy */ }
}
/** The picked document, copied under its own name so the extension
* survives, counted as it goes so a provider that understated its size
* cannot fill the cache; a partial copy is deleted. */
private fun copyInto(uri: Uri, name: String, cap: Long): java.io.File? {
val target = java.io.File(pickedDir(), safeName(name))
val input = activity.contentResolver.openInputStream(uri) ?: return null
var total = 0L
try {
input.use { i ->
target.outputStream().use { o ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = i.read(buf)
if (n < 0) break
total += n.toLong()
if (total > cap) throw TooBig("that file is larger than " + (cap / (1024 * 1024)) + " MB")
o.write(buf, 0, n)
}
}
}
} catch (e: Exception) {
target.delete()
throw e
}
return target
}
/** A name the cache can hold: the document's own, minus anything that
* could walk out of the folder. What the CALLER parses by is the
* display name, which travels beside the path untouched. */
private fun safeName(name: String): String {
val base = name.substringAfterLast('/').substringAfterLast('\\').trim()
val clean = base.replace(Regex("[^A-Za-z0-9 ._()\\[\\]+&,'-]"), "_")
return if (clean.isEmpty() || clean == "." || clean == "..") "picked" else clean.take(120)
}
}
+13
View File
@@ -0,0 +1,13 @@
// The commands, as the Rust functions are named; the ACL slugs them
// (`allow-pick-folder`, …) into permissions/autogenerated.
const COMMANDS: &[&str] = &["pick_folder", "all_files_access", "request_all_files_access", "pick_image", "pick_file"];
fn main() {
// Registers the command permissions and, on Android, hands the Gradle
// module under android/ to tauri-build, which includes it in the app
// project (tauri.settings.gradle) — only with TAURI_ANDROID_PROJECT_PATH
// set, see ANDROID.md.
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.build();
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-all-files-access"
description = "Enables the all_files_access command without any pre-configured scope."
commands.allow = ["all_files_access"]
[[permission]]
identifier = "deny-all-files-access"
description = "Denies the all_files_access command without any pre-configured scope."
commands.deny = ["all_files_access"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-pick-file"
description = "Enables the pick_file command without any pre-configured scope."
commands.allow = ["pick_file"]
[[permission]]
identifier = "deny-pick-file"
description = "Denies the pick_file command without any pre-configured scope."
commands.deny = ["pick_file"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-pick-folder"
description = "Enables the pick_folder command without any pre-configured scope."
commands.allow = ["pick_folder"]
[[permission]]
identifier = "deny-pick-folder"
description = "Denies the pick_folder command without any pre-configured scope."
commands.deny = ["pick_folder"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-pick-image"
description = "Enables the pick_image command without any pre-configured scope."
commands.allow = ["pick_image"]
[[permission]]
identifier = "deny-pick-image"
description = "Denies the pick_image command without any pre-configured scope."
commands.deny = ["pick_image"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-request-all-files-access"
description = "Enables the request_all_files_access command without any pre-configured scope."
commands.allow = ["request_all_files_access"]
[[permission]]
identifier = "deny-request-all-files-access"
description = "Denies the request_all_files_access command without any pre-configured scope."
commands.deny = ["request_all_files_access"]
@@ -0,0 +1,155 @@
## Default Permission
The phone's storage: pick a library folder (a Storage Access Framework tree,
resolved to a path when the volume allows), pick a photo or a file (the
document picker, answered with a path — the document's own where this phone
has one, else a bounded copy in the app's cache), and Android's all-files
access — whether it is granted, and the system screen that grants it.
#### This default permission set includes the following:
- `allow-pick-folder`
- `allow-all-files-access`
- `allow-request-all-files-access`
- `allow-pick-image`
- `allow-pick-file`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`storage:allow-all-files-access`
</td>
<td>
Enables the all_files_access command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`storage:deny-all-files-access`
</td>
<td>
Denies the all_files_access command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`storage:allow-pick-file`
</td>
<td>
Enables the pick_file command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`storage:deny-pick-file`
</td>
<td>
Denies the pick_file command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`storage:allow-pick-folder`
</td>
<td>
Enables the pick_folder command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`storage:deny-pick-folder`
</td>
<td>
Denies the pick_folder command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`storage:allow-pick-image`
</td>
<td>
Enables the pick_image command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`storage:deny-pick-image`
</td>
<td>
Denies the pick_image command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`storage:allow-request-all-files-access`
</td>
<td>
Enables the request_all_files_access command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`storage:deny-request-all-files-access`
</td>
<td>
Denies the request_all_files_access command without any pre-configured scope.
</td>
</tr>
</table>
@@ -0,0 +1,11 @@
"$schema" = "schemas/schema.json"
[default]
description = """
The phone's storage: pick a library folder (a Storage Access Framework tree,
resolved to a path when the volume allows), pick a photo or a file (the
document picker, answered with a path — the document's own where this phone
has one, else a bounded copy in the app's cache), and Android's all-files
access — whether it is granted, and the system screen that grants it.
"""
permissions = ["allow-pick-folder", "allow-all-files-access", "allow-request-all-files-access", "allow-pick-image", "allow-pick-file"]
@@ -0,0 +1,366 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the all_files_access command without any pre-configured scope.",
"type": "string",
"const": "allow-all-files-access",
"markdownDescription": "Enables the all_files_access command without any pre-configured scope."
},
{
"description": "Denies the all_files_access command without any pre-configured scope.",
"type": "string",
"const": "deny-all-files-access",
"markdownDescription": "Denies the all_files_access command without any pre-configured scope."
},
{
"description": "Enables the pick_file command without any pre-configured scope.",
"type": "string",
"const": "allow-pick-file",
"markdownDescription": "Enables the pick_file command without any pre-configured scope."
},
{
"description": "Denies the pick_file command without any pre-configured scope.",
"type": "string",
"const": "deny-pick-file",
"markdownDescription": "Denies the pick_file command without any pre-configured scope."
},
{
"description": "Enables the pick_folder command without any pre-configured scope.",
"type": "string",
"const": "allow-pick-folder",
"markdownDescription": "Enables the pick_folder command without any pre-configured scope."
},
{
"description": "Denies the pick_folder command without any pre-configured scope.",
"type": "string",
"const": "deny-pick-folder",
"markdownDescription": "Denies the pick_folder command without any pre-configured scope."
},
{
"description": "Enables the pick_image command without any pre-configured scope.",
"type": "string",
"const": "allow-pick-image",
"markdownDescription": "Enables the pick_image command without any pre-configured scope."
},
{
"description": "Denies the pick_image command without any pre-configured scope.",
"type": "string",
"const": "deny-pick-image",
"markdownDescription": "Denies the pick_image command without any pre-configured scope."
},
{
"description": "Enables the request_all_files_access command without any pre-configured scope.",
"type": "string",
"const": "allow-request-all-files-access",
"markdownDescription": "Enables the request_all_files_access command without any pre-configured scope."
},
{
"description": "Denies the request_all_files_access command without any pre-configured scope.",
"type": "string",
"const": "deny-request-all-files-access",
"markdownDescription": "Denies the request_all_files_access command without any pre-configured scope."
},
{
"description": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), pick a photo or a file (the\ndocument picker, answered with a path — the document's own where this phone\nhas one, else a bounded copy in the app's cache), and Android's all-files\naccess — whether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`\n- `allow-pick-image`\n- `allow-pick-file`",
"type": "string",
"const": "default",
"markdownDescription": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), pick a photo or a file (the\ndocument picker, answered with a path — the document's own where this phone\nhas one, else a bounded copy in the app's cache), and Android's all-files\naccess — whether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`\n- `allow-pick-image`\n- `allow-pick-file`"
}
]
}
}
}
+273
View File
@@ -0,0 +1,273 @@
//! Storage on the phone.
//!
//! The library on Android lives in the app's private folder unless the user
//! points it somewhere else. Pointing it somewhere else needs two things the
//! WebView cannot do: a folder picker (Android's document tree picker, whose
//! answer is a content URI — resolved here to a real path when the volume
//! allows, which the Rust scanner and the download pipeline work on), and
//! for a folder in shared storage, Android's all-files access, which only
//! the user can grant on a system screen.
//!
//! Five commands: `pick_folder` → `{ uri, path }` (path null when the tree
//! is not on a volume with a path), `all_files_access` → `{ granted }`,
//! `request_all_files_access`, which opens the system screen, `pick_image`
//! → `{ path }` (a photo, copied into the app's cache), and `pick_file` →
//! `{ path, name, size, copied }` — the document picker with the caller's
//! own MIME filter.
//!
//! `pick_file` 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, so picking a 300 MB file costs nothing; one
//! with no path of its own is copied into `cacheDir/picked` under its own
//! display name, and only as far as `maxCopyBytes` allows — zero refuses
//! the copy outright. Those copies are swept at the start of every pick, so
//! a path from one pick is gone by the next: read it in the same breath.
//! The display name travels beside the path because it is load-bearing —
//! the playlist parser picks its format off the extension and titles the
//! draft from the stem.
//!
//! On the desktop the plugin is registered so the permission set resolves,
//! and every command answers "not here".
use serde::{Deserialize, Serialize};
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PickedFolder {
pub uri: String,
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AccessState {
pub granted: bool,
}
/// What a pick offers and what it may copy. `mime_types` is the document
/// sheet's own filter, comma-separated; empty offers every type.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PickFileArgs {
pub mime_types: Option<String>,
/// How big a document may be COPIED into the app's cache when it has no
/// path of its own on this phone. Zero refuses the copy.
pub max_copy_bytes: u32,
}
/// A picked document. `path` is the document's own when this phone has one
/// for it, else the cache copy's (`copied`); `name` is the display name the
/// provider gave it, which is what decides a playlist's format and title.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PickedFile {
pub path: Option<String>,
#[serde(default)]
pub name: String,
#[serde(default)]
pub size: Option<u64>,
#[serde(default)]
pub copied: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PickedImage {
pub path: Option<String>,
}
#[cfg(mobile)]
pub struct Storage<R: Runtime>(tauri::plugin::PluginHandle<R>);
#[tauri::command]
async fn pick_folder<R: Runtime>(app: tauri::AppHandle<R>) -> Result<PickedFolder, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<PickedFolder>("pickFolder", ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = app;
Err("the folder picker here is the desktop's dialog".into())
}
}
#[tauri::command]
fn all_files_access<R: Runtime>(app: tauri::AppHandle<R>) -> Result<AccessState, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<AccessState>("allFilesAccess", ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = app;
Ok(AccessState { granted: true })
}
}
#[tauri::command]
fn request_all_files_access<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(), String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<serde_json::Value>("requestAllFilesAccess", ())
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = app;
Ok(())
}
}
/// A FILE from the phone: the document picker with the caller's own MIME
/// filter, answered with a path the Rust side can open. A document already
/// on this phone's storage is handed over as it lies; one that has no path
/// is copied into the app's cache under its own name, and only as far as
/// `max_copy_bytes` allows — zero refuses the copy, which is what a pick
/// that could be hundreds of megabytes wants.
#[tauri::command]
async fn pick_file<R: Runtime>(
app: tauri::AppHandle<R>,
mime_types: Option<String>,
max_copy_bytes: Option<u32>,
) -> Result<PickedFile, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<PickedFile>(
"pickFile",
PickFileArgs {
mime_types,
max_copy_bytes: max_copy_bytes.unwrap_or(0),
},
)
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = (app, mime_types, max_copy_bytes);
Err("the file picker here is the desktop's dialog".into())
}
}
/// A photo from the phone: the document picker limited to images, the
/// picked content copied into the app's cache — a plain path for the Rust
/// side (a playlist's cover, for one).
#[tauri::command]
async fn pick_image<R: Runtime>(app: tauri::AppHandle<R>) -> Result<PickedImage, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<PickedImage>("pickImage", ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = app;
Err("the image picker here is the desktop's dialog".into())
}
}
/// What Android's create-document sheet answered: where the file went, and
/// the name it actually got (the sheet lets the user change it on the way
/// past). Both absent when the sheet was dismissed — a cancel is not an
/// error here, because the export it serves answers a cancel with None.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct CreatedDocument {
pub uri: Option<String>,
pub name: Option<String>,
}
/// What `createDocument` is handed, as the Kotlin `@InvokeArg` names it.
/// Borrowed: the file can be long and nothing here keeps a copy.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)] // only the phone ever sends one; the desktop has a dialog
struct CreateDocumentArgs<'a> {
name: &'a str,
mime: &'a str,
text: &'a str,
}
/// A text file written wherever the user points: Android's create-document
/// sheet (ACTION_CREATE_DOCUMENT), and the Kotlin side writing the bytes
/// through the ContentResolver. That last part is the whole reason this
/// exists — the sheet answers with a `content://` URI, and `std::fs` has
/// never been able to open one, which is why the playlist export had no
/// door on the phone at all.
///
/// Deliberately NOT a `#[tauri::command]`: the caller is Rust, so the file
/// is built where the desktop builds it and the bytes never pass through
/// the WebView. Nothing is added to the ACL, and there is no JS wrapper to
/// keep in step with.
///
/// A dismissed sheet is not an error — it answers with both fields empty.
pub fn create_document<R: Runtime>(
app: &tauri::AppHandle<R>,
name: &str,
mime: &str,
text: &str,
) -> Result<CreatedDocument, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<CreatedDocument>(
"createDocument",
CreateDocumentArgs { name, mime, text },
)
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = (app, name, mime, text);
Err("the create-document sheet is the phone's; here a save dialog picks the path".into())
}
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("storage")
.invoke_handler(tauri::generate_handler![pick_folder, all_files_access, request_all_files_access, pick_image, pick_file])
.setup(|app, api| {
#[cfg(target_os = "android")]
{
use tauri::Manager;
let handle = api.register_android_plugin("com.soulstream.storage", "StoragePlugin")?;
app.manage(Storage(handle));
}
#[cfg(not(target_os = "android"))]
{
let _ = (app, api);
}
Ok(())
})
.build()
}
@@ -0,0 +1,16 @@
[package]
name = "tauri-plugin-transfer-notice"
version = "0.1.0"
description = "SoulStream's downloads in the phone's shade: a progress notification on its own low-importance channel, carried by its own dataSync foreground service, so a Save outlives the app going to the background"
edition = "2021"
rust-version = "1.77.2"
# required by tauri-plugin's build script; must equal the crate name
links = "tauri-plugin-transfer-notice"
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[build-dependencies]
tauri-plugin = { version = "2", features = ["build"] }
@@ -0,0 +1,2 @@
/build
/.tauri
@@ -0,0 +1,32 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.soulstream.transfernotice"
compileSdk = 36
defaultConfig {
minSdk = 26
// the plugin's own R8 keep rules, handed to whatever app links this
// module — inert unless that app minifies, which only the release
// build type does
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
// NotificationCompat, ServiceCompat, ContextCompat — no media here,
// there is no session in this one
implementation("androidx.core:core-ktx:1.13.1")
implementation(project(":tauri-android"))
}
@@ -0,0 +1,22 @@
# Consumer R8/ProGuard rules for the transfer-notice plugin. Inert unless the
# consuming app minifies, and only the `release` build type does, so the
# debug APK the deploy pipeline builds is untouched by this file.
#
# TransferNoticePlugin is found by the string
# "com/soulstream/transfernotice/TransferNoticePlugin"
# (register_android_plugin → FindClass), constructed through
# "(Landroid/app/Activity;)V", and its two @Command methods — update and
# clear — are dispatched by NAME through PluginHandle's commands map.
# NoticeArgs is filled by Jackson field by field. Rename any of it and a Save
# runs to completion with no notice in the shade at all.
#
# The notice's own Cancel and Open come back as the plugin event "action",
# which the WebView subscribes to through Plugin.registerListener — declared
# on tauri's base class, which no rule of tauri's own names. See
# src-tauri/android/proguard-soulstream.pro.
#
# TransferService is declared in this module's AndroidManifest.xml, so AGP's
# own merged-manifest keep rules hold it by name. Not repeated here: one
# place decides it.
-keep class com.soulstream.transfernotice.TransferNoticePlugin { *; }
-keep class com.soulstream.transfernotice.NoticeArgs { *; }
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- the foreground service that carries the downloads while the app is
in the background; a fetch is dataSync, not playback -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- the notice is a notification; asked for at runtime on Android 13+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<!-- no intent filter: the notice's own buttons come back as
onStartCommand intents on this very service -->
<service
android:name="com.soulstream.transfernotice.TransferService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -0,0 +1,76 @@
package com.soulstream.transfernotice
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
/** What the notice says — the Rust `TransferNotice`, camelCase. */
@InvokeArg
class NoticeArgs {
/** how many files are still in flight; zero means take the notice down */
var count: Int = 0
var title: String? = null
var line: String? = null
var percent: Int = 0
/** nobody has stated a size yet, so the bar has no end to walk towards */
var indeterminate: Boolean = false
}
/**
* The bridge between the WebView and the service that owns the notice.
* `update` mirrors what Save is fetching (and starts the service the first
* time); `clear` takes the notice down. Its buttons come back as the plugin
* event `action` with `{ action }` — "cancel" or "open".
*/
@TauriPlugin
class TransferNoticePlugin(private val activity: Activity) : Plugin(activity) {
private var askedForNotifications = false
init {
TransferHub.onAction = { action ->
val payload = JSObject()
payload.put("action", action)
trigger("action", payload)
}
/* the channel exists from app start, so a user can silence the
downloads on their own before the first one ever runs */
TransferHub.ensureChannel(activity.applicationContext)
}
@Command
fun update(invoke: Invoke) {
val args = invoke.parseArgs(NoticeArgs::class.java)
ensureNotificationPermission()
TransferHub.update(activity.applicationContext, args)
invoke.resolve()
}
@Command
fun clear(invoke: Invoke) {
TransferHub.clear(activity.applicationContext)
invoke.resolve()
}
/** Android 13+: the notice is a notification and needs the permission;
* asked once, the first time something is fetched. Its own request code
* — 4711 is the media card's, and two requests that share one would
* answer each other. The service runs either way; without the
* permission it simply has nothing to show. */
private fun ensureNotificationPermission() {
if (Build.VERSION.SDK_INT < 33 || askedForNotifications) return
askedForNotifications = true
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED
) return
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4712)
}
}
@@ -0,0 +1,196 @@
package com.soulstream.transfernotice
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
/**
* What is being fetched, shared between the plugin (which hears the
* WebView) and the service (which owns the notice). The service is started
* on the first update and refreshed on every one after; a count of zero is
* a clear, since the phone's own rule is that an empty queue has no notice.
*/
object TransferHub {
const val CHANNEL = "downloads"
/** 1 is the media card's, and ids are per-package */
const val NOTIFICATION_ID = 2
const val ACTION_CANCEL = "com.soulstream.transfernotice.CANCEL"
const val ACTION_OPEN = "com.soulstream.transfernotice.OPEN"
@Volatile var onAction: ((String) -> Unit)? = null
@Volatile var state: NoticeArgs? = null
@Volatile var service: TransferService? = null
/** Its own channel, low importance and no badge: a download is not an
* alarm, and silencing it must not silence the player's card. A
* filename is not for a locked screen, so the notice is private there. */
fun ensureChannel(ctx: Context) {
if (Build.VERSION.SDK_INT < 26) return
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL) != null) return
val channel = NotificationChannel(CHANNEL, "Downloads", NotificationManager.IMPORTANCE_LOW)
channel.description = "What Save is fetching into your library"
channel.setShowBadge(false)
channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
manager.createNotificationChannel(channel)
}
fun update(ctx: Context, np: NoticeArgs) {
if (np.count <= 0) {
clear(ctx)
return
}
state = np
val running = service
if (running == null) {
/* Android 12+ refuses a foreground start from the background. A
Save is always begun from the foreground, so the first start
lands; one that cannot simply leaves the fetch without a card */
try {
ContextCompat.startForegroundService(ctx, Intent(ctx, TransferService::class.java))
} catch (e: Exception) {
android.util.Log.w("SoulStream", "transfer notice could not start: $e")
}
} else {
running.refresh()
}
}
fun clear(ctx: Context) {
state = null
val running = service
/* refresh() on a null state is the one path that takes the notice
down and stops the service; stopService is for a service that was
never here */
if (running != null) running.refresh() else ctx.stopService(Intent(ctx, TransferService::class.java))
}
fun act(action: String) {
onAction?.invoke(action)
}
}
/**
* The foreground service behind the notice: a progress notification with
* Stop all and Open on it, and — the point — the reason the process is not
* frozen while a Save runs with nothing playing. The fetches themselves
* stay where they are, on the Rust core's own connections; this only says
* what they are doing.
*/
class TransferService : Service() {
private var inForeground = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
TransferHub.ensureChannel(this)
TransferHub.service = this
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
/* Started with startForegroundService, so Android demands
startForeground() promptly WHATEVER the state — a queue that
emptied while the start was in flight included — or it kills the
whole app about ten seconds later. MediaService learned this the
same way, on 2026-09-13, and it was a Save that died. Promote
first, with the notice there is or a plain one; refresh() steps
back at once if there is nothing left to say. */
if (!inForeground) promote(build(TransferHub.state ?: NoticeArgs()))
when (intent?.action) {
TransferHub.ACTION_CANCEL -> TransferHub.act("cancel")
TransferHub.ACTION_OPEN -> {
packageManager.getLaunchIntentForPackage(packageName)?.let {
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try { startActivity(it) } catch (_: Exception) { /* no launcher activity */ }
}
TransferHub.act("open")
}
}
refresh()
return START_NOT_STICKY
}
/** Mirror TransferHub.state into the notice — or take it down. */
fun refresh() {
val np = TransferHub.state
if (np == null) {
/* the queue emptied: the notice goes, whole */
if (inForeground) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
inForeground = false
}
stopSelf()
return
}
/* promote() on an already-promoted service is just a notify */
promote(build(np))
}
private fun promote(notification: Notification) {
if (Build.VERSION.SDK_INT >= 29) {
ServiceCompat.startForeground(
this, TransferHub.NOTIFICATION_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
} else {
startForeground(TransferHub.NOTIFICATION_ID, notification)
}
inForeground = true
}
/** Android 15 caps a dataSync service at six hours a day and kills the
* app if it is still up when this returns. Take the notice down: the
* fetches are the Rust core's own and Transfers still lists them. */
override fun onTimeout(startId: Int, fgsType: Int) {
TransferHub.clear(applicationContext)
}
override fun onDestroy() {
TransferHub.service = null
super.onDestroy()
}
private fun build(np: NoticeArgs): Notification {
val open = serviceIntent(11, TransferHub.ACTION_OPEN)
val cancel = serviceIntent(12, TransferHub.ACTION_CANCEL)
return NotificationCompat.Builder(this, TransferHub.CHANNEL)
.setSmallIcon(R.drawable.ic_soulstream_down)
.setContentTitle(np.title ?: "Saving")
.setContentText(np.line ?: "")
.setProgress(100, np.percent.coerceIn(0, 100), np.indeterminate)
.setContentIntent(open)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setOngoing(true)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop all", cancel)
.addAction(android.R.drawable.ic_menu_upload, "Open", open)
.build()
}
/** The notice only exists while this service is in the foreground, so
* its own buttons are never a background service start. The request
* codes are 11 and 12 on purpose — the media plugin uses 0 in the same
* package, and two PendingIntents that filterEqual overwrite each other. */
private fun serviceIntent(code: Int, action: String): PendingIntent {
val i = Intent(this, TransferService::class.java).setAction(action)
return PendingIntent.getForegroundService(
this, code, i,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
}
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- the status-bar glyph: the falcon diving into a tray, white on transparent,
as the shade wants. Its own copy of the path on purpose —
R.drawable.ic_soulstream_note belongs to the media-session module's R
class and is not visible here — and deliberately the SAME falcon, so the
two notices read as one app.
Same kit source and same fold as the playback glyph
(branding/soulstream-logo-kit/android/adaptive/ic_launcher_monochrome.svg),
sized to clear the tray: the falcon lands x 7.04 to 16.96, y 2 to 16, and
the tray bar below it is unchanged at y 17.5 to 20. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:translateX="2.4375"
android:translateY="0.0899"
android:scaleX="0.018330"
android:scaleY="0.018330">
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M 471.889 863.531 C 470.033 861.073 467.485 856.812 466.226 854.062 C 464.398 850.070 463.857 847.418 463.544 840.920 C 463.249 834.800 463.512 831.540 464.601 827.793 C 466.399 821.609 469.407 815.555 473.409 810.066 C 476.304 806.095 476.482 805.474 476.562 799.067 C 476.630 793.639 477.162 790.964 479.217 785.732 C 483.406 775.062 489.001 766.958 499.269 756.689 C 504.437 751.520 515.049 742.428 522.967 736.383 C 530.854 730.363 540.573 722.201 544.565 718.246 C 548.557 714.290 553.986 707.799 556.630 703.821 C 566.285 689.294 570.061 677.926 570.109 663.251 C 570.167 645.247 564.288 631.978 550.406 618.778 C 543.711 612.413 537.141 607.939 527.046 602.871 C 511.185 594.907 499.224 590.830 464.632 581.594 C 429.650 572.254 407.140 565.108 384.291 556.087 C 369.696 550.324 349.615 540.740 338.462 534.213 C 320.047 523.435 302.208 509.772 290.291 497.319 C 282.487 489.163 272.391 476.452 268.043 469.309 C 260.079 456.225 252.284 438.708 251.281 431.640 C 250.907 429.006 251.062 428.044 251.860 428.044 C 252.460 428.044 257.063 430.702 262.090 433.950 C 279.955 445.495 298.689 456.033 313.781 463.025 L 321.990 466.828 L 315.175 459.916 C 306.482 451.099 297.522 440.054 290.373 429.342 C 283.846 419.562 275.156 402.180 271.326 391.240 C 268.303 382.605 266.682 375.526 264.934 363.320 C 263.614 354.107 263.278 340.053 264.377 340.053 C 264.755 340.053 271.343 346.050 279.018 353.379 C 305.154 378.337 323.767 394.790 344.545 411.305 C 357.025 421.223 379.468 437.524 391.447 445.369 C 421.469 465.031 452.755 481.034 483.387 492.398 C 493.465 496.137 514.890 502.670 542.833 510.526 C 553.736 513.591 567.823 518.023 574.138 520.375 C 589.578 526.124 605.702 534.093 616.621 541.371 C 626.525 547.973 639.041 559.257 644.889 566.856 C 650.348 573.951 654.995 583.053 657.601 591.759 C 659.560 598.303 659.892 600.978 659.957 610.795 C 660.037 622.827 659.257 627.864 655.632 638.715 C 653.007 646.577 649.606 653.442 644.525 661.136 C 642.375 664.394 640.265 667.630 639.838 668.328 C 639.410 669.026 642.266 666.701 646.183 663.161 C 658.976 651.602 669.377 635.863 674.360 620.525 C 680.887 600.431 681.418 582.436 676.073 562.442 C 672.127 547.683 662.390 530.337 650.734 517.305 C 640.568 505.936 625.125 494.022 607.956 484.301 C 589.833 474.040 573.381 467.503 530.216 453.412 C 512.773 447.718 493.356 440.996 487.066 438.473 C 474.757 433.536 464.611 428.344 455.790 422.469 C 449.119 418.025 438.217 408.277 432.454 401.601 C 422.380 389.933 413.615 372.247 410.336 356.975 C 409.168 351.535 408.804 346.280 408.840 335.400 C 408.881 323.303 409.202 319.739 410.863 312.979 C 414.453 298.361 419.429 287.287 428.148 274.505 C 434.444 265.277 446.706 252.323 456.519 244.535 C 483.937 222.774 516.119 207.844 564.045 194.653 C 579.471 190.408 592.384 187.409 620.816 181.469 C 654.191 174.497 674.652 168.373 699.416 157.943 C 728.416 145.729 764.550 125.102 787.619 107.593 C 791.507 104.642 792.482 104.208 792.476 105.431 C 792.471 106.273 791.534 108.965 790.394 111.414 C 785.297 122.364 772.485 139.362 759.953 151.803 C 750.130 161.555 738.729 170.675 725.744 179.167 C 715.779 185.684 694.819 196.809 683.162 201.768 C 680.040 203.097 677.586 204.285 677.710 204.409 C 678.135 204.834 706.608 197.159 714.223 194.567 C 728.618 189.666 742.169 183.962 756.021 176.972 C 763.653 173.121 770.077 170.148 770.294 170.366 C 771.203 171.274 761.797 182.974 752.317 192.729 C 741.008 204.364 729.933 213.624 716.943 222.304 C 698.650 234.528 680.678 243.412 657.959 251.461 C 642.229 257.034 624.511 261.607 598.311 266.858 C 587.608 269.003 574.363 271.878 568.878 273.248 C 555.300 276.639 537.612 282.530 526.395 287.397 C 507.859 295.440 490.988 307.165 482.040 318.224 C 479.729 321.080 476.651 325.830 475.199 328.779 C 473.747 331.728 471.958 337.085 471.225 340.682 C 470.121 346.096 470.058 348.298 470.860 353.458 C 472.774 365.768 478.100 375.203 489.472 386.432 C 502.963 399.753 527.921 414.250 558.969 426.799 C 566.647 429.902 581.496 435.667 591.966 439.609 C 638.284 457.049 660.959 467.018 682.072 479.225 C 693.287 485.709 708.151 495.840 717.893 503.640 C 729.475 512.914 743.275 526.023 750.422 534.542 C 762.716 549.194 773.237 567.193 778.922 583.298 C 782.840 594.397 785.607 607.564 786.657 620.110 C 787.335 628.206 787.324 633.770 786.618 641.059 C 783.472 673.507 771.319 700.824 748.857 725.937 C 745.773 729.385 740.336 734.824 736.774 738.023 C 710.587 761.547 675.649 780.088 633.589 792.780 C 625.820 795.125 606.899 800.277 591.543 804.231 C 558.928 812.627 544.532 816.705 530.869 821.417 C 510.985 828.275 497.176 835.191 487.628 843.074 C 480.759 848.745 477.646 854.394 477.088 862.205 C 476.860 865.392 476.356 868.000 475.968 868.000 C 475.580 868.000 473.745 865.989 471.889 863.531 Z M 507.752 803.633 C 514.224 802.436 520.624 799.550 524.691 795.993 C 526.496 794.414 530.427 789.316 533.427 784.662 C 536.427 780.009 539.690 775.298 540.676 774.193 L 542.471 772.184 L 540.355 772.833 C 535.533Line truncated
</group>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M5,17.5h14v2.5H5z" />
</vector>
@@ -0,0 +1,16 @@
// `update` and `clear` are ours. registerListener / removeListener are the
// mobile plugin runtime's own commands, the ones `addPluginListener`
// invokes to hear the `action` event — commands like any other to the ACL,
// and without them here the listener is refused and the notice's buttons go
// nowhere. The media-session plugin carries the same scar.
const COMMANDS: &[&str] = &["update", "clear", "registerListener", "removeListener"];
fn main() {
// Registers the command permissions (permissions/autogenerated) and, on
// Android, hands the Gradle module under android/ to tauri-build, which
// includes it in the app project (tauri.settings.gradle) — but only with
// TAURI_ANDROID_PROJECT_PATH set, see ANDROID.md.
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.build();
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-clear"
description = "Enables the clear command without any pre-configured scope."
commands.allow = ["clear"]
[[permission]]
identifier = "deny-clear"
description = "Denies the clear command without any pre-configured scope."
commands.deny = ["clear"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-registerListener"
description = "Enables the registerListener command without any pre-configured scope."
commands.allow = ["registerListener"]
[[permission]]
identifier = "deny-registerListener"
description = "Denies the registerListener command without any pre-configured scope."
commands.deny = ["registerListener"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-removeListener"
description = "Enables the removeListener command without any pre-configured scope."
commands.allow = ["removeListener"]
[[permission]]
identifier = "deny-removeListener"
description = "Denies the removeListener command without any pre-configured scope."
commands.deny = ["removeListener"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-update"
description = "Enables the update command without any pre-configured scope."
commands.allow = ["update"]
[[permission]]
identifier = "deny-update"
description = "Denies the update command without any pre-configured scope."
commands.deny = ["update"]
@@ -0,0 +1,126 @@
## Default Permission
The downloads notice on the phone: `update` mirrors what Save is fetching,
`clear` takes it down, and the two listener commands are what lets the page
hear the notice's buttons (`addPluginListener`).
#### This default permission set includes the following:
- `allow-update`
- `allow-clear`
- `allow-registerListener`
- `allow-removeListener`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`transfer-notice:allow-clear`
</td>
<td>
Enables the clear command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`transfer-notice:deny-clear`
</td>
<td>
Denies the clear command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`transfer-notice:allow-registerListener`
</td>
<td>
Enables the registerListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`transfer-notice:deny-registerListener`
</td>
<td>
Denies the registerListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`transfer-notice:allow-removeListener`
</td>
<td>
Enables the removeListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`transfer-notice:deny-removeListener`
</td>
<td>
Denies the removeListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`transfer-notice:allow-update`
</td>
<td>
Enables the update command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`transfer-notice:deny-update`
</td>
<td>
Denies the update command without any pre-configured scope.
</td>
</tr>
</table>
@@ -0,0 +1,9 @@
"$schema" = "schemas/schema.json"
[default]
description = """
The downloads notice on the phone: `update` mirrors what Save is fetching,
`clear` takes it down, and the two listener commands are what lets the page
hear the notice's buttons (`addPluginListener`).
"""
permissions = ["allow-update", "allow-clear", "allow-registerListener", "allow-removeListener"]
@@ -0,0 +1,354 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the clear command without any pre-configured scope.",
"type": "string",
"const": "allow-clear",
"markdownDescription": "Enables the clear command without any pre-configured scope."
},
{
"description": "Denies the clear command without any pre-configured scope.",
"type": "string",
"const": "deny-clear",
"markdownDescription": "Denies the clear command without any pre-configured scope."
},
{
"description": "Enables the registerListener command without any pre-configured scope.",
"type": "string",
"const": "allow-registerListener",
"markdownDescription": "Enables the registerListener command without any pre-configured scope."
},
{
"description": "Denies the registerListener command without any pre-configured scope.",
"type": "string",
"const": "deny-registerListener",
"markdownDescription": "Denies the registerListener command without any pre-configured scope."
},
{
"description": "Enables the removeListener command without any pre-configured scope.",
"type": "string",
"const": "allow-removeListener",
"markdownDescription": "Enables the removeListener command without any pre-configured scope."
},
{
"description": "Denies the removeListener command without any pre-configured scope.",
"type": "string",
"const": "deny-removeListener",
"markdownDescription": "Denies the removeListener command without any pre-configured scope."
},
{
"description": "Enables the update command without any pre-configured scope.",
"type": "string",
"const": "allow-update",
"markdownDescription": "Enables the update command without any pre-configured scope."
},
{
"description": "Denies the update command without any pre-configured scope.",
"type": "string",
"const": "deny-update",
"markdownDescription": "Denies the update command without any pre-configured scope."
},
{
"description": "The downloads notice on the phone: `update` mirrors what Save is fetching,\n`clear` takes it down, and the two listener commands are what lets the page\nhear the notice's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`",
"type": "string",
"const": "default",
"markdownDescription": "The downloads notice on the phone: `update` mirrors what Save is fetching,\n`clear` takes it down, and the two listener commands are what lets the page\nhear the notice's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`"
}
]
}
}
}
@@ -0,0 +1,110 @@
//! The downloads notice on the phone.
//!
//! The Transfers screen only exists inside the app: the desktop says what a
//! Save is doing with its title-bar ring, the phone with a badge on the nav,
//! and neither is visible once the app is in the background — which is
//! exactly when a Save is left to run on its own. So a running Save says so
//! where the phone says things, in the shade: how many files are left, the
//! one it is on, a progress bar, Stop all and Open.
//!
//! Its own channel, low importance, so it can be silenced without silencing
//! the player's card. Its own foreground service (`TransferService`,
//! `dataSync`) rather than the media one, because a download is not
//! playback: `MediaService` stops itself the moment the bar empties, and a
//! `mediaPlayback` service with no session is a policy violation. That
//! service is also what keeps the process alive for a Save with nothing
//! playing — the way the first real Save died, four tracks in.
//!
//! Two commands, both fed by the phone UI (src/mobile/xfernote.svelte.js):
//! `update` with what is still in flight, `clear` when the queue empties.
//! The notice's buttons arrive as the plugin event `action`
//! (`addPluginListener("transfer-notice", "action", …)`): `cancel` stops
//! everything still going, `open` brings the app forward on Transfers.
//!
//! On the desktop the plugin is registered too, so the permission set
//! resolves on every platform, but its commands answer "not here".
use serde::{Deserialize, Serialize};
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
};
/// What the notice says: how many files are still in flight, the one it is
/// on, and how far the burst has come. `indeterminate` is a queue whose
/// sizes no peer has stated yet — a bar with no end to walk towards.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TransferNotice {
#[serde(default)]
pub count: u32,
pub title: Option<String>,
pub line: Option<String>,
#[serde(default)]
pub percent: i32,
#[serde(default)]
pub indeterminate: bool,
}
#[cfg(mobile)]
pub struct TransferNoticePlugin<R: Runtime>(tauri::plugin::PluginHandle<R>);
#[tauri::command]
fn update<R: Runtime>(app: tauri::AppHandle<R>, state: TransferNotice) -> Result<(), String> {
#[cfg(mobile)]
{
use tauri::Manager;
let notice = app.state::<TransferNoticePlugin<R>>();
notice
.0
.run_mobile_plugin::<serde_json::Value>("update", state)
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = (app, state);
Err("the downloads notice is a phone feature".into())
}
}
#[tauri::command]
fn clear<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(), String> {
#[cfg(mobile)]
{
use tauri::Manager;
let notice = app.state::<TransferNoticePlugin<R>>();
notice
.0
.run_mobile_plugin::<serde_json::Value>("clear", ())
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = app;
Err("the downloads notice is a phone feature".into())
}
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("transfer-notice")
.invoke_handler(tauri::generate_handler![update, clear])
.setup(|app, api| {
#[cfg(target_os = "android")]
{
use tauri::Manager;
let handle = api.register_android_plugin(
"com.soulstream.transfernotice",
"TransferNoticePlugin",
)?;
app.manage(TransferNoticePlugin(handle));
}
#[cfg(not(target_os = "android"))]
{
let _ = (app, api);
}
Ok(())
})
.build()
}
+19
View File
@@ -0,0 +1,19 @@
# handoff/ — the Android port's pending work
Saved 2026-09-14 when the session ended. Nothing here is on `main`; `main` holds
only rounds that were built, walked on the device and committed.
- `rounds/` — every round's patch script and commit message, and the insertion
files they read. **Rounds 43 to 48 are written and verified but not yet
shipped**; 49 is incomplete (authoring was stopped mid-flight). The earlier
ones are kept because a patch is the record of how a round was made.
- `tools/` — the pipeline. `preflight.sh` applies a wave in order to a throwaway
copy; `chain.sh` patches and ships a list of rounds; `reset-tree.sh` puts the
tree back after a failure (a plain `git checkout --` leaves new files behind
and trips the round's own re-run guard); `compare-walks.sh` and
`baseline-mock-steps.txt` are the A/B for the unverified adaptive-wait change;
`audit-commands.cjs` lists backend commands the phone never calls.
- `notes/` — the Android Auto design in full, and the icon background comparison
mock (open `icon-mock.html` directly; it needs no server).
Start with `../HANDOFF-2026-09-14-android-auto-and-icon.md`.
+298
View File
@@ -0,0 +1,298 @@
# Android Auto — the design
_Produced 2026-09-14 by a research pass (current API research, testing research,
a scout of this repo's media stack, a scout of every icon surface) and a synthesis._
## Verdict
**Build it on media3 `MediaLibraryService`, and migrate the existing card onto it rather than standing a second service beside it. Do the icon first, in two small rounds, before touching any of it.**
Three decisions, stated plainly:
**1. media3, not `MediaBrowserServiceCompat`.** `androidx.media` — the whole library the plugin currently depends on (`crates/tauri-plugin-media-session/android/build.gradle.kts:26`, `androidx.media:media:1.7.0`) — was deprecated in 1.8.0 (2026-05-06). `MediaSessionCompat`, `MediaButtonReceiver` and the `MediaStyle` notification in `MediaService.kt` are all on it. Building Auto on `MediaBrowserServiceCompat` would be adding new code to a deprecated library, which is exactly the obsolete pattern Kyle asked to avoid. media3 1.11.0 is stable and gives the legacy `android.media.browse.MediaBrowserService` binder Auto actually binds, for free — you just declare both actions in the intent-filter.
**2. Migrate, do not wrap or double up.** Two live `MediaSession`s in one app means two cards in the shade and undefined media-button targeting; media3 posts and owns the notification itself. There is no safe "add Auto alongside" intermediate — the cut is atomic within one deploy. That means one round deliberately puts the daily-use lock-screen card at risk. **This is the real cost of Auto and it should be said out loud before starting.** The mitigations are: land the hardest sub-piece (the queue mirror) a round *early*, where it cannot break anything; make the migration its own round that ships and soaks before any car-facing code follows; and re-run by hand the one check the walk provably cannot catch — the 25-second paused-restore FGS survival test, which `memory/android-fgs-promote-first.md` records as having killed a real Save while the walks passed.
**3. The cold-start problem has a real answer, and it is smaller than it looks.** The framing "playback lives in Rust driven by a WebView, so cold start is hard" is half right. What is genuinely WebView-bound is the **queue** (`src/mobile/player.svelte.js`) and the **resolver** (`src/lib/sources.js`, 872 lines of JS). What is *not* WebView-bound — and this is the load-bearing finding — is everything else: `crates/library` is plain rusqlite with no Tauri dependency, `crates/player` is plain rodio/symphonia with no Tauri dependency, and `JNI_OnLoad` already publishes the JavaVM and Context to `ndk-context` at `System.loadLibrary` time, before any Activity exists. `object Rust`'s class initializer does nothing but load that .so.
So a service-started process with no Activity, no Tauri and no WebView can already: load the native library, open `library.db`, and drive rodio. The honest staged answer is:
- **Browse works cold from day one**, served by a new JNI entry in `libapp_lib.so` that opens the library **read-only** (`Connection::open_with_flags`, never `Library::open` — that one writes) and answers from its own small queries.
- **Playback from cold lands one stage later**, as a headless fallback queue of local file paths that exists only while no WebView is alive, over a process-singleton `Player`.
- **Catalog and Soulseek content never plays from cold**, in this plan or any realistic successor, because the resolver is JS and Soulseek needs a live session. Those items get the documented `STATE_ERROR` + `ERROR_RESOLUTION_ACTION_LABEL` + resolution intent — "open SoulStream on your phone" — which is also exactly what satisfies Google's "must work when the user isn't signed in" test requirement.
**What the user experiences in between** (after browse lands, before cold playback lands): the car shows the full library tree whether or not the app is running. If the app *is* running — which it is whenever the phone has been used recently — taps play normally. If the process is cold, a tap surfaces a one-line card telling them to open the app, and the next tap works. That is a real, defensible intermediate, not a broken one, and it is worth shipping on the way.
**Two mechanism notes that save real work.** First, the JNI symbol lives in `libapp_lib.so` (the app crate, which has the `library` and `player` deps) while the Kotlin `external fun` is declared in the *plugin* module's own package and calls `System.loadLibrary("app_lib")` itself. Names link at runtime, so the plugin crate needs no Rust dependency on `library` — the whole "plugin can't depend upward on `LibraryState`, so it needs a registration hook" problem simply evaporates. Second, resources: a library module cannot override a resource the app module already defines, so the density mipmaps must come from a **tracked res overlay copied in by `build.sh`/`dev.sh`**; but the app module defines no `mipmap-anydpi-v26` and sets no `android:roundIcon`/`appCategory`, so the adaptive icon and the manifest attributes can come from a library module. Use both, each for what it can actually do. The overlay also becomes the only sane home for `res/xml/file_paths.xml`, which Auto needs later for `content://` cover art and which currently exposes only `external-path` and `cache-path`.
Scope: Android Automotive OS, Android TV, Wear and a Play release are **separate jobs**, none of which come along for free. The template already declares `LEANBACK_LAUNCHER` with no banner — leave it dormant rather than adding a banner and making a never-tested TV entry look shippable. And note that a sideloaded SoulStream is invisible to Android Auto until developer mode + "Unknown sources" are enabled on the phone; real-world visibility needs a Play release and the car-app-quality review. That is a product decision, not an engineering one.
## Rounds
Nine rounds. Two are icon, six are Auto, one is Kyle's. Rounds 4 and 7 are honestly bigger than a sitting and are flagged as such.
---
**R1 — The launcher icon, everywhere it is static.**
*Delivers:* SoulStream's falcon replaces Tauri's logo on the launcher, in recents, on the splash and in other apps' share sheets; a real adaptive icon with a monochrome layer so Android 13+ themed icons work.
*Touches:* a new tracked `src-tauri/android/res/` overlay carrying `mipmap-anydpi-v26/{ic_launcher,ic_launcher_round}.xml`, `mipmap-*/ic_launcher.png` (kit `legacy/`), and the foreground/background/monochrome layers; a copy step added to `src-tauri/android/build.sh` (before the `cd src-tauri/gen/android && gradlew` at line ~21) **and** to `dev.sh`. The kit's own `mipmap-anydpi-v26/*.xml` references `@drawable/ic_launcher_foreground` while the PNGs ship under `mipmap-*` with no `drawable-*` directory anywhere — verified by reading both — so it will not compile as delivered and must be reconciled during the copy (point the XML at `@mipmap/`, or land the layers under `drawable-*`).
*Verified by:* the walk cannot see a launcher. Three checks instead — (a) unzip the built APK and assert `res/mipmap-anydpi-v26/ic_launcher.xml` exists and the `ic_launcher.png` hashes now match the kit, which is a pure build assertion and can be a permanent one-liner; (b) `deploy.sh` already screenshots after launch, so the recents/splash change shows up there; (c) one `adb exec-out screencap` of the launcher with themed icons off, then on — 60 seconds of Kyle's phone, and the only way adaptive masking and the monochrome layer are ever really proven.
*Depends on:* nothing. Ship first.
---
**R2 — The icon's moving parts.**
*Delivers:* a falcon notification glyph replacing the generic eighth-note; an app-icon fallback bitmap so the lock screen and (later) Auto never show a grey hole when a cover hasn't decoded; a proper Android 12+ splash instead of the platform's white flash; `android:appCategory="audio"`.
*Touches:* `ic_soulstream_note.xml` (media-session) and `ic_soulstream_down.xml` (transfer-notice) — **both**, or the two notifications stop matching; `MediaService.kt:261` `.setLargeIcon(MediaHub.art)` and `:205-208`'s `METADATA_KEY_ALBUM_ART` gain a decoded-once fallback; `values/themes.xml` in the overlay gains `Theme.App.Starting`; a small library manifest in the media-session module adds `appCategory` (the app manifest sets none, so the merger takes it).
*Verified by:* the walk already reads `dumpsys notification --noredact` (smoke.mjs:2180) — assert the card is still posted and the transport still works. The glyph itself is a screenshot: Android's shade takes the alpha channel only, so a falcon exported with colour renders as a white blob and only a pixel will show it. Splash is a screencap during the first 400 ms of a cold start.
*Depends on:* R1's overlay mechanism.
---
**R3 — The mirror grows a queue window. (No behaviour change.)**
*Delivers:* the single hardest sub-piece of the media3 migration, landed a round early where it cannot break anything.
*Touches:* `NowPlaying` in `crates/tauri-plugin-media-session/src/lib.rs:29-44` and `NowPlayingArgs` in `MediaSessionPlugin.kt:18-31` gain a bounded queue window (current ±N items: id, title, artist, album, duration, artKey) plus the current index; `src/mobile/media.svelte.js:15-43` fills them from `player.queue`/`player.qi`. `MediaService.kt` ignores the new fields entirely.
*Why it matters:* media3's `SimpleBasePlayer.getState()` wants a playlist and an index. Today Kotlin knows exactly one track. Doing this inside R4 would mean debugging a new mirror contract and a new session library at the same time. Doing it here means that by the time media3 arrives, the data it needs has already been running on the phone for a round.
*Verified by:* the existing card must be completely unchanged — the walk's media-session steps pass as-is. Add one logcat assertion that the window arrives populated and the index is right after a next/prev. Genuinely half a sitting.
*Depends on:* nothing (can even run in parallel with R1/R2).
---
**R4 — media3 migration. No Auto yet. THE BIG ONE.**
*Delivers:* the same lock-screen card, shade transport, headset buttons, audio focus and becoming-noisy pause, on a current API — and the `Player` abstraction that everything after this depends on.
*Touches:* `build.gradle.kts` swaps `androidx.media:media:1.7.0` for `androidx.media3:media3-session` + `media3-common`. A new `SoulStreamPlayer : SimpleBasePlayer` maps `getState()` from `MediaHub.state` (with R3's window) and routes `handlePlay`/`handlePause`/`handleSeek`/`handleSetMediaItem`/`handleSeekToNext|Previous` into the existing `MediaHub.act(...)` — the round trip out to the WebView via `Plugin.trigger("action", …)` is unchanged, so `media.svelte.js:57-68` keeps working untouched. `MediaService` becomes a `MediaSessionService` returning a `MediaSession`. Deleted: `buildNotification` (`:247-282`), the `MediaButtonReceiver` declaration and the `MEDIA_BUTTON` intent-filter in the plugin manifest (`:11-27`), and the whole audio-focus block (`:107-127`, `:295-308`) — media3 handles focus via `setAudioAttributes(…, handleAudioFocus = true)`. Leaving the old receiver in place alongside media3's will fight for media buttons.
*Verified by:* the walk's existing media-session steps re-pointed at the new card (`mediaSessionState()` at smoke.mjs:2211 reads `dumpsys media_session`, which media3 still populates). Then **by hand, every time**: lock screen shows title/artist/art; shade transport play/pause/next/prev; a headset button with the app backgrounded; unplugging headphones pauses; another app taking focus pauses and returning resumes; and the 25-second paused-restore check — force-stop, relaunch with a paused track, wait 25 s, confirm the pid survives and logcat has no `ForegroundServiceDidNotStartInTimeException`. That last one is not optional and the walk cannot do it: its restart step resumes inside 3.5 s, well under the ~10 s kill window, which is exactly why this class of bug shipped once already.
*Honest sizing:* 3–4 sittings, and it cannot be split — a half-migrated app has two sessions. Recommend it ships on its own and soaks for a few days of ordinary use before R5.
*Depends on:* R3.
---
**R5 — Discovery + the prober. Auto can see the app and gets a root.**
*Delivers:* Android Auto lists SoulStream and opens it to a root that answers instantly, with nothing under it yet. Plus the only instrument that can observe any of this.
*Touches:* `MediaSessionService` becomes `MediaLibraryService`; `onGetSession` returns a `MediaLibrarySession`; `onGetLibraryRoot` returns immediately and unconditionally (no DB, no I/O — the host times out otherwise). Plugin manifest: service `exported="true"` with **both** `androidx.media3.session.MediaLibraryService` and `android.media.browse.MediaBrowserService` actions, plus `<meta-data android:name="com.google.android.gms.car.application" android:resource="@xml/automotive_app_desc"/>` and a new `res/xml/automotive_app_desc.xml` in the plugin module. New `tools/auto-prober/` — its own ~200-line Kotlin Gradle project, deliberately outside `src-tauri/gen/android` so `tauri android init` cannot delete it — that binds a `MediaBrowser` with Auto-shaped root hints, walks the tree, and logs JSON under one logcat tag.
*Verified by:* `adb shell pm dump com.soulstream.app` asserts the `android.media.browse.MediaBrowserService` filter resolves and the `gms.car.application` meta-data points at a real resource — two permanent one-line assertions in the walk that catch the `TAURI_ANDROID_PROJECT_PATH` trap documented in `env.sh:18-22`, a failure this project has already lived through as a start-up `ClassNotFoundException`. Then the prober: `am force-stop`, bind, assert a root comes back within DR-3's 10 s with the app process cold.
*Before committing to this round:* run the plain `bindService`/`unbindService` check for androidx/media issue #3158 — a create/bind/destroy loop at ~30 cycles/second when Auto binds with nothing playing, reported open against 1.6.1 and 1.7.1 and unconfirmed on 1.11.0. `MediaService` registers a `BroadcastReceiver` and an `AudioFocusRequest` in `onCreate`, which is precisely the shape that bug punishes. Mitigation if it still bites: process-lifetime singleton session/player, nothing registered in `onCreate`.
*Depends on:* R4.
---
**R6 — The tree.**
*Delivers:* Recent / Albums / Artists / Playlists in the car, three levels deep, with covers.
*Touches:* a new Rust module in the app crate exporting `Java_com_soulstream_mediasession_..._nativeBrowse`, opening the DB **read-only** via `Connection::open_with_flags(SQLITE_OPEN_READ_ONLY)` with its own queries — never `Library::open`, which runs a `CREATE TABLE IF NOT EXISTS` + `PRAGMA journal_mode = WAL` write batch (`crates/library/src/lib.rs:259-262`). The path is derived from the `filesDir` Kotlin passes down plus a **read-only, never-saving** parse of `config.json` for `download_dir` (default `<filesDir>/Library`, per `config.rs:326-341`). Artists are not a Rust concept — they are derived in JS at `lib.svelte.js:55-65` — so the Rust side needs the same grouping. Cover art goes out as `content://` through the existing FileProvider, which needs a `files-path` entry added to `res/xml/file_paths.xml` via R1's overlay. Root children: read `BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT` rather than hardcoding 4; all browsable, none playable; ignore `page`/`pageSize`, since Auto does not paginate.
*Verified by:* the prober, extended into real assertions in the walk — root ≤ limit and all browsable; each tab's children load with unique stable ids; every item has a title and an art URI (MC-1); per-node `onGetChildren` latency under DR-3's 10 s, as numbers, the same way the walk already asserts preload latency. All of it with the app force-stopped, which is the whole point: with no WebView there is no CDP, so the existing walk is blind here and the prober is the only eye.
*Watch for:* a read-only connection on a WAL database needs the `-shm`; same UID so it should create one, but if the WAL holds uncheckpointed content with no writer around it can fail to open. Prove it, and make the failure return an empty-but-valid tree rather than hanging the head unit.
*Depends on:* R5.
---
**R7 — Cold-start playback. The second big one.**
*Delivers:* a tap in the car plays a local library track with the phone in a pocket and no UI running, and the album keeps going.
*Touches:* the `Player` moves to a process-wide `OnceLock<Arc<Player>>` in the app crate, taken by whoever needs it first — `playback::init` (`src-tauri/src/playback.rs:187-191`, today `Player::spawn_with_device` owned inside `PlayerState`) or the headless service. Two `Player`s in one process means two AAudio output streams, so this is the load-bearing piece. The event pump that feeds `PlaybackSnapshot` becomes the same singleton, with a subscription slot. A new `nativePlay(mediaId)` resolves local paths read-only and drives a small **headless fallback queue** — a `Vec<PathBuf>` plus an index that advances on `track-ended`. It is not "move the queue into Rust": the JS queue keeps shuffle, repeat, `alts` failover, provenance and 3-ahead preload. The fallback exists only when no WebView is alive, and the WebView takes over when it comes up — which the session-restore path already half-does, since it asks `player_state()` and follows a live audio thread.
*Verified by:* the prober — force-stop, bind, `playFromMediaId`, assert `dumpsys media_session` reaches `STATE_PLAYING` and `dumpsys media.audio_flinger` shows a track on the app's uid (both parsers already exist in the walk, smoke.mjs:2211 and :2242). The real proof is the **second** track: assert the fallback queue advances on its own. Then the handover: launch the app while headless playback is running and assert exactly one audio stream and one queue driver survive.
*Honest sizing:* 3 sittings. The handover rule is where the bugs will be.
*Depends on:* R6.
---
**R8 — Voice, errors, and the quality pass.**
*Delivers:* "play X" from Assistant/Gemini; an honest error instead of silence for anything the resolver would be needed for; the checklist items that block Play acceptance later.
*Touches:* `onSearch`/`onGetSearchResult` **and** the `mediaItem.requestMetadata.searchQuery` path in `onAddMediaItems` — Google's own samples disagree on which Auto uses, so implement both. Empty query ("play some music") → Recent. Catalog/Soulseek items → `STATE_ERROR` with a localized message routed through `src/mobile/trouble.js`'s existing `say()`, plus `ERROR_RESOLUTION_ACTION_LABEL` and a resolution intent. Content-style extras (grid for albums/artists, list for tracks), title + thumbnail on every item (MC-1), no autoplay on bind (MA-1 — already half-satisfied, since restore comes up paused), tintable white icons from the kit's monochrome SVG (VD-2).
*Verified by:* the prober for search (assert rows come back for a known query, and that a catalog item's tap lands in `STATE_ERROR` with a non-empty message rather than silence). The visual half is DHU-only.
*Depends on:* R7.
---
**R9 — DHU pass. Kyle's round, not an agent's.**
*Delivers:* the answers only pixels can give.
*How:* `sdkmanager "extras;google;auto"` → `%LOCALAPPDATA%\Android\Sdk\extras\google\auto\desktop-head-unit.exe`. On the phone: Settings → Connected devices → Connection preferences → Android Auto → About → tap Version ×10 → ⋮ → Developer settings → **Unknown sources on**, then ⋮ → Start head unit server. Then `adb forward tcp:5277 tcp:5277` and launch the DHU. Prefer the adb tunnel over `--usb` on Windows — `--usb` needs a WinUSB driver and fights adb.
*Checks:* contrast (VD-1/2/3), no animation (SA-1 — the app's animated eq-bars glyph and waveform lane must never reach a browse row or the playback card), no auto-scrolling text (ST-1), ≤2 s button response (DR-1), day/night (`daynight`), driving restrictions (`U` / `Shift+U`), rotary input (`-i rotary`), all three resolutions, and — critically — what Auto actually presents itself as in `onConnect` (log `packageName`/`uid`/signature rather than copying a years-old `allowed_media_browser_callers.xml`).
*Cannot be answered by DHU or by any agent:* real car audio routing, OEM launcher skins, wireless projection handover, and genuine Gemini/Assistant round trips. Those need an actual car, and are worth deferring to "Kyle happens to be in one."
*One caution:* `smoke.mjs` calls `adb("forward", "--remove-all")` around line 2430, which will drop the DHU tunnel mid-session. Do not run the walk and a DHU session at the same time, or fence that call.
*Add to ANDROID.md:* the exact invocation and the phone's setting path. Those taps are forgettable and the docs for them move.
## Decisions that were Kyle's
**Fork 1 — The phone icon's background: the kit's flat `#05080B`, or concept-04's navy gradient rebuilt at 108dp.**
The desktop ships concept 04 "Deep navy glow" (`src-tauri/icons/icon.svg` is byte-identical to the kit's `soulstream-04-deep-navy-glow.svg`). The kit's *adaptive* background is flat `#05080B` — the older, darker `soulstream-app-dark` ground. Taking the kit as-is gives a phone icon visibly flatter than the desktop's, side by side.
- Flat: ship it as `<color name="ic_launcher_background">#FF05080B</color>` rather than five identical flat PNGs. Zero extra work, and honestly it reads fine under a launcher mask.
- Gradient: one 108-unit SVG reproducing concept 04's `#0A2330 → #07141F → #040A10` axis plus the `#31C0CB`/0.18 radial glow, scaled 1024→108.
**Recommendation: build the gradient, but mock it first.** It is maybe 15 minutes and it is the difference between "the phone has the app's icon" and "the phone has a version of the app's icon". Kyle's own standing rule is that a look gets mocked before it gets implemented, and this qualifies — a side-by-side of both backgrounds under a circle mask, themed on and off, is cheap and is the only way to actually judge it.
One note either way: the kit's foreground sits 2 px above the geometric safe-zone line at xxxhdpi, recorded in `brand-tokens.json` as `opticalSourceOffset`. That is a deliberate optical correction for the light wing and tail tips. Do not "fix" it — it will look low, and it will diverge from every other kit export.
---
**Fork 2 — Where Auto stops.**
Three defensible stopping points, and they differ by several sittings:
- **(a) R1–R6.** Browse works always; playback works when the app process is alive, which is most of the time in practice. Cold tap → "open SoulStream on your phone", then it works. Stops after the tree.
- **(b) R1–R8.** Adds cold-start playback for the local library and the search/voice/error pass. The car works from a genuinely cold phone for music Kyle owns.
- **(c) Catalog and Soulseek in the car.** Would require the resolver (`src/lib/sources.js`, 872 lines of shared JS) and a Soulseek session to exist headlessly. Not in this plan and not in a realistic successor.
**Recommendation: commit to (b), ship (a) on the way, never promise (c).** (a) is a real intermediate rather than a broken one, and shipping it means R7's risk is taken against something already working in a car rather than against a blank. But (a) alone is not what "Android Auto support" means to a user — the car binds the service when the phone is in a pocket, which is exactly when the process is cold. Stopping at (a) permanently would mean the feature works least often precisely when it is most wanted.
Folded into this recommendation: **let R4 soak.** The media3 migration should ship on its own and get a few days of ordinary daily use before R5 lands on top of it. It is the round that risks the card Kyle uses every day, and a regression in it is much easier to see and attribute alone than underneath three rounds of car code.
---
**Fork 3 — Build the prober APK, or drive everything by hand from the DHU.**
The prober is ~200 lines of Kotlin plus its own small Gradle project under `tools/`, installed alongside the app, driven from `smoke.mjs` with the `adb` helper it already has (`tests/mobile/cdp.mjs:25`) and gated to dev builds.
- **Build it:** every structural thing Auto checks becomes an assertion — root width and flags, unique stable ids, title and art on every item, per-node latency as numbers against DR-3, and the cold-start case.
- **Skip it:** rely on the DHU and Google's Media Controller Test app, both of which are human-driven and neither of which asserts anything about the browse tree.
**Recommendation: build it — but note the decision is coupled to Fork 2.** If Kyle stops at (a), the prober's value drops a lot: the tree can be eyeballed in the DHU and the cold-start case barely matters. If Kyle goes to (b), the prober is not optional. With no WebView there is no CDP, so the walk is structurally blind to every single thing R6 and R7 deliver — the prober is the only instrument that can see the state Auto actually operates in. It is also the cheapest insurance against R7's handover bugs, which will otherwise only show up as "the car went quiet sometimes".
---
*Deliberately not offered as forks:* media3 vs `MediaBrowserServiceCompat` (the latter means writing new code into a library deprecated five months ago — settled), migrate vs second service (two sessions means two shade cards and undefined button routing — settled), and Android TV (the template's dormant `LEANBACK_LAUNCHER` costs nothing; adding the kit's banner would make a never-tested TV entry look shippable, and removing the category means editing a generated file — leave it alone).
## Risks
**What could break that works today**
**The lock-screen card, in R4.** This is the headline risk and there is no way to avoid it while getting Auto right. Media3 takes over notification posting, media-button routing and audio focus, which means the promote-first invariant gets re-litigated inside `MediaSessionService`'s own lifecycle. `memory/android-fgs-promote-first.md` records that this exact class of bug — a foreground start that does not promote in time — killed the process mid-Save on 2026-09-13, four of fourteen tracks in, **and that the walks did not catch it**: the restart step resumed in 3.5 s, well inside the ~10 s kill window. The 25-second paused-restore check is manual, every time, no exceptions.
**Two sessions is not a safe intermediate.** No incremental "add media3 alongside" exists. R4 is atomic within one deploy, or the shade shows two cards and media buttons land somewhere undefined.
**The `mediaPlayback` service's lifetime changes shape.** Today `MediaService.refresh()` calls `stopSelf()` the moment the bar empties (`:191-198`) — deliberately, which is why downloads needed their own `dataSync` service. Auto binds with `BIND_AUTO_CREATE` and **never calls `onStartCommand`**, so the current promote-first path is simply not on Auto's route into the process. Bound is not the same as foregrounded, so this is probably fine — but "probably" is not good enough for the code path that already killed the app once. Decide it deliberately in R5, do not inherit it.
**androidx/media #3158 — the bind loop.** When Auto binds a `MediaLibraryService` with no playback ongoing, the service can enter a `onCreate → onBind → onGetSession → onConnect → onUnbind → onDestroy` loop at ~30 cycles/second, building a fresh player and session each time; reporters hit crashes past 1000 registered receivers. Open, reported on 1.6.1 and 1.7.1, **unconfirmed on 1.11.0**. `MediaService.onCreate` registers a `BroadcastReceiver` and requests audio focus, which is the exact shape that gets punished. Reproduce it with a plain `bindService`/`unbindService` instrumentation test before committing to R5 — no DHU needed — and if it still bites, the mitigation is a process-lifetime singleton session/player with nothing registered in `onCreate`.
**Two `Player`s in one process, in R7.** `playback::init` creates its own `Player` via `Player::spawn_with_device` (`playback.rs:189`). A headless one alongside it means two rodio output streams and two AAudio clients. The `OnceLock<Arc<Player>>` singleton is the fix; getting it wrong is either doubled audio or a silently dead output, and the event pump has a single receiver so it must become part of the same singleton rather than a second consumer.
**The config-write hazard — the sharpest one in the plan.** `config::load` (`config.rs:306-311`) is `serde_json::from_str(&s).ok()` → `unwrap_or_default()`, with no BOM tolerance, and `save` writes unconditionally. A headless path that used the normal load/save shape and failed to parse would hand back all-defaults and, on the next save, wipe Kyle's library folder setting — the exact failure `memory/config-json-bom-silently-defaults.md` already documents, arriving from a new direction. **The headless browse path must be read-only end to end: parse `config.json` tolerantly, take `download_dir` if present, default to `<filesDir>/Library`, and never call anything that writes.**
**A second SQLite connection.** `Library::open` runs a `CREATE TABLE IF NOT EXISTS` + `PRAGMA journal_mode = WAL` batch (`crates/library/src/lib.rs:259-262`) — it is a writer, not an opener, and must not be used headlessly. `Connection::open_with_flags(SQLITE_OPEN_READ_ONLY)` is the right shape, but reading a WAL database needs the `-shm` file; same UID so it should create one, and if the WAL holds uncheckpointed content with no writer around it can still fail to open. Prove it in R6, and make failure yield an empty-but-valid tree rather than a head unit stuck on a spinner.
**The res overlay must be copied in two places.** `build.sh` and `dev.sh`. Miss `dev.sh` and dev builds silently revert to the Tauri logo — a failure with no error message that will be blamed on something else.
**`npx tauri icon` is a trap, and `src-tauri/icon-source.svg` is a loaded gun.** The CLI regenerates every platform from `default` in one pass — `icon.ico`, `.icns`, the `Square*Logo.png` set — and when `gen/` exists it writes into the gitignored tree rather than `src-tauri/icons/android/`, which is exactly why that tracked directory looks correct while the APK does not. Meanwhile `src-tauri/icon-source.svg` is still the pre-falcon green `#1ed760` waveform, tracked, with a name that invites the one command that would re-brand the app backwards. Worth deleting in passing.
**`src-tauri/icons/android/` is an attractive nuisance.** It is tracked and on-brand, so it looks like the obvious source — but its `ic_launcher_foreground.png` is full-bleed (alpha bbox = the whole 432×432 canvas) over a `#fff` background. Copied into `gen/` it produces a cropped rounded box on white: a *different* wrong icon that will look plausibly done in a file listing. Confirmed independently — the three candidate `mipmap-xxxhdpi/ic_launcher.png` files (`gen/`, `src-tauri/icons/android/`, kit) have three different md5s, and there is no `mipmap-anydpi-v26` in `gen/` at all.
**Library resources cannot override app resources.** Putting `mipmap-*/ic_launcher.png` in a plugin crate does nothing — the app module wins for the same name at the same qualifier. Only `mipmap-anydpi-v26` (which the app module does not define) survives, and only with layer names that do not collide with the app's existing `ic_launcher_foreground`. Get this wrong and you get a green build with an unchanged icon.
**Patch scripts.** `memory/heredoc-regex-patch-trap.md`: a regex inside a heredoc→template-literal patch became a `//` comment and shipped, losing every artist photo. These rounds are patch-script-shaped. Write them with the Write tool, and look at the resulting still.
---
**What is unverifiable without a car**
DHU covers more than expected: browse tree rendering, content style, contrast, day/night, driving-restriction states, rotary input, all three resolutions, the ≤2 s button response, error and sign-in cards, and a canned voice query via `mic play`. Everything structural is covered better by the prober.
Genuinely car-only: **real car audio routing** (DHU keeps audio on the phone — there is no car speaker path, no Bluetooth/USB audio, no car-side focus contention), **OEM launcher skins and quirks**, **wireless projection handover**, and **actual Gemini/Assistant round trips**. Also unverifiable anywhere before a Play release: whether the app is visible to a non-developer at all.
Which leads to the thing worth saying before the work starts rather than after: **a sideloaded SoulStream will not appear in Android Auto until developer mode and "Unknown sources" are turned on in the phone's Android Auto settings.** Until that tap happens, a completely correct implementation looks identical to a broken one. Real-world visibility needs a Play Store release and the car-app-quality review — a product decision, not an engineering one, and `tauri-plugin-storage`'s `MANAGE_EXTERNAL_STORAGE` is a Play-policy-restricted permission that would need its own answer if that ever comes up.
Two smaller unknowns worth five minutes each on the phone before R9: whether Android 16's Android Auto overflow still carries "Start head unit server" (every write-up describes Android 9–15 menus), and whether the head unit server can be started from adb at all — if it can, R9 becomes scriptable end to end; if not, it stays a manual tap before every DHU session.
## Effort
Sized in sittings of roughly twenty minutes, the unit this project actually ships in.
| Round | Sittings | Note |
|---|---|---|
| R1 launcher icon overlay | 1 | + ~1 min of Kyle's phone for the launcher screencap |
| R2 glyph, art fallback, splash | 1 | |
| R3 mirror grows a queue window | 0.5 | pure additive, zero risk |
| R4 **media3 migration** | **3–4** | cannot be split; ships alone and soaks |
| R5 discovery + prober | 2 | prober is its own Gradle project |
| R6 the tree (JNI + read-only DB) | 2 | |
| R7 **cold-start playback** | **3** | singleton player + fallback queue + handover |
| R8 search, voice, errors, quality | 2 | |
| R9 DHU pass | — | Kyle, 30–45 min, once, not an agent's round |
**Total: 14–15 sittings of agent work, plus one session of Kyle's with the DHU.**
The proportions matter more than the total:
- **The icon is about 15% of this** — two and a half sittings out of fifteen. It is low risk, the assets already exist, it is the most visible change in the whole plan, and it fixes a live shipped defect: the phone is currently wearing Tauri's logo with no adaptive icon at all. It should go first, and it should go first even if Auto is deferred indefinitely.
- **Two rounds are half the work.** R4 and R7 together are 6–7 of the 15. Everything else is genuinely sitting-sized. Any estimate that makes those two look like ordinary rounds is wrong, and splitting either one produces a worse outcome — R4 split leaves two sessions fighting, R7 split leaves a headless player racing the app's.
- **R3 is the cheapest insurance in the plan.** Half a sitting, no risk, and it removes the single hardest sub-problem from R4 by landing it a round early where the existing card simply ignores it.
- **The two open questions that could move these numbers** are both cheap to settle and both should be settled before their round rather than during it: reproduce androidx/media #3158 against 1.11.0 with a plain `bindService`/`unbindService` test before R5 (if it still bites, R5 grows by a sitting for the singleton restructure), and prove a read-only WAL open of `library.db` before R6 (if it fails, R6 needs a different data path and grows by one to two).
- **Stopping at Fork 2(a)** — browse only, no cold-start playback — lands at about 9 sittings instead of 15, and makes the prober optional, which would trim another 1–2.
Nothing here is blocked on anything outside the repo except the DHU install and two settings taps on the phone. The critical path is strictly sequential from R3 onward; R1, R2 and R3 can run in any order or in parallel.
---
## Decided — 2026-09-15
The earlier document left the car UI as forks. Kyle's direction was to use the
app's own design language and pick. These are the picks, and the mockup at
`research/auto-mockup/` is built to them (`CHOSEN` in `auto.js`).
**First, the correction that reframes all of it.** An earlier pass in this repo
mocked a car UI from imagination — a tab strip, header icons, a bottom
mini-player, a big-art playback view. None of that exists. Google's line is
flat: *"You don't draw your user interface directly on Android Auto; the
framework has two well-defined UIs that are created automatically."* The
reference frames are saved with this note. What the app actually controls is a
short list, and everything below is one of those slots.
| slot | decision | why |
|---|---|---|
| **Root tabs** | Recent · Albums · Artists · **Saved** | Playlists loses its tab: the phone holds exactly one ("Imported playlist", 3 tracks), and an empty tab is a dead quarter of the only four we get. **Saved** is the set that plays with the app shut, which is the one thing genuinely different about SoulStream in a car — and the only tab that always works. |
| **Content style** | albums **grid**, artists and tracks **list** | Not a density choice: the car fits ~3 either way. It is about what identifies the row. A record is known by its sleeve — that is the app's whole thesis — so albums get art. An artist is known by name, a track only by text, so those get lists where the type is bigger. |
| **Playback actions** | **Shuffle · Save** (2 of the 6 allowed) | Shuffle is what a driver reaches for. Save is SoulStream's core verb and the highest-value button on the screen: hear it, save it, it plays cold on the way home. Repeat is rarely touched while driving; Follow is a library action, so it belongs on a browse row, not the transport. |
| **Reach signal** | an amber dot on rows that **need the app**, plus the Save button showing a tick when already saved | The dot marks the *risky* row, not the safe one — a warning has to appear where the tap can fail. It also thins out on its own as more music is saved, which is the right direction. Group titles were rejected: on today's library they make one block of 3 and one of 415. |
| **Accent** | brand teal `#31C0CB` | The app derives an accent per cover; Auto allows exactly one pair set at build time and it cannot follow the artwork, so it has to be the constant — the falcon's teal from the desktop icon. It tints the FAB and the ring around play/pause, and nothing else. |
**Deliberately not decided here:** the queue's contents, search ranking and the
error copy, all of which want the prober in front of them rather than a guess.
### What blocks building it
Nothing in this list can ship before **round 48 (media3)**. The current card is
`MediaSessionCompat`; Auto needs a `MediaLibraryService`, and two live sessions
is not a safe intermediate. Order is unchanged: 44 → 45 → 46 → 47 → 48, soak,
then 49–52 build the table above.
+159
View File
@@ -0,0 +1,159 @@
// Generates icon-mock.html from the REAL SoulStream brand-kit sources.
// Reads only; never writes into F:/Coding/SoulStream.
import fs from 'node:fs';
import path from 'node:path';
const KIT = 'F:/Coding/SoulStream/branding/soulstream-logo-kit';
const ADP = KIT + '/android/adaptive';
const OUT = path.dirname(new URL(import.meta.url).pathname.replace(/^\//, ''));
const read = p => fs.readFileSync(p, 'utf8');
/* ---------- helpers ---------------------------------------------------- */
function attrs(tag) {
const o = {};
for (const m of tag.matchAll(/([\w:-]+)="([^"]*)"/g)) o[m[1]] = m[2];
return o;
}
function gradBlock(svg, id) {
const i = svg.indexOf('id="' + id + '"');
if (i < 0) throw new Error('gradient not found: ' + id);
const start = svg.lastIndexOf('<', i);
const isRadial = svg.slice(start, i).includes('radialGradient');
const close = isRadial ? '</radialGradient>' : '</linearGradient>';
const end = svg.indexOf(close, i) + close.length;
return { block: svg.slice(start, end), radial: isRadial };
}
function stopsOf(block) {
return [...block.matchAll(/<stop\b[^>]*\/?>/g)].map(m => {
const a = attrs(m[0]);
const s = { o: Number(a.offset), c: a['stop-color'] };
if (a['stop-opacity'] != null) s.a = Number(a['stop-opacity']);
return s;
});
}
function linOf(svg, id) {
const { block } = gradBlock(svg, id);
const a = attrs(block.slice(0, block.indexOf('>')));
return { x1: +a.x1, y1: +a.y1, x2: +a.x2, y2: +a.y2, stops: stopsOf(block) };
}
function radOf(svg, id) {
const { block } = gradBlock(svg, id);
const a = attrs(block.slice(0, block.indexOf('>')));
const gt = a.gradientTransform || '';
const tr = /translate\(\s*([-\d.]+)[ ,]+([-\d.]+)\s*\)/.exec(gt);
const ro = /rotate\(\s*([-\d.]+)\s*\)/.exec(gt);
const sc = /scale\(\s*([-\d.]+)\s*\)/.exec(gt);
if (!tr || !sc) throw new Error('unexpected gradientTransform: ' + gt);
return { cx: +tr[1], cy: +tr[2], rot: ro ? +ro[1] : 0, r: +sc[1], stops: stopsOf(block) };
}
const r6 = n => Math.round(n * 1e6) / 1e6;
function scaleLin(g, s, off) {
return { x1: r6(g.x1 * s + off), y1: r6(g.y1 * s + off),
x2: r6(g.x2 * s + off), y2: r6(g.y2 * s + off), stops: g.stops };
}
function scaleRad(g, s, off) {
return { cx: r6(g.cx * s + off), cy: r6(g.cy * s + off), rot: g.rot, r: r6(g.r * s), stops: g.stops };
}
/* ---------- read the real layers --------------------------------------- */
const fgSvg = read(ADP + '/ic_launcher_foreground.svg');
const bgSvg = read(ADP + '/ic_launcher_background.svg');
const monoSvg = read(ADP + '/ic_launcher_monochrome.svg');
const conSvg = read('F:/Coding/SoulStream/src-tauri/icons/icon.svg');
const fgTag = /<path\b[^>]*>/.exec(fgSvg)[0];
const monoTag = /<path\b[^>]*>/.exec(monoSvg)[0];
const fgA = attrs(fgTag), monoA = attrs(monoTag);
if (fgA.d !== monoA.d) console.warn('WARN: monochrome path d differs from foreground');
else console.log('ok monochrome path d is byte-identical to the foreground path');
if (fgA.transform !== monoA.transform) console.warn('WARN: transforms differ');
else console.log('ok both layers share transform:', fgA.transform);
const flat = attrs(/<rect\b[^>]*>/.exec(bgSvg)[0]).fill;
console.log('ok kit adaptive background =', flat);
// transform: translate(a b) scale(s) translate(c d)
const tm = /translate\(([-\d.]+)\s+([-\d.]+)\)\s*scale\(([-\d.]+)\)\s*translate\(([-\d.]+)\s+([-\d.]+)\)/
.exec(fgA.transform);
if (!tm) throw new Error('unexpected foreground transform');
const tParsed = [ +tm[1], +tm[2], +tm[3], +tm[4], +tm[5] ];
console.log('ok optical offset preserved verbatim: translate(' + tm[4] + ' ' + tm[5] + ')');
const fgGrad = linOf(fgSvg, 'adaptive-gradient');
const surf1024 = linOf(conSvg, 'rounded-4-surface-gradient');
const glow1024 = radOf(conSvg, 'rounded-4-glow-gradient');
console.log('ok concept 04 surface axis :', JSON.stringify(surf1024).slice(0, 120));
console.log('ok concept 04 glow : centre', glow1024.cx + ',' + glow1024.cy,
'r', glow1024.r, 'peak alpha', glow1024.stops[0].a, glow1024.stops[0].c);
const S_LAYER = 108 / 1024; // B: 1024 artwork spans the whole 108 layer
const S_MASK = 72 / 1024; // C: 1024 artwork spans only the visible 72 window
const gradL = { surf: scaleLin(surf1024, S_LAYER, 0), glow: scaleRad(glow1024, S_LAYER, 0) };
const gradM = { surf: scaleLin(surf1024, S_MASK, 18), glow: scaleRad(glow1024, S_MASK, 18) };
/* ---------- composite sampling ----------------------------------------- */
const hex2rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
const rgb2hex = c => '#' + c.map(v => Math.round(Math.max(0, Math.min(255, v)))
.toString(16).padStart(2, '0')).join('').toUpperCase();
function sampleStops(stops, t) {
t = Math.max(0, Math.min(1, t));
let a = stops[0], b = stops[stops.length - 1];
for (let i = 0; i < stops.length - 1; i++)
if (t >= stops[i].o && t <= stops[i + 1].o) { a = stops[i]; b = stops[i + 1]; break; }
const f = b.o === a.o ? 0 : (t - a.o) / (b.o - a.o);
const ca = hex2rgb(a.c), cb = hex2rgb(b.c);
const al = (a.a == null ? 1 : a.a), bl = (b.a == null ? 1 : b.a);
return { rgb: ca.map((v, i) => v + f * (cb[i] - v)), a: al + f * (bl - al) };
}
function linT(g, p) {
const vx = g.x2 - g.x1, vy = g.y2 - g.y1;
return ((p[0] - g.x1) * vx + (p[1] - g.y1) * vy) / (vx * vx + vy * vy);
}
function composite(v, p) {
if (v === 'flat') return rgb2hex(hex2rgb(flat));
const g = v === 'gradL' ? gradL : gradM;
const s = sampleStops(g.surf.stops, linT(g.surf, p)).rgb;
const d = Math.hypot(p[0] - g.glow.cx, p[1] - g.glow.cy);
const gl = sampleStops(g.glow.stops, d / g.glow.r);
return rgb2hex(s.map((v2, i) => gl.rgb[i] * gl.a + v2 * (1 - gl.a)));
}
const R = 36 / Math.SQRT2;
const POINTS = [
['visible NW corner (18, 18)', [18, 18]],
['circle NW rim (' + r6(54 - R).toFixed(1) + ', ' + r6(54 - R).toFixed(1) + ')', [54 - R, 54 - R]],
['mask centre (54, 54)', [54, 54]],
['circle SE rim (' + r6(54 + R).toFixed(1) + ', ' + r6(54 + R).toFixed(1) + ')', [54 + R, 54 + R]],
['visible SE corner (90, 90)', [90, 90]]
];
const samples = POINTS.map(([where, p]) => ({
where, v: ['flat', 'gradL', 'gradM'].map(v => composite(v, p))
}));
console.log('\n point A flat B layer C mask');
for (const s of samples) console.log(' ' + s.where.padEnd(34) + s.v.join(' '));
/* ---------- shipping raster -------------------------------------------- */
const pngPath = 'F:/Coding/SoulStream/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png';
const todayPng = 'data:image/png;base64,' + fs.readFileSync(pngPath).toString('base64');
console.log('\nok shipping raster embedded:', pngPath);
/* ---------- emit -------------------------------------------------------- */
const DATA = {
flat,
fgPath: { d: fgA.d, t: fgA.transform, tParsed },
monoPath: { d: monoA.d, t: monoA.transform },
fgGrad, gradL, gradM, samples, todayPng,
themed: {
light: { ground: '#DCE3E9', ink: '#44515E' },
dark: { ground: '#2A3138', ink: '#C6D3DF' }
}
};
const tpl = read(path.join(OUT, 'template.html'));
if (!tpl.includes('/*__DATA__*/null')) throw new Error('template marker missing');
const html = tpl.replace('/*__DATA__*/null', JSON.stringify(DATA));
const dest = path.join(OUT, 'icon-mock.html');
fs.writeFileSync(dest, html, 'utf8');
console.log('ok wrote', dest, (html.length / 1024).toFixed(1) + ' KB');
+376
View File
@@ -0,0 +1,376 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SoulStream Android icon — flat vs concept 04 gradient</title>
<style>
:root{
--ui-bg:#0e1013; --ui-panel:#161a1f; --ui-line:#252b33;
--ui-fg:#d2d9e1; --ui-dim:#828e9b; --ui-hi:#6fd3e0;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--ui-bg);color:var(--ui-fg);
font:13px/1.45 ui-sans-serif,system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
a{color:var(--ui-hi)}
.wrap{max-width:1180px;margin:0 auto;padding:18px 20px 60px}
header.top{display:flex;flex-wrap:wrap;align-items:baseline;gap:10px 16px;margin-bottom:14px}
header.top h1{font-size:15px;font-weight:650;margin:0;letter-spacing:.2px}
header.top .sub{font-size:12px;color:var(--ui-dim)}
.bar{position:sticky;top:0;z-index:20;display:flex;flex-wrap:wrap;gap:8px 18px;
align-items:center;padding:10px 12px;margin:0 0 18px;border:1px solid var(--ui-line);
border-radius:10px;background:rgba(16,19,23,.94);backdrop-filter:blur(6px)}
.grp{display:flex;align-items:center;gap:7px}
.grp>label{font-size:10.5px;text-transform:uppercase;letter-spacing:.9px;color:var(--ui-dim)}
.seg{display:flex;border:1px solid var(--ui-line);border-radius:7px;overflow:hidden}
.seg button{appearance:none;border:0;background:transparent;color:var(--ui-dim);
font:inherit;font-size:12px;padding:5px 9px;cursor:pointer;white-space:nowrap}
.seg button+button{border-left:1px solid var(--ui-line)}
.seg button:hover{color:var(--ui-fg)}
.seg button[aria-pressed="true"]{background:#232c35;color:#eaf3fa}
.chk{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-dim);cursor:pointer}
h2.sec{font-size:11px;text-transform:uppercase;letter-spacing:1.2px;color:var(--ui-dim);
margin:26px 0 9px;font-weight:600}
.band{border-radius:12px;padding:22px 18px 16px;margin-bottom:12px;position:relative;overflow:hidden}
.band .bandname{position:absolute;top:8px;left:12px;font-size:10px;letter-spacing:1.1px;
text-transform:uppercase;opacity:.55;font-weight:600}
.band.dark{background:radial-gradient(125% 105% at 18% -5%,#2e3339 0%,#191c21 52%,#0a0b0d 100%)}
.band.dark .bandname{color:#fff}
.band.light{background:radial-gradient(125% 105% at 18% -5%,#fdfaf5 0%,#ece5db 52%,#d5cdc1 100%)}
.band.light .bandname{color:#2b2722}
.band.neutral{background:#12151a;border:1px solid var(--ui-line)}
.band.neutral .bandname{color:var(--ui-dim)}
.cols{display:flex;flex-wrap:wrap;gap:26px;align-items:flex-start;justify-content:center}
.col{display:flex;flex-direction:column;align-items:center;gap:9px;min-width:104px}
.col.today{opacity:.92}
.tilebox{display:flex;align-items:center;justify-content:center;min-height:0}
.tag{font-size:12px;font-weight:700;letter-spacing:.4px;text-align:center}
.tagsub{font-size:10.5px;line-height:1.35;text-align:center;max-width:150px}
.band.dark .tag{color:#eef4fa}.band.dark .tagsub{color:#9aa6b3}
.band.light .tag{color:#221f1b}.band.light .tagsub{color:#6a635a}
.band.neutral .tag{color:#eef4fa}.band.neutral .tagsub{color:var(--ui-dim)}
.divider{width:1px;align-self:stretch;background:currentColor;opacity:.16;margin:0 2px}
.ladder{display:flex;flex-direction:column;gap:14px}
.lrow{display:flex;align-items:flex-end;gap:26px;justify-content:center}
.lcell{display:flex;flex-direction:column;align-items:center;gap:6px}
.lcell .sz{font-size:10px;color:var(--ui-dim)}
.lhead{display:flex;gap:26px;justify-content:center;margin-bottom:2px}
.lhead span{width:118px;text-align:center;font-size:11px;font-weight:700;letter-spacing:.4px}
.note{border:1px solid var(--ui-line);border-radius:10px;padding:14px 16px;background:var(--ui-panel);
font-size:12.5px;color:#b6c0cb;margin-top:14px}
.note b{color:#e6eef6;font-weight:650}
.note p{margin:0 0 9px}.note p:last-child{margin:0}
table.m{border-collapse:collapse;font:11.5px/1.3 ui-monospace,SFMono-Regular,Consolas,monospace;margin-top:4px}
table.m th,table.m td{border:1px solid var(--ui-line);padding:4px 8px;text-align:left}
table.m th{color:var(--ui-dim);font-weight:600}
.sw{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:-1px;margin-right:6px;
border:1px solid rgba(255,255,255,.18)}
.hidden{display:none}
.banner{border-left:2px solid var(--ui-hi);padding:8px 0 8px 12px;margin:0 0 14px;
font-size:12.5px;color:#b6c0cb}
.banner b{color:#e6eef6}
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<h1>SoulStream Android launcher icon — background layer decision</h1>
<span class="sub">real kit layers · true 108/72 adaptive geometry · nothing here is shipped</span>
</header>
<div class="bar">
<div class="grp"><label>Mask</label><div class="seg" id="segMask"></div></div>
<div class="grp"><label>Size</label><div class="seg" id="segSize"></div></div>
<div class="grp"><label>Mode</label><div class="seg" id="segMode"></div></div>
<label class="chk"><input type="checkbox" id="cbShadow" checked> launcher shadow</label>
<label class="chk"><input type="checkbox" id="cbSafe"> safe-zone guides</label>
</div>
<div id="stdBanner" class="banner">
<b>A</b> is the kit as delivered. <b>B</b> and <b>C</b> are the same concept-04 gradient, differing only in
how the 1024 artwork is mapped onto the 108-unit layer. Reply with a letter.
</div>
<div id="thmBanner" class="banner hidden">
<b>Themed icons discard the background layer entirely.</b> Android 13+ draws only the
<code>&lt;monochrome&gt;</code> drawable, tinted, on a wallpaper-derived system ground — so A, B and C are
pixel-identical here. The decision below is not a background decision; it is only whether the monochrome
layer exists at all.
</div>
<div id="stage"></div>
<h2 class="sec">Size ladder — same mask, same wallpaper</h2>
<div class="band neutral"><span class="bandname">Neutral ground</span><div id="ladder" class="ladder"></div></div>
<div class="note" id="notes"></div>
</div>
<script>
const D = {"flat":"#05080B","fgPath":{"d":"M 471.889 863.531 C 470.033 861.073 467.485 856.812 466.226 854.062 C 464.398 850.070 463.857 847.418 463.544 840.920 C 463.249 834.800 463.512 831.540 464.601 827.793 C 466.399 821.609 469.407 815.555 473.409 810.066 C 476.304 806.095 476.482 805.474 476.562 799.067 C 476.630 793.639 477.162 790.964 479.217 785.732 C 483.406 775.062 489.001 766.958 499.269 756.689 C 504.437 751.520 515.049 742.428 522.967 736.383 C 530.854 730.363 540.573 722.201 544.565 718.246 C 548.557 714.290 553.986 707.799 556.630 703.821 C 566.285 689.294 570.061 677.926 570.109 663.251 C 570.167 645.247 564.288 631.978 550.406 618.778 C 543.711 612.413 537.141 607.939 527.046 602.871 C 511.185 594.907 499.224 590.830 464.632 581.594 C 429.650 572.254 407.140 565.108 384.291 556.087 C 369.696 550.324 349.615 540.740 338.462 534.213 C 320.047 523.435 302.208 509.772 290.291 497.319 C 282.487 489.163 272.391 476.452 268.043 469.309 C 260.079 456.225 252.284 438.708 251.281 431.640 C 250.907 429.006 251.062 428.044 251.860 428.044 C 252.460 428.044 257.063 430.702 262.090 433.950 C 279.955 445.495 298.689 456.033 313.781 463.025 L 321.990 466.828 L 315.175 459.916 C 306.482 451.099 297.522 440.054 290.373 429.342 C 283.846 419.562 275.156 402.180 271.326 391.240 C 268.303 382.605 266.682 375.526 264.934 363.320 C 263.614 354.107 263.278 340.053 264.377 340.053 C 264.755 340.053 271.343 346.050 279.018 353.379 C 305.154 378.337 323.767 394.790 344.545 411.305 C 357.025 421.223 379.468 437.524 391.447 445.369 C 421.469 465.031 452.755 481.034 483.387 492.398 C 493.465 496.137 514.890 502.670 542.833 510.526 C 553.736 513.591 567.823 518.023 574.138 520.375 C 589.578 526.124 605.702 534.093 616.621 541.371 C 626.525 547.973 639.041 559.257 644.889 566.856 C 650.348 573.951 654.995 583.053 657.601 591.759 C 659.560 598.303 659.892 600.978 659.957 610.795 C 660.037 622.827 659.257 627.864 655.632 638.715 C 653.007 646.577 649.606 653.442 644.525 661.136 C 642.375 664.394 640.265 667.630 639.838 668.328 C 639.410 669.026 642.266 666.701 646.183 663.161 C 658.976 651.602 669.377 635.863 674.360 620.525 C 680.887 600.431 681.418 582.436 676.073 562.442 C 672.127 547.683 662.390 530.337 650.734 517.305 C 640.568 505.936 625.125 494.022 607.956 484.301 C 589.833 474.040 573.381 467.503 530.216 453.412 C 512.773 447.718 493.356 440.996 487.066 438.473 C 474.757 433.536 464.611 428.344 455.790 422.469 C 449.119 418.025 438.217 408.277 432.454 401.601 C 422.380 389.933 413.615 372.247 410.336 356.975 C 409.168 351.535 408.804 346.280 408.840 335.400 C 408.881 323.303 409.202 319.739 410.863 312.979 C 414.453 298.361 419.429 287.287 428.148 274.505 C 434.444 265.277 446.706 252.323 456.519 244.535 C 483.937 222.774 516.119 207.844 564.045 194.653 C 579.471 190.408 592.384 187.409 620.816 181.469 C 654.191 174.497 674.652 168.373 699.416 157.943 C 728.416 145.729 764.550 125.102 787.619 107.593 C 791.507 104.642 792.482 104.208 792.476 105.431 C 792.471 106.273 791.534 108.965 790.394 111.414 C 785.297 122.364 772.485 139.362 759.953 151.803 C 750.130 161.555 738.729 170.675 725.744 179.167 C 715.779 185.684 694.819 196.809 683.162 201.768 C 680.040 203.097 677.586 204.285 677.710 204.409 C 678.135 204.834 706.608 197.159 714.223 194.567 C 728.618 189.666 742.169 183.962 756.021 176.972 C 763.653 173.121 770.077 170.148 770.294 170.366 C 771.203 171.274 761.797 182.974 752.317 192.729 C 741.008 204.364 729.933 213.624 716.943 222.304 C 698.650 234.528 680.678 243.412 657.959 251.461 C 642.229 257.034 624.511 261.607 598.311 266.858 C 587.608 269.003 574.363 271.878 568.878 273.248 C 555.300 276.639 537.612 282.530 526.395 287.397 C 507.859 295.440 490.988 307.165 482.040 318.224 C 479.729 321.080 476.651 325.830 475.199 328.779 C 473.747 331.728 471.958 337.085 471.225 340.682 C 470.121 346.096 470.058 348.298 470.860 353.458 C 472.774 365.768 478.100 375.203 489.472 386.432 C 502.963 399.753 527.921 414.250 558.969 426.799 C 566.647 429.902 581.496 435.667 591.966 439.609 C 638.284 457.049 660.959 467.018 682.072 479.225 C 693.287 485.709 708.151 495.840 717.893 503.640 C 729.475 512.914 743.275 526.023 750.422 534.542 C 762.716 549.194 773.237 567.193 778.922 583.298 C 782.840 594.397 785.607 607.564 786.657 620.110 C 787.335 628.206 787.324 633.770 786.618 641.059 C 783.472 673.507 771.319 700.824 748.857 725.937 C 745.773 729.385 740.336 734.824 736.774 738.023 C 710.587 761.547 675.649 780.088 633.589 792.780 C 625.820 795.125 606.899 800.277 591.543 804.231 C 558.928 812.627 544.532 816.705 530.869 821.417 C 510.985 828.275 497.176 835.191 487.628 843.074 C 480.759 848.745 477.646 854.394 477.088 862.205 C 476.860 865.392 476.356 868.000 475.968 868.000 C 475.580 868.000 473.745 865.989 471.889 863.531 Z M 507.752 803.633 C 514.224 802.436 520.624 799.550 524.691 795.993 C 526.496 794.414 530.427 789.316 533.427 784.662 C 536.427 780.009 539.690 775.298 540.676 774.193 L 542.471 772.184 L 540.355 772.Line truncated
/* ---------- Android adaptive geometry ----------------------------------
Layers are 108x108 dp. The launcher mask shows only the central 72x72 dp,
i.e. the layer is drawn at 1.5x the on-screen icon size and centre-cropped.
Every tile below is an <svg viewBox="18 18 72 72"> over 108-unit artwork,
which reproduces exactly that. The 66 dp circle is the content safe zone.
------------------------------------------------------------------------*/
const VIEWBOX = '18 18 72 72';
/* Mask outlines in a 0..100 box, mapped into the visible 72 window. */
const MASKS = {
circle: {label:'Circle', d:'M50,0 A50,50 0 1,1 50,100 A50,50 0 1,1 50,0 Z'},
squircle: {label:'Squircle', d:'M50,0 C10.05,0 0,10.05 0,50 C0,89.95 10.05,100 50,100 C89.95,100 100,89.95 100,50 C100,10.05 89.95,0 50,0 Z'},
rsquare: {label:'Rounded square',d:'M20,0 L80,0 A20,20 0 0,1 100,20 L100,80 A20,20 0 0,1 80,100 L20,100 A20,20 0 0,1 0,80 L0,20 A20,20 0 0,1 20,0 Z'},
teardrop: {label:'Teardrop (aggressive OEM)',d:'M0,20 A20,20 0 0,1 20,0 L50,0 A50,50 0 1,1 0,50 Z'}
};
const SIZES = [
{px:24, cap:'24 dp · notification'},
{px:32, cap:'32 dp · recents chip'},
{px:48, cap:'48 dp · launcher grid'},
{px:64, cap:'64 dp · folder / tablet'},
{px:112, cap:'112 dp · inspect'}
];
const VARIANTS = [
{id:'today', tag:'TODAY', sub:'shipping raster — Tauri placeholder on a white plate', today:true},
{id:'flat', tag:'A — FLAT', sub:'kit as delivered · solid ' + D.flat},
{id:'gradL', tag:'B — GRADIENT', sub:'concept 04 mapped 1024 → the whole 108 layer'},
{id:'gradM', tag:'C — GRADIENT', sub:'concept 04 mapped 1024 → the visible 72 window'}
];
/* ---------- defs ------------------------------------------------------ */
function stopsSVG(stops){
return stops.map(s =>
'<stop offset="'+s.o+'" stop-color="'+s.c+'"'+(s.a!=null?' stop-opacity="'+s.a+'"':'')+'/>'
).join('');
}
function linSVG(id,g){
return '<linearGradient id="'+id+'" gradientUnits="userSpaceOnUse" color-interpolation="sRGB" '+
'x1="'+g.x1+'" y1="'+g.y1+'" x2="'+g.x2+'" y2="'+g.y2+'">'+stopsSVG(g.stops)+'</linearGradient>';
}
function radSVG(id,g){
return '<radialGradient id="'+id+'" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" '+
'gradientTransform="translate('+g.cx+' '+g.cy+') rotate('+g.rot+') scale('+g.r+')">'+
stopsSVG(g.stops)+'</radialGradient>';
}
function buildDefs(){
let s = '<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>';
s += linSVG('fgGrad', D.fgGrad);
s += linSVG('surfL', D.gradL.surf) + radSVG('glowL', D.gradL.glow);
s += linSVG('surfM', D.gradM.surf) + radSVG('glowM', D.gradM.glow);
for (const k in MASKS){
s += '<clipPath id="clip-'+k+'" clipPathUnits="userSpaceOnUse">'+
'<path transform="translate(18,18) scale(0.72)" d="'+MASKS[k].d+'"/></clipPath>';
}
s += '</defs></svg>';
const el = document.createElement('div'); el.innerHTML = s; document.body.appendChild(el.firstChild);
}
/* ---------- layers ---------------------------------------------------- */
const FG = '<path d="'+D.fgPath.d+'" fill="url(#fgGrad)" fill-rule="evenodd" transform="'+D.fgPath.t+'"/>';
function MONO(fillVar){
return '<path d="'+D.monoPath.d+'" fill="'+fillVar+'" fill-rule="evenodd" transform="'+D.monoPath.t+'"/>';
}
const RECT = '<rect x="0" y="0" width="108" height="108" ';
function layers(id){
switch(id){
case 'flat': return RECT+'fill="'+D.flat+'"/>' + FG;
case 'gradL': return RECT+'fill="url(#surfL)"/>' + RECT+'fill="url(#glowL)"/>' + FG;
case 'gradM': return RECT+'fill="url(#surfM)"/>' + RECT+'fill="url(#glowM)"/>' + FG;
case 'today': /* Launcher3 wraps a legacy raster: corner-extracted plate + icon at ~0.7 */
return RECT+'fill="#ffffff"/>' +
'<image href="'+D.todayPng+'" x="28.8" y="28.8" width="50.4" height="50.4" '+
'preserveAspectRatio="xMidYMid meet"/>';
case 'monoLight': return RECT+'fill="'+D.themed.light.ground+'"/>' + MONO(D.themed.light.ink);
case 'monoDark': return RECT+'fill="'+D.themed.dark.ground +'"/>' + MONO(D.themed.dark.ink);
case 'todayThemed': return layers('today');
}
return '';
}
const SAFE = '<g fill="none" stroke="#ff4d6d" stroke-opacity=".85">'+
'<circle cx="54" cy="54" r="33" stroke-width=".6" stroke-dasharray="2 2"/>'+
'<rect x="18" y="18" width="72" height="72" stroke-width=".6" stroke-opacity=".45"/></g>';
function tile(variantId, px, mask){
const sh = state.shadow
? 'filter:drop-shadow(0 '+(px/48).toFixed(2)+'px '+(px/26).toFixed(2)+'px rgba(0,0,0,.30)) '+
'drop-shadow(0 0 '+(px/40).toFixed(2)+'px rgba(0,0,0,.14));' : '';
return '<svg class="tile" width="'+px+'" height="'+px+'" viewBox="'+VIEWBOX+'" style="'+sh+'display:block">'+
'<g clip-path="url(#clip-'+mask+')">'+layers(variantId)+(state.safe?SAFE:'')+'</g></svg>';
}
/* ---------- state + render -------------------------------------------- */
const state = {mask:'circle', size:48, mode:'standard', shadow:true, safe:false};
/* the hash makes any view reproducible: #mask=teardrop&size=112&mode=themed */
(function readHash(){
for (const kv of location.hash.replace(/^#/,'').split('&')){
const [k,v] = kv.split('=');
if (k === 'mask' && MASKS[v]) state.mask = v;
if (k === 'size' && SIZES.some(s => s.px === +v)) state.size = +v;
if (k === 'mode' && (v === 'themed' || v === 'standard')) state.mode = v;
if (k === 'shadow') state.shadow = v !== '0';
if (k === 'safe') state.safe = v === '1';
}
})();
function writeHash(){
history.replaceState(null,'','#mask='+state.mask+'&size='+state.size+'&mode='+state.mode+
'&shadow='+(state.shadow?1:0)+'&safe='+(state.safe?1:0));
}
function seg(host, items, key, onpick){
host.innerHTML = items.map(i =>
'<button data-v="'+i.v+'" aria-pressed="'+(state[key]===i.v)+'">'+i.t+'</button>').join('');
host.onclick = e => {
const b = e.target.closest('button'); if(!b) return;
state[key] = items.find(i => String(i.v) === b.dataset.v).v;
[...host.children].forEach(c => c.setAttribute('aria-pressed', c === b));
onpick && onpick(); render(); writeHash();
};
}
function colHTML(v, px){
return '<div class="col'+(v.today?' today':'')+'">'+
'<div class="tilebox">'+tile(v.id, px, state.mask)+'</div>'+
'<div class="tag">'+v.tag+'</div><div class="tagsub">'+v.sub+'</div></div>';
}
function bandStandard(cls, name){
let h = '<div class="band '+cls+'"><span class="bandname">'+name+'</span><div class="cols">';
h += colHTML(VARIANTS[0], state.size);
h += '<div class="divider"></div>';
for (let i=1;i<VARIANTS.length;i++) h += colHTML(VARIANTS[i], state.size);
return h + '</div></div>';
}
function bandThemed(cls, name, which){
const t = which === 'light' ? D.themed.light : D.themed.dark;
const cols = [
{id:'todayThemed', tag:'TODAY', sub:'no monochrome layer — the launcher leaves it full-colour among themed neighbours', today:true},
{id: which==='light'?'monoLight':'monoDark', tag:'A / B / C', sub:'kit monochrome, tinted '+t.ink+' on system ground '+t.ground}
];
let h = '<div class="band '+cls+'"><span class="bandname">'+name+'</span><div class="cols">';
h += colHTML(cols[0], state.size) + '<div class="divider"></div>' + colHTML(cols[1], state.size);
return h + '</div></div>';
}
function render(){
const stage = document.getElementById('stage');
const themed = state.mode === 'themed';
document.getElementById('stdBanner').classList.toggle('hidden', themed);
document.getElementById('thmBanner').classList.toggle('hidden', !themed);
if (!themed){
stage.innerHTML = bandStandard('dark','Dark wallpaper') + bandStandard('light','Light wallpaper');
} else {
stage.innerHTML = bandThemed('light','Light theme · wallpaper-derived ground','light') +
bandThemed('dark','Dark theme · wallpaper-derived ground','dark');
}
const list = themed
? [{id:'todayThemed',tag:'TODAY'},{id:'monoLight',tag:'THEMED · LIGHT'},{id:'monoDark',tag:'THEMED · DARK'}]
: VARIANTS;
let lh = '<div class="lhead">' + list.map(v=>'<span>'+v.tag+'</span>').join('') + '</div>';
for (const s of SIZES){
lh += '<div class="lrow">' + list.map(v =>
'<div class="lcell" style="width:118px"><div class="tilebox">'+tile(v.id, s.px, state.mask)+
'</div><div class="sz">'+s.cap+'</div></div>').join('') + '</div>';
}
document.getElementById('ladder').innerHTML = lh;
}
/* ---------- measurement: how much background does the falcon leave? ---- */
function coverage(maskKey){
const N = 512, c = document.createElement('canvas');
c.width = c.height = N; const x = c.getContext('2d');
const k = N/72; // 72 visible units -> N px
// mask
x.save(); x.scale(k,k); x.translate(-18,-18);
x.translate(18,18); x.scale(0.72,0.72);
x.fill(new Path2D(MASKS[maskKey].d)); x.restore();
const maskData = x.getImageData(0,0,N,N).data;
let maskPx = 0; for (let i=3;i<maskData.length;i+=4) if (maskData[i]>127) maskPx++;
// falcon inside the mask
x.globalCompositeOperation = 'source-in';
x.save(); x.scale(k,k); x.translate(-18,-18);
const t = D.fgPath.tParsed; // [tx,ty,s,tx2,ty2]
x.translate(t[0],t[1]); x.scale(t[2],t[2]); x.translate(t[3],t[4]);
x.fill(new Path2D(D.fgPath.d),'evenodd'); x.restore();
const fgData = x.getImageData(0,0,N,N).data;
let fgPx = 0; for (let i=3;i<fgData.length;i+=4) if (fgData[i]>127) fgPx++;
// the falcon on its own, to see how much the mask shaves off
const c2 = document.createElement('canvas'); c2.width = c2.height = N;
const y = c2.getContext('2d');
y.save(); y.scale(k,k); y.translate(-18,-18);
y.translate(t[0],t[1]); y.scale(t[2],t[2]); y.translate(t[3],t[4]);
y.fill(new Path2D(D.fgPath.d),'evenodd'); y.restore();
const allData = y.getImageData(0,0,N,N).data;
let allPx = 0; for (let i=3;i<allData.length;i+=4) if (allData[i]>127) allPx++;
return {mask:maskPx, fg:fgPx, pct: 100*fgPx/maskPx, kept: 100*fgPx/allPx,
maskOfSquare: 100*maskPx/(N*N)};
}
/* ---------- notes ----------------------------------------------------- */
function notes(){
const cov = coverage(state.mask);
const rows = D.samples.map(r =>
'<tr><td>'+r.where+'</td>'+
r.v.map(v=>'<td><span class="sw" style="background:'+v+'"></span>'+v+'</td>').join('')+'</tr>').join('');
document.getElementById('notes').innerHTML =
'<p><b>Geometry.</b> Layers are 108 dp; the mask shows the central 72 dp, so every layer is drawn at '+
'1.5&times; the icon size and centre-cropped — that is what the <code>viewBox="18 18 72 72"</code> on each '+
'tile reproduces. The foreground and monochrome paths, including the kit\'s v1.1 optical offset '+
'<code>translate(-40.740741 20.370370)</code>, are copied verbatim from the kit SVGs and are '+
'<b>identical in A, B and C</b>. Only the background layer changes.</p>'+
'<p><b>Composited background colour</b> (surface + glow, under the falcon, sampled in layer units):</p>'+
'<table class="m"><tr><th>point in the visible 72 window</th><th>A flat</th><th>B layer-fit</th>'+
'<th>C mask-fit</th></tr>'+rows+'</table>'+
'<p style="margin-top:10px"><b>How much background is even visible?</b> Measured live on this page: '+
'under the '+MASKS[state.mask].label.toLowerCase()+' mask the falcon covers only <b>'+
cov.pct.toFixed(1)+'%</b> of the masked area, so the background owns the other '+
(100-cov.pct).toFixed(1)+'%. That mask keeps '+cov.maskOfSquare.toFixed(1)+'% of the 72 dp square and '+
cov.kept.toFixed(1)+'% of the falcon (the falcon\'s painted box is 46.6 &times; 65.8 of the 72 window, '+
'so even a circle shaves only the extreme tips).</p>'+
'<p><b>B vs C.</b> B maps the 1024 artwork onto the whole 108 layer, so the visible 72 window sees only '+
'the middle two-thirds of the gradient. C maps it onto the visible 72 window, so the masked result matches '+
'the desktop tile corner for corner — but C\'s outer 18 dp of bleed is flat padding, which is what a '+
'launcher reveals when it parallaxes the layers on a swipe. Under a circle the two are almost the same '+
'(#0F3643 vs #0E3442 at the NW rim); they separate only where a mask keeps the corners.</p>'+
'<p><b>Honesty notes.</b> 1 dp = 1 CSS px here; on a Pixel-class screen a 48 dp icon is ~132 physical '+
'pixels but only ~7.6 mm across. Mask outlines are the standard circle / squircle / rounded-square / '+
'teardrop families — OEM paths vary slightly. The launcher shadow is a Launcher3-style ambient+key pair, '+
'approximated in CSS, applied identically to every tile. Themed-icon ground and ink are representative '+
'Material You tones; the real values are derived from the user\'s wallpaper. The TODAY column is the '+
'actual shipping <code>mipmap-xxxhdpi/ic_launcher.png</code> with Launcher3\'s legacy plating '+
'(corner-extracted plate, icon at 0.7) applied.</p>';
}
/* ---------- boot ------------------------------------------------------ */
buildDefs();
seg(document.getElementById('segMask'),
Object.keys(MASKS).map(k=>({v:k,t:MASKS[k].label})), 'mask', notes);
seg(document.getElementById('segSize'),
SIZES.map(s=>({v:s.px,t:s.px+' dp'})), 'size');
seg(document.getElementById('segMode'),
[{v:'standard',t:'Standard'},{v:'themed',t:'Themed (13+)'}], 'mode');
document.getElementById('cbShadow').checked = state.shadow;
document.getElementById('cbSafe').checked = state.safe;
document.getElementById('cbShadow').onchange = e => { state.shadow = e.target.checked; render(); writeHash(); };
document.getElementById('cbSafe').onchange = e => { state.safe = e.target.checked; render(); writeHash(); };
render(); notes(); writeHash();
</script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
android: playlists, the import doors, the share sheet, You → Sources — and three bugs the walks found
The desktop's two playlist pages on the phone: the shelf (pinned first,
mosaic covers, badges, New playlist, Import) and the page (kind, Pinned
chip, description, the meta line's warnings, Play / Shuffle / more, a
filter, rows tagged unresolved / set audio / file missing). Sheets for a
card, the page's overflow, a row, pick-a-playlist, a prompt and a confirm;
Add to playlist on every track and record sheet; a removal undoable for
seven seconds from the neutral toast (notice.svelte.js); stream rows play
through the resolver as path-less entries, a set slice starts and ends at
its cues; Add to library / Download all tracks with the desktop's bulk
rules; nav.replace for a review that became the thing it made.
The import doors that need no file — a link (YouTube · Spotify · Apple
Music · TIDAL · Deezer), a pasted tracklist, a YouTube DJ set — with the
review screen and the desktop's catalog verification pass. A share plugin
crate (crates/tauri-plugin-share): Share → SoulStream from any app keeps
the text in a ShareActivity and the WebView hands it to the import when it
comes back to the front. You → Sources: the source-quality window and the
priority per mode.
Found by the walks and fixed: set_settings takes camelCase arguments, so
the phone's normalization and library-folder saves never landed;
Cover.svelte's non-eager wrapper has no layout box, so its intersection
observer never fired and row thumbnails never loaded (the Artists tab too);
a history push while a history.go was still in flight left the nav mirror
one entry ahead and the page blank after a confirmed delete — the mirror
now applies once per microtask and never pushes during a pending traversal.
The harness names unmocked commands, carries console warnings' frames (the
smoke bundle is built unminified), probes the history mirror on every step,
mocks app_status / play_local / waveform / lyrics_get / player_state and
applies settings patches, runs adb steps on the phone (the share intent),
and walks playlists, the imports, the share and the settings on both the
mock and the phone. ANDROID.md documents all of it.
+8
View File
@@ -0,0 +1,8 @@
android: reorder from the sheet — a playlist row up, down or to the top; a queue row up or down
Without a drag: a playlist row's sheet moves it up, down or to the top and
persists the whole order in one call (playlist_set_order, as the desktop's
drop does); a queue row's sheet moves it up or down, never past the track
playing. Also src-tauri/android/ship.sh — one round end to end: the mock
walk, the embedded build installed, the phone walk, the commit, each step
gated on the one before.
+5
View File
@@ -0,0 +1,5 @@
android: the room list filtered by name, the rest on demand
Soulseek lists hundreds of rooms; the phone showed the first 80 and no way
to the rest. Community → Rooms now has a filter by name and extends the
list 200 at a time.
+8
View File
@@ -0,0 +1,8 @@
android: a playlist's cover from a photo on the phone
The storage plugin gains pick_image — the document picker limited to
images, the picked content copied into the app's cache so the Rust side
gets a plain path. playlist_cover_set(id, path) is the desktop pick's
install step split out and shared (squared, the master cover, the row's
revision moved). A playlist's sheet offers Choose cover photo and Remove
custom cover; the mock walk exercises both.
+6
View File
@@ -0,0 +1,6 @@
android: hours on the library's count line, By format, Jump back in on the empty search
The desktop Library summary's hours on the phone's count line; a By format
order from the file stats (lossless by depth and rate first, then by
bitrate, the badge as the card's line); the empty Search page's Jump back
in — the last records played — as the desktop's empty search shows them.
+5
View File
@@ -0,0 +1,5 @@
android: the nav's badges — transfers in flight on You, unread chats on Community
The desktop's title-bar download ring and its unread indicator, on the
phone's nav: a count on You while Save is fetching, a count on Community
when a chat has unread lines.
+6
View File
@@ -0,0 +1,6 @@
android: the hub's filters on the network results, pasted lyrics
The raw results take the Soulseek hub's filters — a free slot only, FLAC
or MP3 320+ — as chips above the folders. The lyrics sheet has the
desktop's paste door: plain text, or LRC with timestamps, kept for the
track.
+7
View File
@@ -0,0 +1,7 @@
android: downloads on Wi-Fi only
One switch the desktop never needed, off by default: on mobile data every
Save the phone can start — a record, a track, a folder, a file, a
playlist's bulk, the now-playing Save — waits, with the reason on the bar;
streaming is never held back. The walk flips the switch, makes the
connection cellular and sees a download refused.
+30
View File
@@ -0,0 +1,30 @@
android: the repair sheet, browse, every long-press sheet, You's last sections, the lyrics tools — and the walk's audio check made honest
The playlist repair (Find a local match: the library searched by the
row's name, a pick relinks it through playlist_relink_item; an unknown
row takes the track's identity). A peer's sheet on the raw results (play
or save the folder, browse their files, message, buddy) and the Browse
screen — the hub's Browse tab: a user's whole share with sizes and totals,
a folder played or saved whole; a share can hold thousands of folders, so
it filters and renders 80 at a time. "Look up a user" on Community →
Buddies. The sheets the phone lacked: a queue row's (Play now, Move to
next, Remove from queue), a catalog song's, a catalog card's and the
record page's own, a raw file's (in the results and in Browse), a room
name's, a buddy's, the now-playing Save (the desktop's Save menu for a
streamed track or record). The artwork viewer with pinch zoom, one layer
the back gesture peels. Search's recent searches and its spelling
suggestion from the names this phone knows. You → Streaming, Downloads,
Sharing, Lyrics, Album artwork, and Clear play history; a record's
Re-fetch the cover. The lyrics tools on the sheet's face: the offset
nudged and kept per track, another LRCLIB candidate chosen, the lookup
forgotten and done again (lyrics.svelte.js, shared by the face and the
lyrics sheet).
The harness: every CDP call times out and the reload step re-attaches
(a WebView that stopped answering hung a walk for ten minutes); the
audio check counts SoulStream's own tracks' underruns from the mixer dump
instead of the thread-wide counters that any app's notification moves —
the afternoon's "dropouts" were other apps' sounds, and the browse of a
49,544-file share measured at zero; __settle resets every tab to its root
through the nav; an adb step kind; the walks cover all of the above on
the mock and on the phone.
+11
View File
@@ -0,0 +1,11 @@
android: a peer in full — their card, their interests, and what they have that you don't
The peer sheet's new "About this user" row opens a screen of its own: the
card their client serves (their own words, their picture, the upload slot
and the queue), the likes and hates they declared, and the comparison this
phone makes of their share against the library. The comparison is the
point of opening a stranger — every record they have by an artist in your
library that you don't, each row opening the search that finds it, and a
way to forget the whole comparison again. Nothing is uploaded to work it
out. Their picture opens in the artwork viewer, which now takes a data:
key beside a library path and an https URL.
+12
View File
@@ -0,0 +1,12 @@
android: a name of your own for anyone on the network
The desktop's local contact metadata on the phone: a peer's sheet ends in
Name and notes — a petname, a group, a private note and the Favorite and
Trusted marks, kept in the app's own config and published nowhere. 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, and clearing a person's
details drops the entry rather than storing an empty one — the backend's
own rule.
+11
View File
@@ -0,0 +1,11 @@
android: your own Soulseek interests, and who the server says is like you
Community's fourth tab is Taste: the likes and hates this client
publishes to the Soulseek server (slsk_interests_mine, slsk_interest_set),
added and taken back as chips, and the people the server ranks as close to
them (slsk_similar_users) — a row each into the peer sheet the app already
has. Every declaration asks the server again and the chips stay quiet
until it has answered, the way the desktop does, because the ranking only
moves when its input does. The lists live in taste.svelte.js, read once
per account when the tab is opened; the mock walk declares a like, watches
the match grow, declares a hate and takes the like back.
+12
View File
@@ -0,0 +1,12 @@
android: tap-to-sync, the per-track auto-sync switch, the vocal-reduced copy
The editor the desktop drives with a mouse is better under a thumb: 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. One layer the back gesture peels, and a set left mid-tap is
offered back by the row that opened it; Save writes the track's own
document, lines never tapped left out of it and the button saying how
many are kept. The lyrics sheet also gains the per-track auto-sync switch
and the desktop's reduced-vocals copy — the same title, artist and
duration, so the words keep following it, and one row puts the original
back.
+11
View File
@@ -0,0 +1,11 @@
android: a folder of audio files, and a paste of track links
The paste door now reads itself the way the desktop's does: one track link
in the paste and every line is looked up on its own public page
(playlist_import_track_links), which is the way past Spotify's 100-track
public ceiling — the wait screen counts them as they arrive. A fourth door
imports a folder of audio files off this phone, picked with the storage
plugin's own document-tree picker; playlist_import_folder grew a path
argument for it, because on Android it had no dialog to open and always
answered nothing. A YouTube playlist link needed no door at all — the link
one already dispatches it.
Loaded 100 of 638 files, more files were not shown because too many files have changed in this diff. Show more