publish the download map before signalling it is loaded

Second review round on the downloads block:

- `loaded` was completed upstream of the state flow that publishes the map, so
  `awaitLoaded()` could return while the map was still the initial empty one and
  the first window after a cold start would stream a track that is already on
  disk. The collector now assigns the map before completing, and the map is a
  plain state flow instead of a lazy eager `stateIn` with a bare touch to start
  it.
- A failed download now deletes its partial file, in both the engine-failure
  and the recovery paths. A failed row used to protect its own partial bytes
  from the sweep, and the UI offers no delete for it.
- A queued row is left queued when the download URL cannot be resolved yet,
  instead of being failed. At launch the repository's reconcile can run before
  the source repository has built its client, and failing there turned a
  resumable download into a dead one.
- The poller only considers the active source's rows, so a queued row belonging
  to another source no longer keeps it polling the database every second.
- `SourceRepository.downloadUri` takes the expected source id and returns null
  unless it is the active one, so a download cannot be enqueued against the
  previous server's URL during a source switch.
- Tests: a stream with no content length still gets its declared length through
  the production data source factory (the http half of the chain, which the
  first round left uncovered), and an engine failure is asserted to leave no
  partial file. The engine's status mapping now runs as a plain unit test.
This commit is contained in:
austinried
2026-10-04 00:30:01 +00:00
parent e037f3ef37
commit ae41b0e66a
7 changed files with 55 additions and 30 deletions
@@ -1,12 +1,9 @@
package com.subtracks.data.download
import android.app.DownloadManager
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 SystemDownloadEngineTest {
@Test
fun successfulRunningAndFailedStatusesAreMapped() {
@@ -188,17 +188,19 @@ class DownloadRepositoryTest {
}
@Test
fun anEngineFailureIsMirroredWithItsMessage() =
fun anEngineFailureIsMirroredWithItsMessageAndLeavesNoPartialFile() =
runTest {
seedLibrary()
runBlocking { repository.download(1, "s1") }
val id = engine.requests.single().first
writeFile(1, "s1", "half")
engine.fail(id)
runBlocking { repository.reconcile() }
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
assertEquals("Download failed", row(1, "s1")?.error)
assertFalse(file(1, "s1").exists())
}
@Test
@@ -291,7 +293,7 @@ class DownloadRepositoryTest {
),
)
}
await { sources.downloadUri("s1") != null }
await { sources.downloadUri(1, "s1") != null }
}
private fun file(
@@ -6,7 +6,10 @@ import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.source.streamLengthSuffix
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -14,11 +17,14 @@ import java.io.File
@RunWith(AndroidJUnit4::class)
class MediaDataSourceTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private fun dataSource() = mediaDataSourceFactory(context, OkHttpDataSource.Factory(OkHttpClient())).createDataSource()
@Test
fun aDownloadedFileIsReadThroughTheStreamingChain() {
val context = ApplicationProvider.getApplicationContext<Context>()
val file = File(context.cacheDir, "downloaded-${System.nanoTime()}").apply { writeBytes(byteArrayOf(1, 2, 3, 4)) }
val dataSource = mediaDataSourceFactory(context, OkHttpDataSource.Factory(OkHttpClient())).createDataSource()
val dataSource = dataSource()
val length = dataSource.open(DataSpec(Uri.fromFile(file)))
val buffer = ByteArray(4)
@@ -29,4 +35,19 @@ class MediaDataSourceTest {
assertEquals(4, read)
assertEquals(listOf<Byte>(1, 2, 3, 4), buffer.toList())
}
@Test
fun aStreamWithoutAContentLengthGetsItsDeclaredLength() {
val server = MockWebServer()
server.enqueue(MockResponse().setChunkedBody("0123456789", 1))
server.start()
val url = "http://${server.hostName}:${server.port}/rest/stream" + streamLengthSuffix(1_000_000)
val dataSource = dataSource()
val length = dataSource.open(DataSpec(Uri.parse(url)))
dataSource.close()
server.shutdown()
assertEquals(1_000_000L, length)
}
}