bound the downloads handed to the platform, and confirm bulk deletes

Two things a 3k-song playlist turned up, plus the hero's downloaded state.

- The platform runs about eight downloads at a time, but a bulk action handed it
  every song at once. Three thousand pending requests meant a notification flood,
  and a poll tick that rewrote every row's bytes/total, so the whole list churned
  ten times a second. Now a bulk action only records the songs and hands over at
  most IN_FLIGHT_LIMIT, oldest first, promoting the next as each finishes, so our
  row state mirrors what is actually running and a cancel only has to reach what
  is in flight.
- Cancelling was therefore unusable at that size. It now touches at most the
  in-flight requests instead of thousands, and the rows wait behind it.
- A queued row with no engine id is waiting its turn, not lost, so reconcile no
  longer re-enqueues the whole list on every pass; a row whose engine entry
  vanished goes back to waiting and the next promote picks it up. A song that
  cannot be handed over fails instead of being retried every tick.
- Requests follow the order the screen shows: artist songs resolve through the
  same albums ordering the artist page uses, so they were the one list that did
  not.
- The hero's fully-downloaded state shows the downloaded glyph rather than a bin,
  and tapping it asks before deleting, naming the album or playlist and the space
  it frees.
This commit is contained in:
austinried
2026-10-04 00:30:02 +00:00
parent 17c1ea40a4
commit 4652430f73
11 changed files with 324 additions and 73 deletions
@@ -13,7 +13,7 @@ interface DownloadDao {
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId")
fun downloads(sourceId: Long): Flow<List<SongDownload>>
@Query("SELECT * FROM song_downloads")
@Query("SELECT * FROM song_downloads ORDER BY rowid")
suspend fun all(): List<SongDownload>
@Query(
@@ -163,9 +163,10 @@ interface QueueDao {
): List<String>
@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,
@@ -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<SongDownload> {
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<SongDownload>()
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<SongDownload>): List<SongDownload> {
val resumed = ArrayList<SongDownload>()
private suspend fun reconcileRows(rows: List<SongDownload>) {
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<SongDownload>) {
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"
@@ -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") } },
)
}
@@ -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 -> {
@@ -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<Long?>(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
@@ -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)
@@ -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<Long?>(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
@@ -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)
@@ -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<String> = engine.requests.map { it.second.path.substringAfterLast('/') }
private fun seedSongs(tracks: IntRange) {
@@ -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<ComponentActivity>()
@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)
}
}
}
}