download a song to the device and play it from there

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.
This commit is contained in:
austinried
2026-10-04 00:30:01 +00:00
parent b21c297964
commit 2b104f1387
29 changed files with 2423 additions and 61 deletions
@@ -0,0 +1,45 @@
package com.subtracks.data.download
class FakeDownloadEngine : DownloadEngine {
private val downloads = mutableMapOf<Long, EngineDownload>()
private var nextId = 1L
val requests = mutableListOf<Pair<Long, EngineRequest>>()
val cancelled = mutableListOf<Long>()
override fun enqueue(request: EngineRequest): Long {
val id = nextId++
requests += id to request
downloads[id] = EngineDownload(EngineStatus.Pending)
return id
}
override fun download(id: Long): EngineDownload? = downloads[id]
override fun cancel(id: Long) {
cancelled += id
}
fun running(
id: Long,
bytes: Long,
total: Long,
) {
downloads[id] = EngineDownload(EngineStatus.Running, bytes, total)
}
fun complete(
id: Long,
total: Long,
) {
downloads[id] = EngineDownload(EngineStatus.Completed, total, total)
}
fun fail(id: Long) {
downloads[id] = EngineDownload(EngineStatus.Failed, error = "Download failed")
}
fun forget(id: Long) {
downloads.remove(id)
}
}
@@ -0,0 +1,268 @@
package com.subtracks.data.repo
import android.content.Context
import android.net.Uri
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.CopyOnWriteArrayList
@RunWith(AndroidJUnit4::class)
class DownloadRepositoryTest {
private lateinit var db: SubtracksDatabase
private lateinit var sources: SourceRepository
private lateinit var engine: FakeDownloadEngine
private lateinit var repository: DownloadRepository
private lateinit var dir: File
private val messages = CopyOnWriteArrayList<String>()
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
engine = FakeDownloadEngine()
dir = File(context.cacheDir, "downloads-${System.nanoTime()}")
repository = DownloadRepository(db, sources, engine, dir, showMessage = { messages += it })
}
@After
fun tearDown() {
repository.close()
sources.close()
db.close()
dir.deleteRecursively()
}
@Test
fun startingADownloadAsksTheEngineForTheOriginalAndRecordsIt() =
runTest {
seedLibrary()
runBlocking { repository.download(1, "s1") }
val request = engine.requests.single().second
assertTrue("expected the download endpoint, was ${request.uri}", request.uri.contains("/rest/download"))
assertEquals("downloads/1/s1", request.path)
assertEquals("Song 1", request.title)
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
}
@Test
fun progressAndCompletionAreMirroredAndTheFileBecomesPlayable() =
runTest {
seedLibrary()
repository.start()
runBlocking { repository.download(1, "s1") }
val id = engine.requests.single().first
engine.running(id, bytes = 40, total = 100)
await { row(1, "s1")?.status == DownloadStatus.Running }
assertEquals(40L, row(1, "s1")?.bytes)
engine.complete(id, total = 100)
writeFile(1, "s1", "audio")
runBlocking { repository.reconcile() }
await { repository.localUri("s1") != null }
assertEquals(DownloadStatus.Completed, row(1, "s1")?.status)
assertEquals(Uri.fromFile(file(1, "s1")).toString(), repository.localUri("s1"))
}
@Test
fun aPartialFileIsNotTreatedAsPlayableWhileTheDownloadRuns() =
runTest {
seedLibrary()
repository.start()
runBlocking { repository.download(1, "s1") }
val id = engine.requests.single().first
engine.running(id, bytes = 40, total = 100)
writeFile(1, "s1", "half")
await { row(1, "s1")?.status == DownloadStatus.Running }
assertNull(repository.localUri("s1"))
}
@Test
fun aCompletedRowWhoseFileIsGoneIsFailedRatherThanLeftCompleted() =
runTest {
seedLibrary()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = null))
runBlocking { repository.reconcile() }
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
}
@Test
fun aCompletedEngineStateWithoutAFileIsFailed() =
runTest {
seedLibrary()
runBlocking { repository.download(1, "s1") }
val id = engine.requests.single().first
engine.complete(id, total = 100)
runBlocking { repository.reconcile() }
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
}
@Test
fun aQueuedRowWhoseEngineEntryIsGoneIsStartedAgain() =
runTest {
seedLibrary()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Queued, engineId = 7))
runBlocking { repository.reconcile() }
val request = engine.requests.single()
assertNotEquals(7L, request.first)
assertEquals("downloads/1/s1", request.second.path)
}
@Test
fun removingADownloadCancelsItAndDeletesTheFile() =
runTest {
seedLibrary()
writeFile(1, "s1", "audio")
runBlocking { repository.download(1, "s1") }
val id = engine.requests.single().first
runBlocking { repository.remove(1, "s1") }
assertTrue(engine.cancelled.contains(id))
assertFalse(file(1, "s1").exists())
assertNull(row(1, "s1"))
}
@Test
fun aFailedDownloadCanBeStartedAgain() =
runTest {
seedLibrary()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Failed, engineId = 3, error = "boom"))
runBlocking { repository.download(1, "s1") }
assertEquals(1, engine.requests.size)
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
}
@Test
fun downloadingFromASourceThatIsNotActiveIsRefused() =
runTest {
seedLibrary()
db.sourcesDao().upsertSource(Source(2, "other", "http://other/", isActive = false, createdAt = 0))
db.sourcesDao().upsertSubsonicSource(SubsonicSource(2, "user", "pass"))
runBlocking { repository.download(2, "s1") }
assertTrue(engine.requests.isEmpty())
assertEquals(listOf("Can't download from a source that isn't active"), messages.toList())
}
@Test
fun removingASourceCancelsItsDownloadsAndDeletesItsFiles() =
runTest {
seedLibrary()
writeFile(1, "s1", "audio")
runBlocking { repository.download(1, "s1") }
val id = engine.requests.single().first
runBlocking { repository.removeSource(1) }
assertTrue(engine.cancelled.contains(id))
assertFalse(file(1, "s1").exists())
assertTrue(allRows().isEmpty())
}
@Test
fun reconcileDeletesFilesThatBelongToNoDownload() =
runTest {
seedLibrary()
writeFile(1, "orphan", "audio")
runBlocking { repository.reconcile() }
assertFalse(file(1, "orphan").exists())
}
private fun seedLibrary() {
runBlocking {
db.sourcesDao().upsertSource(Source(1, "source", "http://localhost:4533/", isActive = true, createdAt = 0))
db.sourcesDao().upsertSubsonicSource(SubsonicSource(1, "user", "pass"))
db.libraryDao().upsertSongs(
listOf(
Song(
sourceId = 1,
id = "s1",
albumId = "al1",
artistId = "ar1",
title = "Song 1",
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
genre = null,
),
),
)
}
await { sources.downloadUri("s1") != null }
}
private fun file(
sourceId: Long,
songId: String,
): File = File(File(dir, sourceId.toString()), songId)
private fun row(
sourceId: Long,
songId: String,
): SongDownload? = runBlocking { db.downloadDao().find(sourceId, songId) }
private fun allRows(): List<SongDownload> = runBlocking { db.downloadDao().all() }
private fun writeFile(
sourceId: Long,
songId: String,
contents: String,
) {
val file = file(sourceId, songId)
file.parentFile?.mkdirs()
file.writeText(contents)
}
private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + 5_000_000_000L
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out waiting for the expected download state", predicate())
}
}
@@ -0,0 +1,57 @@
package com.subtracks.playback
import androidx.media3.common.C
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MediaItemTest {
private val song =
QueueItem(
id = "s1",
title = "Song",
artist = "Artist",
album = "Album",
coverArtId = "al1",
durationMs = 100_000L,
)
@Test
fun aDownloadedFileIsPreferredOverTheStream() {
val item = queueMediaItem(song, local = "file:///downloads/1/s1", stream = "http://server/stream?s=s1", transcode = false)
assertEquals("file:///downloads/1/s1", item.localConfiguration?.uri.toString())
}
@Test
fun theStreamIsUsedWhenThereIsNoDownload() {
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false)
assertEquals("http://server/stream?s=s1", item.localConfiguration?.uri.toString())
}
@Test
fun aDownloadedFileIsNotClippedEvenForATranscodingQuality() {
val item = queueMediaItem(song, local = "file:///downloads/1/s1", stream = "http://server/stream?s=s1", transcode = true)
assertEquals(C.TIME_END_OF_SOURCE, item.clippingConfiguration?.endPositionMs)
}
@Test
fun aTranscodedStreamIsClippedToItsDeclaredDuration() {
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = true)
assertEquals(100_000L, item.clippingConfiguration?.endPositionMs)
}
@Test
fun theItemKeepsItsIdTitleAndCoverArt() {
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false)
assertEquals("s1", item.mediaId)
assertEquals("Song", item.mediaMetadata.title.toString())
assertEquals(CoverArtArtwork.uri("al1"), item.mediaMetadata.artworkUri)
}
}
@@ -7,6 +7,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.Album
import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind
@@ -16,6 +17,7 @@ import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.Dispatchers
@@ -34,6 +36,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
@@ -46,6 +49,7 @@ class PlaybackControllerTest {
private lateinit var networkMode: MutableStateFlow<NetworkMode>
private lateinit var sources: SourceRepository
private lateinit var queues: QueueRepository
private lateinit var downloads: DownloadRepository
private lateinit var handle: FakePlayerHandle
private lateinit var controller: PlaybackController
private val messages = CopyOnWriteArrayList<String>()
@@ -62,15 +66,24 @@ class PlaybackControllerTest {
networkMode = MutableStateFlow(NetworkMode.Wifi)
sources = SourceRepository(db, OkHttpClient(), prefs, networkMode = networkMode)
queues = QueueRepository(db)
downloads = DownloadRepository(db, sources, FakeDownloadEngine(), File(context.cacheDir, "downloads"))
handle = FakePlayerHandle()
controller =
PlaybackController(sources, queues, FakePlayerConnection(handle), showMessage = { messages += it }, dispatcher = dispatcher)
PlaybackController(
sources,
queues,
FakePlayerConnection(handle),
downloads,
showMessage = { messages += it },
dispatcher = dispatcher,
)
}
@After
fun tearDown() {
controller.close()
sources.close()
downloads.close()
db.close()
dispatcher.close()
}
@@ -616,7 +629,7 @@ class PlaybackControllerTest {
runBlocking { queues.setPosition(12_000L) }
val restoredHandle = FakePlayerHandle()
val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), dispatcher = dispatcher)
val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), downloads, dispatcher = dispatcher)
restored.connect()
await {
restored.state.value.item
@@ -724,7 +737,7 @@ class PlaybackControllerTest {
block: Runnable,
) = block.run()
}
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline)
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline)
eager.playAlbum(1, "al1", 0)
await {
@@ -771,7 +784,7 @@ class PlaybackControllerTest {
block: Runnable,
) = block.run()
}
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline)
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline)
eager.playAlbum(1, "al1", 0)
await {
@@ -814,7 +827,7 @@ class PlaybackControllerTest {
block: Runnable,
) = block.run()
}
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline)
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline)
eager.playAlbum(1, "al1", 0)
await {
@@ -857,7 +870,7 @@ class PlaybackControllerTest {
block: Runnable,
) = block.run()
}
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline)
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline)
eager.playAlbum(1, "al1", 0)
await {
@@ -2241,7 +2254,7 @@ class PlaybackControllerTest {
runBlocking { queues.setPosition(12_000L) }
val restoredHandle = FakePlayerHandle()
val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), dispatcher = dispatcher)
val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), downloads, dispatcher = dispatcher)
restored.connect()
await {
restored.state.value.item
@@ -2277,6 +2290,21 @@ class PlaybackControllerTest {
assertEquals(0L, runBlocking { queues.cursorPositionMs() })
}
@Test
fun refreshingMediaItemsRebuildsTheWindowAtTheSamePosition() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await { handle.operations.contains("play") }
handle.positionMs = 42_000L
val windows = handle.operations.count { it.startsWith("setWindow") }
controller.refreshMediaItems()
await { handle.operations.count { it.startsWith("setWindow") } > windows }
assertEquals("s1", handle.currentItem?.id)
assertEquals(42_000L, handle.positionMs)
}
@Test
fun positionTickerAdvancesPositionWithoutEmittingPlaybackState() =
runBlocking {
@@ -0,0 +1,72 @@
package com.subtracks.ui.library
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SongRowDownloadTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private val song =
Song(
sourceId = 1,
id = "s1",
albumId = "al1",
artistId = "ar1",
title = "Song",
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
genre = null,
)
@Test
fun aCompletedDownloadShowsTheDownloadedIndicator() {
render(download(DownloadStatus.Completed))
composeRule.onNodeWithContentDescription("Downloaded").assertIsDisplayed()
}
@Test
fun aRunningDownloadShowsNoDownloadedIndicator() {
render(download(DownloadStatus.Running))
composeRule.onNodeWithContentDescription("Downloaded").assertDoesNotExist()
}
@Test
fun aSongWithoutADownloadShowsNoIndicator() {
render(null)
composeRule.onNodeWithText("Song").assertIsDisplayed()
composeRule.onNodeWithContentDescription("Downloaded").assertDoesNotExist()
}
private fun download(status: DownloadStatus) =
SongDownload(sourceId = 1, songId = "s1", status = status, engineId = 7, bytes = 40, total = 100)
private fun render(download: SongDownload?) {
composeRule.setContent {
SubtracksTheme {
SongRow(song = song, download = download)
}
}
}
}
@@ -6,11 +6,13 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongListItem
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.FakePlayerConnection
@@ -30,6 +32,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.Executors
@OptIn(ExperimentalCoroutinesApi::class)
@@ -53,7 +56,8 @@ class QueueViewModelTest {
.build()
val sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
queues = QueueRepository(db)
controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), dispatcher = dispatcher)
val downloads = DownloadRepository(db, sources, FakeDownloadEngine(), File(context.cacheDir, "downloads"))
controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), downloads, dispatcher = dispatcher)
viewModel = QueueViewModel(queues, controller)
}