diff --git a/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt index 996e0b23..36ce5e22 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt @@ -13,7 +13,7 @@ interface DownloadDao { @Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId") fun downloads(sourceId: Long): Flow> - @Query("SELECT * FROM song_downloads") + @Query("SELECT * FROM song_downloads ORDER BY rowid") suspend fun all(): List @Query( diff --git a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt index 8e9a006a..789447a7 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt @@ -163,9 +163,10 @@ interface QueueDao { ): List @Query( - "SELECT id FROM songs WHERE sourceId = :sourceId " + - "AND albumId IN (SELECT id FROM albums WHERE sourceId = :sourceId AND artistId = :artistId) " + - "ORDER BY albumId, disc, track, id", + "SELECT songs.id FROM songs " + + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + + "WHERE songs.sourceId = :sourceId AND albums.artistId = :artistId " + + "ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id", ) suspend fun artistSongIds( sourceId: Long, diff --git a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt index e486f858..17830c85 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt @@ -103,29 +103,64 @@ class DownloadRepository( showMessage(NO_ADDRESS) return@withContext } - if (!enqueueIfAbsent(sourceId, songId)) return@withContext + val promoted = + mutex.withLock { + if (!queueIfAbsentLocked(sourceId, songId)) return@withContext + promote() + } // The media is already with the platform, so this only has to happen while we are still // in the foreground: artwork is fetched by us, and it must not hold up the download. - fetchArtworkSafely(sourceId, listOf(songId)) + fetchArtworkSafely(sourceId, promoted.map { it.songId }) } - private suspend fun enqueueIfAbsent( - sourceId: Long, - songId: String, - ): Boolean = mutex.withLock { enqueueIfAbsentLocked(sourceId, songId) } - - private suspend fun enqueueIfAbsentLocked( + private suspend fun queueIfAbsentLocked( sourceId: Long, songId: String, ): Boolean { if (alreadyDownloadingOrDownloaded(sourceId, songId)) return false val existing = db.downloadDao().find(sourceId, songId) existing?.engineId?.let(engine::cancel) - val url = sourceRepository.downloadUri(sourceId, songId) ?: return false - enqueue(sourceId, songId, url) + dir(sourceId).mkdirs() + file(sourceId, songId).delete() + db.downloadDao().upsert(SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued)) + ensurePolling() return true } + /** + * Hands the platform as many waiting songs as it can run, oldest first, so the queue it holds + * mirrors the list on screen rather than the whole library. Everything else waits as a row + * without an engine id, and the next tick promotes it. + */ + private suspend fun promote(): List { + val rows = db.downloadDao().all() + val free = IN_FLIGHT_LIMIT - rows.count { it.status.isActive && it.engineId != null } + if (free <= 0) return emptyList() + val promoted = ArrayList() + rows + .filter { it.status == DownloadStatus.Queued && it.engineId == null } + .take(free) + .forEach { row -> + val url = sourceRepository.downloadUri(row.sourceId, row.songId) ?: return@forEach + val title = db.libraryDao().songOnce(row.sourceId, row.songId)?.title ?: row.songId + val engineId = + try { + engine.enqueue( + EngineRequest(uri = url, path = enginePath(row.sourceId, row.songId), title = title), + ) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // Leave it failed rather than waiting, or the next tick would retry it forever. + markFailed(row, STOPPED) + return@forEach + } + db.downloadDao().upsert(row.copy(engineId = engineId)) + promoted += row + } + return promoted + } + suspend fun remove( sourceId: Long, songId: String, @@ -167,20 +202,23 @@ class DownloadRepository( } // Under one lock, so a cancel that lands mid-loop waits for the queue to be complete and // then removes all of it, rather than missing the songs enqueued after it looked. - // ponytail: the lock is held for the whole loop, so cancel latency and the poll tick scale - // with the list length; a per-list cancel generation would lift that if it ever bites. - mutex.withLock { - songIds.forEach { songId -> - try { - enqueueIfAbsentLocked(sourceId, songId) - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - // One song failing to enqueue must not abandon the rest of the list. + // ponytail: the lock is held for the whole insert pass, so cancel latency scales with the + // list length; the promote below hands over at most IN_FLIGHT_LIMIT, which is what keeps + // the platform's queue (and our poll writes) bounded. + val promoted = + mutex.withLock { + songIds.forEach { songId -> + try { + queueIfAbsentLocked(sourceId, songId) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // One song failing to queue must not abandon the rest of the list. + } } + promote() } - } - fetchArtworkSafely(sourceId, songIds) + fetchArtworkSafely(sourceId, promoted.map { it.songId }) } suspend fun applyAction( @@ -194,6 +232,20 @@ class DownloadRepository( BulkDownloadAction.Delete -> deleteAll(sourceId, list, refId) } + suspend fun downloadedBytes( + sourceId: Long, + list: DownloadList, + refId: String, + ): Long = + withContext(dispatcher) { + val songIds = songIds(sourceId, list, refId).toHashSet() + db + .downloadDao() + .all() + .filter { it.sourceId == sourceId && it.songId in songIds && it.status == DownloadStatus.Completed } + .sumOf { file(it.sourceId, it.songId).length() } + } + suspend fun cancelAll( sourceId: Long, list: DownloadList, @@ -234,15 +286,15 @@ class DownloadRepository( suspend fun reconcile() = withContext(dispatcher) { - val resumed = + val promoted = mutex.withLock { val rows = db.downloadDao().all() - val resumed = reconcileRows(rows) + reconcileRows(rows) sweep(rows) ensurePolling() - resumed + promote() } - resumed.forEach { fetchArtworkSafely(it.sourceId, listOf(it.songId)) } + warmArtwork(promoted) } @OptIn(ExperimentalCoroutinesApi::class) @@ -316,59 +368,38 @@ class DownloadRepository( .toSet() } - private suspend fun enqueue( - sourceId: Long, - songId: String, - url: String, - ) { - dir(sourceId).mkdirs() - file(sourceId, songId).delete() - val title = db.libraryDao().songOnce(sourceId, songId)?.title ?: songId - val engineId = - engine.enqueue( - EngineRequest(uri = url, path = enginePath(sourceId, songId), title = title), - ) - db.downloadDao().upsert( - SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued, engineId = engineId), - ) - ensurePolling() - } - - private suspend fun recover(row: SongDownload): Boolean = + private suspend fun recover(row: SongDownload) { when (row.status) { DownloadStatus.Completed -> { if (!file(row.sourceId, row.songId).exists()) markFailed(row, MISSING_FILE) - false } + // Waiting rows have no engine id by design; the next promote hands them over. DownloadStatus.Queued -> { - if (sourceRepository.activeSourceIdOnce() != row.sourceId) return false - val url = sourceRepository.downloadUri(row.sourceId, row.songId) ?: return false - enqueue(row.sourceId, row.songId, url) - true + if (row.engineId != null) db.downloadDao().upsert(row.copy(engineId = null)) } DownloadStatus.Running -> { markFailed(row, STOPPED) - false } DownloadStatus.Failed -> { - false + Unit } } + } - private suspend fun reconcileRows(rows: List): List { - val resumed = ArrayList() + private suspend fun reconcileRows(rows: List) { rows.forEach { row -> val state = row.engineId?.let(engine::download) - if (state == null) { - if (recover(row)) resumed += row - } else { - apply(row, state) - } + if (state == null) recover(row) else apply(row, state) + } + } + + private suspend fun warmArtwork(promoted: List) { + promoted.groupBy { it.sourceId }.forEach { (sourceId, rows) -> + fetchArtworkSafely(sourceId, rows.map { it.songId }) } - return resumed } private suspend fun removeRow( @@ -458,16 +489,19 @@ class DownloadRepository( scope.launch { while (true) { delay(POLL_MS) - val resumed = + val promoted = mutex.withLock { val sourceId = sourceRepository.activeSourceIdOnce() val active = db.downloadDao().all().filter { it.status.isActive && it.sourceId == sourceId } if (active.isEmpty()) return@launch reconcileRows(active) + promote() } // Fetching artwork here would hold up the next tick, and with it the progress // the queue is mirrored from. - resumed.forEach { row -> scope.launch { fetchArtworkSafely(row.sourceId, listOf(row.songId)) } } + promoted.groupBy { it.sourceId }.forEach { (sourceId, rows) -> + scope.launch { fetchArtworkSafely(sourceId, rows.map { it.songId }) } + } } } } @@ -507,6 +541,7 @@ class DownloadRepository( const val ENGINE_DIR = "downloads" const val NO_MEDIA = ".nomedia" const val POLL_MS = 1_000L + const val IN_FLIGHT_LIMIT = 8 const val MISSING_FILE = "The downloaded file is missing" const val NO_ADDRESS = "Can't download: the server address is unavailable" const val EMPTY_LIST = "Nothing left to download from this list" diff --git a/app/src/main/kotlin/com/subtracks/ui/components/DeleteDownloadsDialog.kt b/app/src/main/kotlin/com/subtracks/ui/components/DeleteDownloadsDialog.kt new file mode 100644 index 00000000..5cd2993d --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/components/DeleteDownloadsDialog.kt @@ -0,0 +1,40 @@ +package com.subtracks.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable + +fun formatBytes(bytes: Long): String { + val units = listOf("B", "kB", "MB", "GB", "TB") + var value = bytes.toDouble() + var unit = 0 + while (value >= 1000 && unit < units.lastIndex) { + value /= 1000 + unit++ + } + return if (unit == 0) "$bytes ${units[unit]}" else "${"%.1f".format(value)} ${units[unit]}" +} + +@Composable +fun DeleteDownloadsDialog( + name: String, + bytes: Long, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Delete downloads") }, + text = { Text("Delete the downloaded songs of \"$name\"? This frees ${formatBytes(bytes)}.") }, + confirmButton = { + TextButton( + onClick = { + onConfirm() + onDismiss() + }, + ) { Text("Delete") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} diff --git a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt index 9438577e..d0100768 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt @@ -28,8 +28,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.DownloadDone import androidx.compose.material.icons.rounded.MoreHoriz import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Shuffle @@ -291,7 +291,11 @@ fun HeroHeader( ) { when (downloadStatus.action()) { BulkDownloadAction.Delete -> { - Icon(Icons.Rounded.Delete, contentDescription = "Delete download", modifier = Modifier.offset(y = 1.5.dp)) + Icon( + imageVector = Icons.Rounded.DownloadDone, + contentDescription = "Delete download", + modifier = Modifier.size(28.dp).offset(y = 1.5.dp), + ) } BulkDownloadAction.Cancel -> { diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index 47a87554..ce080356 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -13,7 +13,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -31,12 +34,14 @@ import com.subtracks.data.model.SongDownload import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.ContextMenuHost +import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroHeader import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.rememberArtworkColors +import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -60,6 +65,8 @@ fun AlbumDetailRoute( val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList()) val downloads by viewModel.downloads.collectAsStateWithLifecycle() val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + var pendingDelete by remember { mutableStateOf(null) } val playback by playbackController.state.collectAsStateWithLifecycle() val context = playback.context val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } @@ -84,7 +91,13 @@ fun AlbumDetailRoute( artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS), downloads = downloads, downloadStatus = downloadStatus, - onDownloadAction = viewModel::onDownloadAction, + onDownloadAction = { action -> + if (action == BulkDownloadAction.Delete) { + scope.launch { pendingDelete = viewModel.downloadedBytes() } + } else { + viewModel.onDownloadAction(action) + } + }, onBack = onBack, onSongClick = viewModel::play, onSongLongClick = { contextMenuHost?.show(it, actions) }, @@ -98,6 +111,15 @@ fun AlbumDetailRoute( ?.let { id -> { onViewArtist(id) } }, playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId }, ) + + pendingDelete?.let { bytes -> + DeleteDownloadsDialog( + name = album?.name.orEmpty(), + bytes = bytes, + onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) }, + onDismiss = { pendingDelete = null }, + ) + } } @Composable diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt index 5c72f914..8bae3515 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt @@ -47,6 +47,8 @@ class AlbumDetailViewModel( .flatMapLatest { downloadRepository.status(it, DownloadList.Album, albumId) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus()) + suspend fun downloadedBytes(): Long = downloadRepository.downloadedBytes(sourceId.first(), DownloadList.Album, albumId) + fun onDownloadAction(action: BulkDownloadAction) { viewModelScope.launch { downloadRepository.applyAction(sourceId.first(), DownloadList.Album, albumId, action) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt index bcc07ddc..89aeb2e1 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -21,12 +22,14 @@ import com.subtracks.data.model.SongDownload import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.ContextMenuHost +import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroHeader import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.rememberArtworkColors +import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -48,6 +51,8 @@ fun PlaylistDetailRoute( val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null) val downloads by viewModel.downloads.collectAsStateWithLifecycle() val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + var pendingDelete by remember { mutableStateOf(null) } val playback by playbackController.state.collectAsStateWithLifecycle() val context = playback.context val actions = @@ -70,7 +75,13 @@ fun PlaylistDetailRoute( artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS), downloads = downloads, downloadStatus = downloadStatus, - onDownloadAction = viewModel::onDownloadAction, + onDownloadAction = { action -> + if (action == BulkDownloadAction.Delete) { + scope.launch { pendingDelete = viewModel.downloadedBytes() } + } else { + viewModel.onDownloadAction(action) + } + }, onBack = onBack, onSongClick = viewModel::play, onSongLongClick = { contextMenuHost?.show(it, actions) }, @@ -79,6 +90,15 @@ fun PlaylistDetailRoute( onMore = { playlist?.let { contextMenuHost?.show(MenuTarget.Playlist(it, viewModel.coverArt(it.coverArt, true)), actions) } }, playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId }, ) + + pendingDelete?.let { bytes -> + DeleteDownloadsDialog( + name = playlist?.name.orEmpty(), + bytes = bytes, + onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) }, + onDismiss = { pendingDelete = null }, + ) + } } @Composable diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt index 23fdc735..e9377d69 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt @@ -51,6 +51,8 @@ class PlaylistDetailViewModel( .flatMapLatest { downloadRepository.status(it, DownloadList.Playlist, playlistId) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus()) + suspend fun downloadedBytes(): Long = downloadRepository.downloadedBytes(sourceId.first(), DownloadList.Playlist, playlistId) + fun onDownloadAction(action: BulkDownloadAction) { viewModelScope.launch { downloadRepository.applyAction(sourceId.first(), DownloadList.Playlist, playlistId, action) diff --git a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt index aab42a33..5e69d467 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt @@ -560,11 +560,10 @@ class DownloadRepositoryTest { fun cancellingWhileAListIsStillQueuingRemovesTheWholeList() = runTest { seedLibrary() - seedSongs(3..40) - engine.enqueueDelayMs = 1 + seedSongs(3..400) val queuing = launch(Dispatchers.IO) { repository.downloadAll(1, DownloadList.Album, "al1") } - await { engine.requests.size >= 3 } + await { allRows().size >= 50 } val cancelling = launch(Dispatchers.IO) { repository.cancelAll(1, DownloadList.Album, "al1") } queuing.join() cancelling.join() @@ -572,6 +571,50 @@ class DownloadRepositoryTest { assertTrue("expected the whole list cancelled, ${db.downloadDao().all().size} rows left", db.downloadDao().all().isEmpty()) } + @Test + fun aLongListHandsThePlatformOnlyTheInFlightLimit() = + runTest { + seedLibrary() + seedSongs(3..40) + + runBlocking { repository.downloadAll(1, DownloadList.Album, "al1") } + + assertEquals(8, engine.requests.size) + assertEquals(32, db.downloadDao().all().count { it.status == DownloadStatus.Queued && it.engineId == null }) + } + + @Test + fun finishingADownloadHandsOverTheNextSongInListOrder() = + runTest { + seedLibrary() + seedSongs(3..12) + runBlocking { repository.downloadAll(1, DownloadList.Album, "al1") } + val first = engine.requests.first() + + engine.complete(first.first, total = 10) + writeFile(1, first.second.path.substringAfterLast('/'), "audio") + runBlocking { repository.reconcile() } + + assertEquals(9, engine.requests.size) + assertEquals((1..9).map { "s$it" }, requestedSongIds()) + } + + @Test + fun reconcilingAWaitingListDoesNotHandItOverWholesale() = + runTest { + seedLibrary() + seedSongs(3..40) + runBlocking { + (1..40).forEach { track -> + db.downloadDao().upsert(SongDownload(1, "s$track", DownloadStatus.Queued)) + } + } + + runBlocking { repository.reconcile() } + + assertEquals(8, engine.requests.size) + } + @Test fun oneSongFailingToQueueDoesNotAbandonTheRest() = runTest { @@ -613,6 +656,19 @@ class DownloadRepositoryTest { assertNull(row(1, "s2")) } + @Test + fun downloadedBytesCountsTheCompletedFilesOfTheList() = + runTest { + seedLibrary() + writeFile(1, "s1", "12345") + db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = 11)) + writeFile(1, "s2", "1234") + db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Running, engineId = 22)) + writeFile(1, "s3", "123") + + assertEquals(5L, repository.downloadedBytes(1, DownloadList.Album, "al1")) + } + private fun requestedSongIds(): List = engine.requests.map { it.second.path.substringAfterLast('/') } private fun seedSongs(tracks: IntRange) { diff --git a/app/src/test/kotlin/com/subtracks/ui/components/DeleteDownloadsDialogTest.kt b/app/src/test/kotlin/com/subtracks/ui/components/DeleteDownloadsDialogTest.kt new file mode 100644 index 00000000..a512672c --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/components/DeleteDownloadsDialogTest.kt @@ -0,0 +1,69 @@ +package com.subtracks.ui.components + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.ui.theme.SubtracksTheme +import org.junit.Assert.assertEquals +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 DeleteDownloadsDialogTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun theDialogNamesTheThingAndTheSpaceItFrees() { + render(bytes = 1_500_000) + + composeRule.onNodeWithText("Kid A", substring = true).assertIsDisplayed() + composeRule.onNodeWithText("1.5 MB", substring = true).assertIsDisplayed() + } + + @Test + fun deleteConfirms() { + var confirms = 0 + render(onConfirm = { confirms++ }) + + composeRule.onNodeWithText("Delete").performClick() + + assertEquals(1, confirms) + } + + @Test + fun cancelDismisses() { + var dismisses = 0 + render(onDismiss = { dismisses++ }) + + composeRule.onNodeWithText("Cancel").performClick() + + assertEquals(1, dismisses) + } + + @Test + fun bytesReadAsUnits() { + assertEquals("512 B", formatBytes(512)) + assertEquals("1.0 kB", formatBytes(1_024)) + assertEquals("1.5 MB", formatBytes(1_500_000)) + assertEquals("2.0 GB", formatBytes(2_000_000_000)) + } + + private fun render( + bytes: Long = 0, + onConfirm: () -> Unit = {}, + onDismiss: () -> Unit = {}, + ) { + composeRule.setContent { + SubtracksTheme { + DeleteDownloadsDialog(name = "Kid A", bytes = bytes, onConfirm = onConfirm, onDismiss = onDismiss) + } + } + } +}