mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user