Review findings on the first downloads block:
- ExoPlayer could not open a downloaded file at all: the service's data source
factory was OkHttp only, so a file:// URI went to the HTTP stack and failed
with a malformed URL. Wrap it in DefaultDataSource so the file, asset and
content schemes are resolved before the stream chain, and cover it with a
test that reads a local file through the production factory.
- Reconcile no longer restarts a failed download, or a running one whose engine
entry vanished: only a queued row is started again, and the rest become failed
so the explicit Retry in the UI means something.
- song_downloads also references songs with CASCADE now, so a song pruned from
the mirror takes its download row with it and the sweep can reclaim the file.
- The mutating methods run on the injected dispatcher rather than the caller's,
which was the main thread for every call site, and the repository exposes one
shared state flow instead of a second map plus five subscriptions.
- Playback stops when the active source disappears, including deleting the last
source while a downloaded track is playing.
- Dropped the unused request mime type, the queued-at column nothing read, the
metered constructor parameter nobody varied, and a dead index guard.
- The running indicator is asserted positively, the engine's status and reason
mapping has a test, and the failure paths use the fake's failure and forget
helpers.
First block of #65: the state machine, the platform download engine, a
single-song download from the song context menu, the downloaded indicator in
song lists, and local-first playback.
- `song_downloads`, keyed `(sourceId, songId)` with CASCADE on the source, and
the 16 -> 17 migration.
- `DownloadEngine` over `android.app.DownloadManager`, with a fake for tests.
Files land in app-specific external storage at
`.../Music/downloads/<sourceId>/<songId>`, plus a `.nomedia` so media scanners
on pre-Android-10 devices leave them alone.
- `DownloadRepository` enqueues, mirrors progress by polling, cancels, deletes
and reconciles on start and on source change: a queued row whose engine entry
is gone is started again, a completed row whose file is gone becomes failed
rather than being resurrected, and files with no row are swept.
- Playback prefers the downloaded file: the media item build takes the local URI
when the download is complete and leaves it unclipped.
- Deleting a download reloads the player window at its position, so the item
falls back to streaming instead of failing mid-read.
- Deleting a source cancels its downloads and removes their files.
Downloads are the system's own notifications and stay off metered networks for
now. Artwork, bulk downloads and the management screen are later blocks.