## The downloads in the shade

Transfers is a screen inside the app; the desktop says what a Save is doing
with the ring in its title bar, the phone with a badge on the nav. Neither
is anywhere to be seen once the app is in the background, which is exactly
when a Save is left to run on its own. So a running Save now shows in the
shade: how many files are still in flight, the one it is on, a progress
bar, and two buttons — **Stop all** and **Open**.

Its own channel ("Downloads", `IMPORTANCE_LOW`, no badge, private on a
locked screen, since a filename is nobody else's business), separate from
Playback so one can be silenced without the other, and created when the app
starts rather than when the first download does — a user who wants the
downloads quiet should not have to wait for one to be noisy first.

Its own service too, and that is the part worth spelling out.
`foregroundServiceType` is per-service: a download is `dataSync`, playback
is `mediaPlayback`. `MediaService` stops itself the moment the bar empties,
so folding the notice into it would take the card down mid-Save; keeping it
alive for a download instead would leave a `mediaPlayback` service running
with no session, which Android 14+ treats as a violation. And nothing else
keeps the process alive for a Save with nothing playing — which is exactly
how the first real Save died on 2026-09-13, four tracks in. A `dataSync`
foreground service that exists while downloads are in flight is the
structural fix, and it can only be its own service. The same promote-first
rule applies: `onStartCommand` calls `startForeground()` before it looks at
anything, because Android kills the app about ten seconds after a
foreground start that never did. Android 15 caps a `dataSync` service at
six cumulative hours a day and kills the app if it is still up when the
callback returns, so `onTimeout` takes the notice down rather than being
taken down with it; the fetches are the Rust core's own and Transfers still
lists them.

The buttons come back as the plugin event `action`
(`addPluginListener("transfer-notice", "action", …)`). **Stop all** runs
`cancelAll()` in `transfers.svelte.js`, which is the same `cancel(id)` a
row's tap calls, once per live row — not a second path to the same thing.
**Open** brings the activity forward and puts the You tab on Transfers, and
the notice's body does the same. Transfers grew the matching button: Cancel
all while anything is in flight, beside Clear finished.

`src/mobile/xfernote.svelte.js` is the mirror, and it is driven by a Svelte
effect over the transfer store rather than off the `soulseek:download`
event. That is deliberate twice over: the effect tracks progress as well as
arrivals, and it means a row written straight into the store drives the
notice exactly as a fetched one does — which is how the walk exercises this
without fetching anything. Posts are throttled to one per 700 ms of changed
content; download progress arrives far faster than anyone reads it, and the
system rate-limits (and drops) notifications posted in a flood.

`crates/tauri-plugin-transfer-notice` is shaped like the media-session
plugin: `build.rs` hands its `android/` Gradle module to tauri-build,
`src/lib.rs` registers the Kotlin class and exposes `update` / `clear`, and
the Kotlin side is two files — the plugin (arguments in, the `action` event
out) and `TransferService`. The app's capability grants
`transfer-notice:default`; the plugin is registered on every platform so
the permission resolves everywhere, and on the desktop its commands answer
"not here". The tauri-build trap above applies to this plugin as well: the
Android module only reaches the app project when
`TAURI_ANDROID_PROJECT_PATH` is set, which is why adding it also touched
`src-tauri/build.rs`.
