From 9d3b55ccbb706a01f848ec28d22e95840c2bcdc5 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 12:34:18 -0400 Subject: [PATCH 01/23] A library renamed on the server shows its new name in the selector The reload check compared library ids only, so a rename kept the old name until the list itself changed. It now also notices a changed name for the same id. Co-Authored-By: Claude Sonnet 5.5 --- .../app/ui/library/LibraryViewModel.kt | 27 ++++++- .../app/ui/library/LibraryRenameReloadTest.kt | 80 +++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryRenameReloadTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt index 185cd6c..ba6abdf 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt @@ -117,13 +117,26 @@ internal fun shouldRequeryShelfAfterSync( * Membership is compared as a set: the shown list puts retained libraries * after fetched ones while the cache sorts by display order, and treating * that order difference as a change reloaded on every sync, forever. + * A rename keeps the id, so [librariesRenamed] reports it separately. */ internal fun shouldReloadLibrariesAfterSync( selectedLibrary: Library?, shownLibraryIds: List, cachedLibraryIds: List, + librariesRenamed: Boolean = false, ): Boolean = (selectedLibrary == null && cachedLibraryIds.isNotEmpty()) || - cachedLibraryIds.toSet() != shownLibraryIds.toSet() + cachedLibraryIds.toSet() != shownLibraryIds.toSet() || + librariesRenamed + +/** + * Whether a library shown under one name is saved under another. Only ids + * present in both lists count, since added and removed libraries are the id + * comparison's job, and order never matters. + */ +internal fun librariesRenamed(shown: List, cached: List): Boolean { + val cachedNames = cached.associate { it.id to it.name } + return shown.any { library -> cachedNames[library.id]?.let { it != library.name } == true } +} internal enum class AfterProgressPull { REFILTER, RELOAD_LIBRARIES } @@ -139,8 +152,9 @@ internal fun afterProgressPull( selectedLibrary: Library?, shownLibraryIds: List, cachedLibraryIds: List, + librariesRenamed: Boolean = false, ): AfterProgressPull = - if (shouldReloadLibrariesAfterSync(selectedLibrary, shownLibraryIds, cachedLibraryIds)) { + if (shouldReloadLibrariesAfterSync(selectedLibrary, shownLibraryIds, cachedLibraryIds, librariesRenamed)) { AfterProgressPull.RELOAD_LIBRARIES } else { AfterProgressPull.REFILTER @@ -493,6 +507,7 @@ class LibraryViewModel @Inject constructor( selectedLibrary = state.selectedLibrary, shownLibraryIds = state.libraries.map { it.id }, cachedLibraryIds = cached.map { it.id }, + librariesRenamed = librariesRenamed(state.libraries, cached), ) ) { // This can replace a manual refresh in the lane, and the @@ -519,7 +534,13 @@ class LibraryViewModel @Inject constructor( settings = settingsManager.currentSettings, cached = libraryRepository.getAudiobookshelf(), ) - when (afterProgressPull(state.selectedLibrary, state.libraries.map { it.id }, cached.map { it.id })) { + val pull = afterProgressPull( + selectedLibrary = state.selectedLibrary, + shownLibraryIds = state.libraries.map { it.id }, + cachedLibraryIds = cached.map { it.id }, + librariesRenamed = librariesRenamed(state.libraries, cached), + ) + when (pull) { AfterProgressPull.RELOAD_LIBRARIES -> libraryLoadLaunch.launch(viewModelScope) { loadLibrariesOwningRefresh() } AfterProgressPull.REFILTER -> applyFilter() diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryRenameReloadTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryRenameReloadTest.kt new file mode 100644 index 0000000..651c6d1 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryRenameReloadTest.kt @@ -0,0 +1,80 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.domain.model.Library +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A library renamed on the server keeps its id, so the id comparison saw an + * unchanged list and the selector kept showing the old name until the list + * itself changed. A name that differs for the same id now counts as a change. + */ +class LibraryRenameReloadTest { + + private val books = Library(id = "books", name = "Books") + private val podcasts = Library(id = "podcasts", name = "Podcasts") + + @Test + fun `a renamed library is noticed`() { + assertTrue(librariesRenamed(shown = listOf(books, podcasts), cached = listOf(books, podcasts.copy(name = "Shows")))) + } + + @Test + fun `an unchanged list is not a rename, whatever its order`() { + assertFalse(librariesRenamed(shown = listOf(books, podcasts), cached = listOf(books, podcasts))) + assertFalse(librariesRenamed(shown = listOf(books, podcasts), cached = listOf(podcasts, books))) + } + + @Test + fun `added and removed libraries are not renames`() { + // Membership changes are the id comparison's job. + assertFalse(librariesRenamed(shown = listOf(books), cached = listOf(books, podcasts))) + assertFalse(librariesRenamed(shown = listOf(books, podcasts), cached = listOf(podcasts))) + } + + @Test + fun `a rename reloads the list even though the ids match`() { + val renamed = librariesRenamed(shown = listOf(books, podcasts), cached = listOf(books.copy(name = "Audiobooks"), podcasts)) + assertTrue( + shouldReloadLibrariesAfterSync( + selectedLibrary = books, + shownLibraryIds = listOf("books", "podcasts"), + cachedLibraryIds = listOf("books", "podcasts"), + librariesRenamed = renamed, + ), + ) + assertEquals( + AfterProgressPull.RELOAD_LIBRARIES, + afterProgressPull( + selectedLibrary = books, + shownLibraryIds = listOf("books", "podcasts"), + cachedLibraryIds = listOf("books", "podcasts"), + librariesRenamed = renamed, + ), + ) + } + + @Test + fun `an unchanged list still only refilters`() { + val renamed = librariesRenamed(shown = listOf(books, podcasts), cached = listOf(podcasts, books)) + assertFalse( + shouldReloadLibrariesAfterSync( + selectedLibrary = books, + shownLibraryIds = listOf("books", "podcasts"), + cachedLibraryIds = listOf("podcasts", "books"), + librariesRenamed = renamed, + ), + ) + assertEquals( + AfterProgressPull.REFILTER, + afterProgressPull( + selectedLibrary = books, + shownLibraryIds = listOf("books", "podcasts"), + cachedLibraryIds = listOf("podcasts", "books"), + librariesRenamed = renamed, + ), + ) + } +} From eaf46e282795c8a16dff1e50373b708d2deb0589 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 12:35:32 -0400 Subject: [PATCH 02/23] The chapter list opens fast on books with hundreds of chapters, scrolled to the current one The chapter menu built every chapter up front. It is now a lazy list in a bottom sheet that only composes the rows on screen and opens at the chapter that is playing. Co-Authored-By: Claude Sonnet 5.5 --- .../app/ui/player/PlayerScreen.kt | 85 +++++++++++++++---- .../app/ui/player/ChapterListScrollTest.kt | 33 +++++++ 2 files changed, 100 insertions(+), 18 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/player/ChapterListScrollTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/player/PlayerScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/player/PlayerScreen.kt index c49b3a2..cedac4f 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/player/PlayerScreen.kt @@ -12,6 +12,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.graphics.graphicsLayer @@ -559,25 +561,72 @@ private fun ChapterSelectorButton( ) } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - containerColor = NineLivesTheme.colors.archiveVoidSurface, + } + + // Composed only while open, so a book with hundreds of chapters builds + // nothing until asked and then only the rows on screen. + if (expanded) { + ChapterSheet( + chapters = chapters, + currentChapterIndex = currentChapterIndex, + onChapterSelected = { + onChapterSelected(it) + expanded = false + }, + onDismiss = { expanded = false }, + ) + } +} + +/** The row the chapter list opens scrolled to: the chapter that is playing. */ +internal fun chapterListInitialIndex(currentChapterIndex: Int, chapterCount: Int): Int = + currentChapterIndex.coerceIn(0, (chapterCount - 1).coerceAtLeast(0)) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChapterSheet( + chapters: List, + currentChapterIndex: Int, + onChapterSelected: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = chapterListInitialIndex(currentChapterIndex, chapters.size), + ) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = NineLivesTheme.colors.archiveVoidSurface, + contentColor = NineLivesTheme.colors.archiveTextPrimary, + ) { + Text( + text = "Chapters", + style = MaterialTheme.typography.titleMedium, + color = NineLivesTheme.colors.goldFilament, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 20.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + LazyColumn( + state = listState, + modifier = Modifier.weight(1f, fill = false), + contentPadding = PaddingValues(bottom = 32.dp), ) { - chapters.forEachIndexed { index, chapter -> - DropdownMenuItem( - text = { - Text( - text = "${index + 1}. ${chapter.title}", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (index == currentChapterIndex) NineLivesTheme.colors.goldFilament else NineLivesTheme.colors.archiveTextPrimary, - ) - }, - onClick = { - onChapterSelected(index) - expanded = false - }, + itemsIndexed(chapters) { index, chapter -> + Text( + text = "${index + 1}. ${chapter.title}", + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (index == currentChapterIndex) NineLivesTheme.colors.goldFilament else NineLivesTheme.colors.archiveTextPrimary, + modifier = Modifier + .fillMaxWidth() + .clickable { onChapterSelected(index) } + .padding(horizontal = 20.dp, vertical = 14.dp), ) } } diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/player/ChapterListScrollTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/player/ChapterListScrollTest.kt new file mode 100644 index 0000000..36d7cc5 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/player/ChapterListScrollTest.kt @@ -0,0 +1,33 @@ +package com.ninelivesaudio.app.ui.player + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The chapter list opens already scrolled to the chapter that is playing, so + * a book with hundreds of chapters does not start at chapter 1. + */ +class ChapterListScrollTest { + + @Test + fun `opens at the current chapter`() { + assertEquals(0, chapterListInitialIndex(currentChapterIndex = 0, chapterCount = 300)) + assertEquals(137, chapterListInitialIndex(currentChapterIndex = 137, chapterCount = 300)) + assertEquals(299, chapterListInitialIndex(currentChapterIndex = 299, chapterCount = 300)) + } + + @Test + fun `no current chapter opens at the top`() { + assertEquals(0, chapterListInitialIndex(currentChapterIndex = -1, chapterCount = 300)) + } + + @Test + fun `a stale index past the end opens at the last chapter`() { + assertEquals(299, chapterListInitialIndex(currentChapterIndex = 450, chapterCount = 300)) + } + + @Test + fun `an empty list opens at the top`() { + assertEquals(0, chapterListInitialIndex(currentChapterIndex = 3, chapterCount = 0)) + } +} From 4841d3470b2896b68450b337ac3739be40512935 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 12:37:11 -0400 Subject: [PATCH 03/23] The Nightwatch Dossier stays smooth when Show all opens hundreds of books The Dossier was one scrolling column, so Show all built every book card and cover at once. It is now a lazy list that only builds the cards on screen. Co-Authored-By: Claude Sonnet 5.5 --- .../app/ui/dossier/NightwatchDossierScreen.kt | 388 ++++++++++-------- 1 file changed, 218 insertions(+), 170 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierScreen.kt index 7be0542..0356e9b 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierScreen.kt @@ -14,9 +14,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.Lock @@ -199,104 +201,137 @@ private fun DossierContent( isUnlocked: Boolean, onNavigateToUnlock: () -> Unit, ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 8.dp), + var booksExpanded by remember { mutableStateOf(false) } + + // Lazy, so Show all on a long history composes only the cards on screen. + // Several composables in one lazy item stack on top of each other, so each + // section item wraps its children in a spaced Column like the old one. + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - // ─── Period Selector ───────────────────────────────────────── - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - DossierPeriod.entries.forEach { period -> - // Greyed, never hidden, matching every other gate in the app. A - // free user should be able to see that longer windows exist and - // what they would be buying, not wonder why the Dossier only - // has one chip. - val locked = !FreeTier.allowsDossierPeriod(period, isUnlocked) - FilterChip( - selected = state.selectedPeriod == period, - onClick = { - if (locked) onNavigateToUnlock() else viewModel.onPeriodChanged(period) - }, - label = { - Text( - text = period.label, - style = MaterialTheme.typography.labelMedium, - modifier = if (locked) Modifier.alpha(0.5f) else Modifier, + item(key = "overview") { + DossierSection { + // ─── Period Selector ───────────────────────────────────────── + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + DossierPeriod.entries.forEach { period -> + // Greyed, never hidden, matching every other gate in the app. A + // free user should be able to see that longer windows exist and + // what they would be buying, not wonder why the Dossier only + // has one chip. + val locked = !FreeTier.allowsDossierPeriod(period, isUnlocked) + FilterChip( + selected = state.selectedPeriod == period, + onClick = { + if (locked) onNavigateToUnlock() else viewModel.onPeriodChanged(period) + }, + label = { + Text( + text = period.label, + style = MaterialTheme.typography.labelMedium, + modifier = if (locked) Modifier.alpha(0.5f) else Modifier, + ) + }, + // Same lock sigil GatedControl uses, so a gated chip reads + // as part of the existing vocabulary rather than a new badge. + trailingIcon = if (!locked) null else { + { + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = "${period.label} requires unlock", + modifier = Modifier + .size(14.dp) + .alpha(0.5f), + ) + } + }, + colors = FilterChipDefaults.filterChipColors( + containerColor = NineLivesTheme.colors.archiveVoidSurface, + selectedContainerColor = NineLivesTheme.colors.goldFilamentFaint, + labelColor = NineLivesTheme.colors.archiveTextSecondary, + selectedLabelColor = NineLivesTheme.colors.goldFilament, + ), + border = FilterChipDefaults.filterChipBorder( + borderColor = NineLivesTheme.colors.archiveOutline, + selectedBorderColor = NineLivesTheme.colors.goldFilament.copy(alpha = 0.4f), + enabled = true, + selected = state.selectedPeriod == period, + ), + shape = RoundedCornerShape(20.dp), ) - }, - // Same lock sigil GatedControl uses, so a gated chip reads - // as part of the existing vocabulary rather than a new badge. - trailingIcon = if (!locked) null else { - { - Icon( - imageVector = Icons.Outlined.Lock, - contentDescription = "${period.label} requires unlock", - modifier = Modifier - .size(14.dp) - .alpha(0.5f), - ) - } - }, - colors = FilterChipDefaults.filterChipColors( - containerColor = NineLivesTheme.colors.archiveVoidSurface, - selectedContainerColor = NineLivesTheme.colors.goldFilamentFaint, - labelColor = NineLivesTheme.colors.archiveTextSecondary, - selectedLabelColor = NineLivesTheme.colors.goldFilament, - ), - border = FilterChipDefaults.filterChipBorder( - borderColor = NineLivesTheme.colors.archiveOutline, - selectedBorderColor = NineLivesTheme.colors.goldFilament.copy(alpha = 0.4f), - enabled = true, - selected = state.selectedPeriod == period, - ), - shape = RoundedCornerShape(20.dp), - ) + } + } + + // ─── Overview ────────────────────────────────────────────── + OverviewSection(state, viewModel) } } - // ─── Overview ────────────────────────────────────────────── - OverviewSection(state, viewModel) - // ─── Book Stats ──────────────────────────────────────────── if (state.bookStats.isNotEmpty()) { - BookStatsSection(state.bookStats, viewModel) + bookStatsItems( + bookStats = state.bookStats, + expanded = booksExpanded, + onToggleExpanded = { booksExpanded = !booksExpanded }, + viewModel = viewModel, + ) } // ─── Narrators ───────────────────────────────────────────── if (state.narratorStats.isNotEmpty()) { - NarratorSection(state.narratorStats, state.narratorWhisper, viewModel) + item(key = "narrators") { + DossierSection { NarratorSection(state.narratorStats, state.narratorWhisper, viewModel) } + } } // ─── Authors ─────────────────────────────────────────────── if (state.authorStats.isNotEmpty()) { - AuthorSection(state.authorStats, state.authorWhisper, viewModel) + item(key = "authors") { + DossierSection { AuthorSection(state.authorStats, state.authorWhisper, viewModel) } + } } // ─── Genres ──────────────────────────────────────────────── if (state.genreStats.isNotEmpty()) { - GenreSection(state.genreStats, state.genreWhisper, viewModel) + item(key = "genres") { + DossierSection { GenreSection(state.genreStats, state.genreWhisper, viewModel) } + } } // ─── Temporal ────────────────────────────────────────────── if (state.hourlyDistribution.isNotEmpty()) { - TemporalSection(state, viewModel) + item(key = "temporal") { + DossierSection { TemporalSection(state, viewModel) } + } } // ─── Shareable Summary Card ──────────────────────────────── - DossierShareSection(state, viewModel) + item(key = "share") { + DossierSection { DossierShareSection(state, viewModel) } + } // Bottom padding - Spacer(modifier = Modifier.height(80.dp)) + item(key = "bottom-padding") { + Spacer(modifier = Modifier.height(80.dp)) + } } } +@Composable +private fun DossierSection(content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = content, + ) +} + // ═══════════════════════════════════════════════════════════════ // Overview Section // ═══════════════════════════════════════════════════════════════ @@ -392,134 +427,147 @@ private fun StatChip(value: String, label: String, highlight: Boolean = false) { // Book Stats Section // ═══════════════════════════════════════════════════════════════ -@Composable -private fun BookStatsSection( +/** + * The expanded list is one lazy item per book, so tapping Show all on a long + * listening history composes only the cards on screen. + */ +private fun LazyListScope.bookStatsItems( bookStats: List, + expanded: Boolean, + onToggleExpanded: () -> Unit, viewModel: NightwatchDossierViewModel, ) { - SectionHeader("Books") + item(key = "books-header") { SectionHeader("Books") } - var expanded by remember { mutableStateOf(false) } val visibleBooks = if (expanded || bookStats.size <= 5) bookStats else bookStats.take(5) + items(visibleBooks, key = { it.bookId }) { book -> + BookStatCard(book, viewModel) + } - visibleBooks.forEach { book -> - DossierCard { - Row( + if (bookStats.size > 5) { + item(key = "books-toggle") { + TextButton( + onClick = onToggleExpanded, modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - // Cover thumbnail - Box( - modifier = Modifier - .size(56.dp) - .clip(RoundedCornerShape(8.dp)) - .background(NineLivesTheme.colors.archiveVoidElevated), - ) { - BookCoverImage( - coverUrl = book.coverUrl, - contentDescription = book.title, - modifier = Modifier.fillMaxSize(), - title = book.title, - bookId = book.bookId, - ) - } - - Column(modifier = Modifier.weight(1f)) { - Text( - text = book.title, - style = MaterialTheme.typography.bodyMedium, - color = NineLivesTheme.colors.archiveTextPrimary, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = book.author, - style = MaterialTheme.typography.bodySmall, - color = NineLivesTheme.colors.archiveTextSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - book.narrator?.let { narrator -> - Text( - text = "Read by $narrator", - style = MaterialTheme.typography.labelSmall, - color = NineLivesTheme.colors.archiveTextMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - // Time badge - Column(horizontalAlignment = Alignment.End) { - Text( - text = viewModel.formatDuration(book.listeningTime), - style = MaterialTheme.typography.titleMedium, - color = NineLivesTheme.colors.goldFilament, - fontWeight = FontWeight.Bold, - ) - Text( - text = "${book.sessionCount} sessions", - style = MaterialTheme.typography.labelSmall, - color = NineLivesTheme.colors.archiveTextMuted, - ) - } + Text( + text = if (expanded) "Show less" else "Show all ${bookStats.size} books", + color = NineLivesTheme.colors.goldFilamentDim, + style = MaterialTheme.typography.labelMedium, + ) } + } + } +} - Spacer(modifier = Modifier.height(8.dp)) - - // Progress bar - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), +@Composable +private fun BookStatCard( + book: BookStat, + viewModel: NightwatchDossierViewModel, +) { + DossierCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Cover thumbnail + Box( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(8.dp)) + .background(NineLivesTheme.colors.archiveVoidElevated), ) { - LinearProgressIndicator( - progress = { (book.progress / 100.0).toFloat().coerceIn(0f, 1f) }, - modifier = Modifier - .weight(1f) - .height(4.dp) - .clip(RoundedCornerShape(2.dp)), - color = if (book.isFinished) NineLivesTheme.colors.archiveSuccess else NineLivesTheme.colors.goldFilament, - trackColor = NineLivesTheme.colors.archiveVoidElevated, + BookCoverImage( + coverUrl = book.coverUrl, + contentDescription = book.title, + modifier = Modifier.fillMaxSize(), + title = book.title, + bookId = book.bookId, ) + } - val progressLabel = when { - book.isFinished -> "Complete" - book.chaptersTotal > 0 && book.currentChapter > 0 -> - "Ch ${book.currentChapter}/${book.chaptersTotal}" - book.progress > 0 -> "${book.progress.toInt()}%" - else -> "" - } - if (progressLabel.isNotEmpty()) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = book.title, + style = MaterialTheme.typography.bodyMedium, + color = NineLivesTheme.colors.archiveTextPrimary, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = book.author, + style = MaterialTheme.typography.bodySmall, + color = NineLivesTheme.colors.archiveTextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + book.narrator?.let { narrator -> Text( - text = progressLabel, + text = "Read by $narrator", style = MaterialTheme.typography.labelSmall, - color = if (book.isFinished) NineLivesTheme.colors.archiveSuccess else NineLivesTheme.colors.archiveTextMuted, - fontSize = 10.sp, + color = NineLivesTheme.colors.archiveTextMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } - // Book whisper - book.whisper?.let { whisper -> - Spacer(modifier = Modifier.height(4.dp)) - WhisperText(whisper) + // Time badge + Column(horizontalAlignment = Alignment.End) { + Text( + text = viewModel.formatDuration(book.listeningTime), + style = MaterialTheme.typography.titleMedium, + color = NineLivesTheme.colors.goldFilament, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${book.sessionCount} sessions", + style = MaterialTheme.typography.labelSmall, + color = NineLivesTheme.colors.archiveTextMuted, + ) } } - } - if (bookStats.size > 5) { - TextButton( - onClick = { expanded = !expanded }, + Spacer(modifier = Modifier.height(8.dp)) + + // Progress bar + Row( modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = if (expanded) "Show less" else "Show all ${bookStats.size} books", - color = NineLivesTheme.colors.goldFilamentDim, - style = MaterialTheme.typography.labelMedium, + LinearProgressIndicator( + progress = { (book.progress / 100.0).toFloat().coerceIn(0f, 1f) }, + modifier = Modifier + .weight(1f) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + color = if (book.isFinished) NineLivesTheme.colors.archiveSuccess else NineLivesTheme.colors.goldFilament, + trackColor = NineLivesTheme.colors.archiveVoidElevated, ) + + val progressLabel = when { + book.isFinished -> "Complete" + book.chaptersTotal > 0 && book.currentChapter > 0 -> + "Ch ${book.currentChapter}/${book.chaptersTotal}" + book.progress > 0 -> "${book.progress.toInt()}%" + else -> "" + } + if (progressLabel.isNotEmpty()) { + Text( + text = progressLabel, + style = MaterialTheme.typography.labelSmall, + color = if (book.isFinished) NineLivesTheme.colors.archiveSuccess else NineLivesTheme.colors.archiveTextMuted, + fontSize = 10.sp, + ) + } + } + + // Book whisper + book.whisper?.let { whisper -> + Spacer(modifier = Modifier.height(4.dp)) + WhisperText(whisper) } } } From e6a14750e549cfa4d4e79046f110f4e46e53047b Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 12:39:51 -0400 Subject: [PATCH 04/23] Cover glows skip unstarted books and hold still with reduced motion The glow around a cover no longer draws or animates at zero progress, so a shelf of unstarted books runs no per-row animation. With reduced motion on (system animation scale at zero, or the in-app toggle) it is drawn once and held at a middle brightness. Its paints, paths and path effects are built once and reused, so a frame only changes alphas, instead of allocating four paints, three filters and two paths every frame. Co-Authored-By: Claude Sonnet 5.5 --- .../components/FluorescentSquareProgress.kt | 423 +++++++++++------- .../ui/components/FluorescentGlowModeTest.kt | 43 ++ 2 files changed, 312 insertions(+), 154 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/components/FluorescentGlowModeTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/components/FluorescentSquareProgress.kt b/app/src/main/java/com/ninelivesaudio/app/ui/components/FluorescentSquareProgress.kt index 914677c..6b0494f 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/components/FluorescentSquareProgress.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/components/FluorescentSquareProgress.kt @@ -2,6 +2,7 @@ package com.ninelivesaudio.app.ui.components import android.graphics.BlurMaskFilter import android.graphics.DashPathEffect +import android.graphics.Paint import android.graphics.PathMeasure import androidx.compose.animation.core.EaseInOutSine import androidx.compose.animation.core.RepeatMode @@ -11,26 +12,63 @@ import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.asAndroidPath import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.ninelivesaudio.app.ui.components.unhinged.LocalUnhingedSettings import com.ninelivesaudio.app.ui.theme.NineLivesTheme +import kotlin.math.roundToInt + +/** How the glow is drawn right now. */ +internal enum class GlowMode { + /** Nothing to light, so nothing is drawn and no animation runs. */ + Off, + + /** Lit and held at a middle brightness, for people who asked for less motion. */ + Still, + + /** Lit and breathing slowly. */ + Breathing, +} + +/** + * At zero progress there is no arc to light, and a list of unstarted books + * would otherwise run an animation per row for nothing. With reduced motion + * the arc is drawn once and left alone. + */ +internal fun glowMode(progress: Float, reduceMotion: Boolean): GlowMode = when { + !(progress > 0f) -> GlowMode.Off + reduceMotion -> GlowMode.Still + else -> GlowMode.Breathing +} + +/** Breath position (0 dim, 1 bright) the glow holds at under reduced motion. */ +private const val STILL_BREATH = 0.5f + +private const val BREATH_ALPHA_DIM = 0.55f +private const val BREATH_ALPHA_BRIGHT = 1.00f +private const val BREATH_BLOOM_DIM_DP = 20f +private const val BREATH_BLOOM_BRIGHT_DP = 38f /** * Fluorescent rounded-square progress indicator with a multi-layer glow effect - * and continuous breathing animation. + * and a slow breathing animation. * * Draws a stroked rounded-rectangle path clipped to [progress] (0.0–1.0) with * four stacked layers — outer bleed, mid corona, core tube, and hot filament — - * all modulated by a slow breathing animation. + * all modulated by a slow breathing animation. At zero progress nothing is + * drawn and nothing animates. With reduced motion (the system animation scale + * at zero, or the in-app toggle) the glow is drawn once and held still. * * The progress arc starts and ends at the top-center of the rounded square. * @@ -49,177 +87,254 @@ fun FluorescentSquareProgress( strokeScale: Float = 1.0f, ) { val clampedProgress = progress.coerceIn(0f, 1f) - val density = LocalDensity.current - val glowColor = color - - // ── Breathing animation (continuous, regardless of progress) ────────── - val infiniteTransition = rememberInfiniteTransition(label = "fluorescent_breath") - - val breathAlpha by infiniteTransition.animateFloat( - initialValue = 0.55f, - targetValue = 1.00f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 3000, easing = EaseInOutSine), - repeatMode = RepeatMode.Reverse, - ), - label = "breath_alpha", - ) - - val breathBloom by infiniteTransition.animateFloat( - initialValue = 20f, - targetValue = 38f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 3000, easing = EaseInOutSine), - repeatMode = RepeatMode.Reverse, - ), - label = "breath_bloom", - ) + val reduceMotion = LocalUnhingedSettings.current.reduceMotionRequested + val mode = glowMode(clampedProgress, reduceMotion) + if (mode == GlowMode.Off) return + + val glowArgb = color.toArgb() + + // ── Breathing animation ────────────────────────────────────────────── + // One 0..1 position drives both the brightness and the bloom, since the + // two always moved on the same clock. Under reduced motion there is no + // transition at all, only a held value. + val breath: State = if (mode == GlowMode.Breathing) { + rememberInfiniteTransition(label = "fluorescent_breath").animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 3000, easing = EaseInOutSine), + repeatMode = RepeatMode.Reverse, + ), + label = "breath", + ) + } else { + remember { mutableFloatStateOf(STILL_BREATH) } + } + + // The paints, paths and path effects live here and are rebuilt only when + // the size, colour or progress change. A frame of the breathing animation + // only changes alphas (and, a pixel at a time, the outer blur). + val cache = remember { GlowDrawCache() } Canvas(modifier = modifier) { - val paddingPx = with(density) { padding.toPx() } - val cornerRadiusPx = with(density) { cornerRadius.toPx() } + val ready = cache.prepare( + width = size.width, + height = size.height, + paddingPx = padding.toPx(), + cornerRadiusPx = cornerRadius.toPx(), + strokeScale = strokeScale, + density = density, + argb = glowArgb, + progress = clampedProgress, + ) + if (!ready) return@Canvas + + drawIntoCanvas { canvas -> cache.draw(canvas.nativeCanvas, breath.value) } + } +} + +/** + * Everything [FluorescentSquareProgress] draws with, kept between frames. + * [prepare] rebuilds only what the inputs changed, and [draw] touches nothing + * but the per-frame alphas, so a frame allocates next to nothing. + */ +private class GlowDrawCache { + private val roundRect = Path() + private val bleedRect = Path() + private val nativeRoundRect = roundRect.asAndroidPath() + private val nativeBleedRect = bleedRect.asAndroidPath() + private val measure = PathMeasure() + + private val bleedPaint = strokePaint() + private val coronaPaint = strokePaint() + private val corePaint = strokePaint() + private val filamentPaint = strokePaint() + + private var lastWidth = Float.NaN + private var lastHeight = Float.NaN + private var lastPaddingPx = Float.NaN + private var lastCornerRadiusPx = Float.NaN + private var lastStrokeScale = Float.NaN + private var lastDensity = Float.NaN + private var lastArgb = 0 + private var lastProgress = Float.NaN + private var usable = false + + private var scatterPx = 0f + private var bloomScale = 0f + private var lastBloomPx = -1f + /** Rebuilds what changed. False when there is nothing to draw at this size. */ + fun prepare( + width: Float, + height: Float, + paddingPx: Float, + cornerRadiusPx: Float, + strokeScale: Float, + density: Float, + argb: Int, + progress: Float, + ): Boolean { + if (width == lastWidth && height == lastHeight && paddingPx == lastPaddingPx && + cornerRadiusPx == lastCornerRadiusPx && strokeScale == lastStrokeScale && + density == lastDensity && argb == lastArgb && progress == lastProgress + ) { + return usable + } + lastWidth = width + lastHeight = height + lastPaddingPx = paddingPx + lastCornerRadiusPx = cornerRadiusPx + lastStrokeScale = strokeScale + lastDensity = density + lastArgb = argb + lastProgress = progress + usable = rebuild(width, height, paddingPx, cornerRadiusPx, strokeScale, density, argb, progress) + return usable + } + + private fun rebuild( + width: Float, + height: Float, + paddingPx: Float, + cornerRadiusPx: Float, + strokeScale: Float, + density: Float, + argb: Int, + progress: Float, + ): Boolean { val left = paddingPx val top = paddingPx - val right = size.width - paddingPx - val bottom = size.height - paddingPx + val right = width - paddingPx + val bottom = height - paddingPx val rectWidth = right - left val rectHeight = bottom - top + if (rectWidth <= 0f || rectHeight <= 0f) return false - if (rectWidth <= 0f || rectHeight <= 0f) return@Canvas - - // ── Build the rounded-rect path ────────────────────────────────── - val roundRectPath = Path().apply { - addRoundRect( - androidx.compose.ui.geometry.RoundRect( - left = left, - top = top, - right = right, - bottom = bottom, - radiusX = cornerRadiusPx, - radiusY = cornerRadiusPx, - ), - ) - close() - } + // ── The rounded-rect path ──────────────────────────────────────── + roundRect.reset() + roundRect.addRoundRect( + RoundRect( + left = left, + top = top, + right = right, + bottom = bottom, + radiusX = cornerRadiusPx, + radiusY = cornerRadiusPx, + ), + ) + roundRect.close() // ── Measure path and compute top-center phase offset ───────────── - val nativePath = roundRectPath.asAndroidPath() - val pathMeasure = PathMeasure(nativePath, true) - val totalLength = pathMeasure.length - if (totalLength <= 0f) return@Canvas + measure.setPath(nativeRoundRect, true) + val totalLength = measure.length + if (totalLength <= 0f) return false // addRoundRect starts at top-left corner. The distance from top-left // corner to top-center is: cornerRadius + (rectWidth / 2 - cornerRadius) // = rectWidth / 2. So the phase offset is rectWidth / 2. val phaseOffset = rectWidth / 2f - - val drawLength = clampedProgress * totalLength + val drawLength = progress * totalLength // ── Outset path for the outer bleed layer ──────────────────────── - val outsetPx = with(density) { (8.dp * strokeScale).toPx() } - val bleedPath = Path().apply { - addRoundRect( - androidx.compose.ui.geometry.RoundRect( - left = left - outsetPx, - top = top - outsetPx, - right = right + outsetPx, - bottom = bottom + outsetPx, - radiusX = cornerRadiusPx + outsetPx, - radiusY = cornerRadiusPx + outsetPx, - ), - ) - close() - } - val bleedNativePath = bleedPath.asAndroidPath() - val bleedMeasure = PathMeasure(bleedNativePath, true) - val bleedTotalLength = bleedMeasure.length - val bleedDrawLength = clampedProgress * bleedTotalLength + val outsetPx = 8f * strokeScale * density + bleedRect.reset() + bleedRect.addRoundRect( + RoundRect( + left = left - outsetPx, + top = top - outsetPx, + right = right + outsetPx, + bottom = bottom + outsetPx, + radiusX = cornerRadiusPx + outsetPx, + radiusY = cornerRadiusPx + outsetPx, + ), + ) + bleedRect.close() + measure.setPath(nativeBleedRect, true) + val bleedTotalLength = measure.length + val bleedDrawLength = progress * bleedTotalLength val bleedPhaseOffset = (rectWidth + outsetPx * 2) / 2f - // ── Helper to create dash effect (segment + gap covering rest) ─── - fun dashEffect(segmentLen: Float, total: Float, phase: Float): DashPathEffect { - val seg = segmentLen.coerceAtLeast(0.01f) - val gap = (total - seg).coerceAtLeast(0.01f) - return DashPathEffect(floatArrayOf(seg, gap), total - phase) + // ── Strokes ───────────────────────────────────────────────────── + val rgb = argb or OPAQUE + + bleedPaint.color = rgb + bleedPaint.strokeWidth = 6f * strokeScale * density + bleedPaint.pathEffect = dashEffect(bleedDrawLength, bleedTotalLength, bleedPhaseOffset) + // The bloom radius follows the breath, so draw() sets the filter. + bleedPaint.maskFilter = null + lastBloomPx = -1f + bloomScale = strokeScale * density + scatterPx = 1.5f * strokeScale * density + + coronaPaint.color = rgb + coronaPaint.strokeWidth = 4f * strokeScale * density + coronaPaint.maskFilter = BlurMaskFilter(12f * strokeScale * density, BlurMaskFilter.Blur.NORMAL) + coronaPaint.pathEffect = dashEffect(drawLength, totalLength, phaseOffset) + + corePaint.color = rgb + corePaint.strokeWidth = 3f * strokeScale * density + corePaint.maskFilter = BlurMaskFilter(4f * strokeScale * density, BlurMaskFilter.Blur.NORMAL) + corePaint.pathEffect = dashEffect(drawLength, totalLength, phaseOffset) + + filamentPaint.color = FILAMENT_RGB + filamentPaint.strokeWidth = 1.5f * strokeScale * density + filamentPaint.pathEffect = dashEffect(drawLength, totalLength, phaseOffset) + return true + } + + /** One frame at [breath] (0 dim, 1 bright). */ + fun draw(canvas: android.graphics.Canvas, breath: Float) { + val breathAlpha = lerp(BREATH_ALPHA_DIM, BREATH_ALPHA_BRIGHT, breath) + val bloomPx = lerp(BREATH_BLOOM_DIM_DP, BREATH_BLOOM_BRIGHT_DP, breath) * bloomScale + // A blur filter's radius is fixed when it is made, so this is the one + // thing that cannot be reused every frame. Whole pixels are finer than + // the eye can follow on a 3 second breath, and cost far fewer filters. + val bloomWhole = bloomPx.roundToInt().coerceAtLeast(1).toFloat() + if (bloomWhole != lastBloomPx) { + bleedPaint.maskFilter = BlurMaskFilter(bloomWhole, BlurMaskFilter.Blur.NORMAL) + lastBloomPx = bloomWhole } - val bloomPx = with(density) { (breathBloom * strokeScale).dp.toPx() } - - drawIntoCanvas { canvas -> - val nativeCanvas = canvas.nativeCanvas - - // ── Layer 1: Outer bleed (drawn twice with offset for scatter) ── - val bleedPaint = android.graphics.Paint().apply { - isAntiAlias = true - style = android.graphics.Paint.Style.STROKE - strokeWidth = with(density) { (6.dp * strokeScale).toPx() } - setColor(Color( - red = glowColor.red, - green = glowColor.green, - blue = glowColor.blue, - alpha = 0.22f * breathAlpha, - ).toArgb()) - maskFilter = BlurMaskFilter(bloomPx, BlurMaskFilter.Blur.NORMAL) - pathEffect = dashEffect(bleedDrawLength, bleedTotalLength, bleedPhaseOffset) - } - nativeCanvas.drawPath(bleedNativePath, bleedPaint) - - // Second bleed draw with slight offset for light scatter - nativeCanvas.save() - val scatterPx = with(density) { (1.5.dp * strokeScale).toPx() } - nativeCanvas.translate(scatterPx, -scatterPx) - nativeCanvas.drawPath(bleedNativePath, bleedPaint) - nativeCanvas.restore() - - // ── Layer 2: Mid corona ───────────────────────────────────────── - val coronaPaint = android.graphics.Paint().apply { - isAntiAlias = true - style = android.graphics.Paint.Style.STROKE - strokeWidth = with(density) { (4.dp * strokeScale).toPx() } - setColor(Color( - red = glowColor.red, - green = glowColor.green, - blue = glowColor.blue, - alpha = 0.45f * breathAlpha, - ).toArgb()) - maskFilter = BlurMaskFilter( - with(density) { (12.dp * strokeScale).toPx() }, - BlurMaskFilter.Blur.NORMAL, - ) - pathEffect = dashEffect(drawLength, totalLength, phaseOffset) - } - nativeCanvas.drawPath(nativePath, coronaPaint) - - // ── Layer 3: Core tube ────────────────────────────────────────── - val corePaint = android.graphics.Paint().apply { - isAntiAlias = true - style = android.graphics.Paint.Style.STROKE - strokeWidth = with(density) { (3.dp * strokeScale).toPx() } - setColor(Color( - red = glowColor.red, - green = glowColor.green, - blue = glowColor.blue, - alpha = 0.90f * breathAlpha, - ).toArgb()) - maskFilter = BlurMaskFilter( - with(density) { (4.dp * strokeScale).toPx() }, - BlurMaskFilter.Blur.NORMAL, - ) - pathEffect = dashEffect(drawLength, totalLength, phaseOffset) - } - nativeCanvas.drawPath(nativePath, corePaint) - - // ── Layer 4: Hot filament ─────────────────────────────────────── - val filamentColor = Color(0xFFFFF0C0) // Warm near-white - val filamentPaint = android.graphics.Paint().apply { - isAntiAlias = true - style = android.graphics.Paint.Style.STROKE - strokeWidth = with(density) { (1.5.dp * strokeScale).toPx() } - setColor(filamentColor.copy(alpha = 0.75f * breathAlpha).toArgb()) - pathEffect = dashEffect(drawLength, totalLength, phaseOffset) - } - nativeCanvas.drawPath(nativePath, filamentPaint) + bleedPaint.alpha = alphaOf(0.22f * breathAlpha) + coronaPaint.alpha = alphaOf(0.45f * breathAlpha) + corePaint.alpha = alphaOf(0.90f * breathAlpha) + filamentPaint.alpha = alphaOf(0.75f * breathAlpha) + + // ── Layer 1: Outer bleed (drawn twice with offset for scatter) ── + canvas.drawPath(nativeBleedRect, bleedPaint) + + // Second bleed draw with slight offset for light scatter + canvas.save() + canvas.translate(scatterPx, -scatterPx) + canvas.drawPath(nativeBleedRect, bleedPaint) + canvas.restore() + + // ── Layers 2 to 4: Mid corona, core tube, hot filament ────────── + canvas.drawPath(nativeRoundRect, coronaPaint) + canvas.drawPath(nativeRoundRect, corePaint) + canvas.drawPath(nativeRoundRect, filamentPaint) + } + + private fun dashEffect(segmentLen: Float, total: Float, phase: Float): DashPathEffect { + val seg = segmentLen.coerceAtLeast(0.01f) + val gap = (total - seg).coerceAtLeast(0.01f) + return DashPathEffect(floatArrayOf(seg, gap), total - phase) + } + + private fun alphaOf(fraction: Float): Int = (fraction * 255f).roundToInt().coerceIn(0, 255) + + private fun lerp(from: Float, to: Float, t: Float): Float = from + (to - from) * t + + private companion object { + const val OPAQUE = 0xFF000000.toInt() + + /** Warm near-white for the hot filament. */ + const val FILAMENT_RGB = 0xFFFFF0C0.toInt() + + fun strokePaint() = Paint().apply { + isAntiAlias = true + style = Paint.Style.STROKE } } } diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/components/FluorescentGlowModeTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/components/FluorescentGlowModeTest.kt new file mode 100644 index 0000000..df97b89 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/components/FluorescentGlowModeTest.kt @@ -0,0 +1,43 @@ +package com.ninelivesaudio.app.ui.components + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The glow around a cover is skipped at zero progress, holds still when the + * user wants less motion, and breathes the rest of the time. + */ +class FluorescentGlowModeTest { + + @Test + fun `no progress draws nothing`() { + assertEquals(GlowMode.Off, glowMode(progress = 0f, reduceMotion = false)) + } + + @Test + fun `no progress draws nothing even when motion is reduced`() { + assertEquals(GlowMode.Off, glowMode(progress = 0f, reduceMotion = true)) + } + + @Test + fun `a progress that is not a number draws nothing`() { + assertEquals(GlowMode.Off, glowMode(progress = Float.NaN, reduceMotion = false)) + assertEquals(GlowMode.Off, glowMode(progress = -0.2f, reduceMotion = false)) + } + + @Test + fun `progress breathes by default`() { + assertEquals(GlowMode.Breathing, glowMode(progress = 0.4f, reduceMotion = false)) + assertEquals(GlowMode.Breathing, glowMode(progress = 1f, reduceMotion = false)) + } + + @Test + fun `progress holds still when motion is reduced`() { + assertEquals(GlowMode.Still, glowMode(progress = 0.4f, reduceMotion = true)) + } + + @Test + fun `the tiniest progress still counts`() { + assertEquals(GlowMode.Breathing, glowMode(progress = 0.0001f, reduceMotion = false)) + } +} From 2e2ca97592c973258c9dafcdbb6c3e6d9d676431 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 12:41:42 -0400 Subject: [PATCH 05/23] Covers in long lists load without a subcomposed slot per row BookCoverImage now uses AsyncImage with the vintage placeholder composed underneath it, instead of SubcomposeAsyncImage. The placeholder still shows while a cover loads and stays when the cover fails, but each row no longer runs a subcomposition for it. Co-Authored-By: Claude Sonnet 5.5 --- .../app/ui/components/BookCoverImage.kt | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/components/BookCoverImage.kt b/app/src/main/java/com/ninelivesaudio/app/ui/components/BookCoverImage.kt index 89cbf65..6a421c0 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/components/BookCoverImage.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/components/BookCoverImage.kt @@ -1,19 +1,24 @@ package com.ninelivesaudio.app.ui.components +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale -import coil.compose.SubcomposeAsyncImage -import coil.compose.SubcomposeAsyncImageContent +import coil.compose.AsyncImage import java.net.URI /** * Displays an audiobook cover image, falling back to a vintage worn-book * placeholder when no cover URL is available OR when the image fails to load. * - * Uses SubcomposeAsyncImage so that broken/missing server URLs also - * show the vintage placeholder instead of a blank rectangle. + * Uses AsyncImage with the placeholder composed underneath it, so the + * placeholder shows while the cover loads and stays if the server URL is + * broken, and a long list of these does not subcompose a slot per row. * * [thumbnailWidthPx] asks the server for a smaller cover. Long lists of small * rows pass it, while book detail and the player leave it null and get the @@ -34,30 +39,26 @@ fun BookCoverImage( val seed = bookId?.hashCode() ?: title?.hashCode() ?: 0 if (!coverUrl.isNullOrEmpty()) { - SubcomposeAsyncImage( - model = imageModel - ?: thumbnailWidthPx?.let { thumbnailCoverUrl(coverUrl, it) } - ?: coverUrl, - contentDescription = contentDescription, - modifier = modifier, - contentScale = contentScale, - alignment = alignment, - loading = { - VintageBookPlaceholder( - title = title, - seed = seed, - ) - }, - error = { + // Keyed on the URL, so a recycled row that now shows another book + // starts from its placeholder again. + var loaded by remember(coverUrl, thumbnailWidthPx) { mutableStateOf(false) } + Box(modifier = modifier, propagateMinConstraints = true) { + if (!loaded) { VintageBookPlaceholder( title = title, seed = seed, ) - }, - success = { - SubcomposeAsyncImageContent() - }, - ) + } + AsyncImage( + model = imageModel + ?: thumbnailWidthPx?.let { thumbnailCoverUrl(coverUrl, it) } + ?: coverUrl, + contentDescription = contentDescription, + contentScale = contentScale, + alignment = alignment, + onSuccess = { loaded = true }, + ) + } } else { VintageBookPlaceholder( modifier = modifier, From b8abcc0d650350a32180b6cc38f2e5cac9976119 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 12:41:42 -0400 Subject: [PATCH 06/23] Library rows read once to TalkBack, with progress and downloads in words Each row used to read its title twice (cover and text) and left the progress ring and downloaded dot unlabeled. A row now reads as one sentence, like "Dune, by Frank Herbert, 42 percent listened, chapter 3 of 10, downloaded", and the search box has a steady label instead of depending on its flavor hint. Co-Authored-By: Claude Sonnet 5.5 --- .../app/ui/library/LibraryScreen.kt | 42 ++++++- .../app/ui/library/LibraryRowSemanticsTest.kt | 118 ++++++++++++++++++ 2 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryRowSemanticsTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryScreen.kt index 8c65fcb..bb129e4 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryScreen.kt @@ -34,7 +34,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.onClick as onClickAction import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight @@ -342,6 +345,9 @@ internal fun RefreshableEmptyLibraryContent( // ─── Relic Search Bar ──────────────────────────────────────────────────── +/** What TalkBack calls the search box. */ +internal const val SEARCH_FIELD_LABEL = "Search the library" + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun RelicSearchBar( @@ -358,6 +364,9 @@ private fun RelicSearchBar( modifier = modifier .fillMaxWidth() .height(48.dp) + // The hint inside the box changes with the copy mode, so give + // TalkBack one steady name for the field. + .semantics { contentDescription = SEARCH_FIELD_LABEL } .shadow( elevation = if (isFocused) 6.dp else 2.dp, shape = RoundedCornerShape(12.dp), @@ -772,6 +781,28 @@ internal fun GroupHeaderRow( // ─── Archive Book List Item ────────────────────────────────────────────── +/** + * What TalkBack reads for a Library row, as one sentence: the title once, then + * the author, whether it is archived, how far along it is in words, and whether + * it is on the device. The row's pieces (cover, ring, dots, percent text) are + * hidden from TalkBack so none of them repeat it. + */ +internal fun bookRowDescription(book: AudioBook): String = buildList { + add(book.title) + if (book.author.isNotBlank()) add("by ${book.author}") + if (book.isArchived) add("archived") + if (book.isFinished) { + add("finished") + } else if (book.hasProgress) { + add("${book.progressPercent.toInt()} percent listened") + if (book.chapters.isNotEmpty()) { + val chapterIdx = book.getCurrentChapterIndex() + if (chapterIdx >= 0) add("chapter ${chapterIdx + 1} of ${book.chapters.size}") + } + } + if (book.isDownloaded) add("downloaded") +}.joinToString(", ") + @Composable private fun ArchiveBookListItem( book: AudioBook, @@ -793,10 +824,16 @@ private fun ArchiveBookListItem( if (showWhisper) BookWhisperCatalog.getWhisper(book, whisperEpoch) else null } + val description = remember(book) { bookRowDescription(book) } + Column( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick), + .clickable(onClick = onClick) + .clearAndSetSemantics { + contentDescription = description + onClickAction { onClick(); true } + }, ) { Row( modifier = Modifier @@ -822,7 +859,8 @@ private fun ArchiveBookListItem( ) { BookCoverImage( coverUrl = book.effectiveCoverPath, - contentDescription = book.title, + // The row's description already names the book. + contentDescription = null, modifier = Modifier.fillMaxSize(), title = book.title, bookId = book.id, diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryRowSemanticsTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryRowSemanticsTest.kt new file mode 100644 index 0000000..c9dd5c2 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryRowSemanticsTest.kt @@ -0,0 +1,118 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.Chapter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +/** What TalkBack reads for a Library row, and for the search box above it. */ +class LibraryRowSemanticsTest { + + private fun chapters(count: Int) = + (0 until count).map { Chapter(id = it, start = it * 100.0, end = (it + 1) * 100.0, title = "Ch $it") } + + @Test + fun `a new book reads its title and author once`() { + val book = AudioBook(id = "1", title = "Dune", author = "Frank Herbert") + + assertEquals("Dune, by Frank Herbert", bookRowDescription(book)) + } + + @Test + fun `the title is said once even when it is long`() { + val book = AudioBook(id = "1", title = "The Long Way", author = "Becky Chambers") + + val spoken = bookRowDescription(book) + + assertEquals(1, Regex("The Long Way").findAll(spoken).count()) + } + + @Test + fun `a book with no author reads just the title`() { + assertEquals("Dune", bookRowDescription(AudioBook(id = "1", title = "Dune", author = " "))) + } + + @Test + fun `progress is said in words`() { + val book = AudioBook(id = "1", title = "Dune", author = "Frank Herbert", progress = 0.42) + + assertEquals("Dune, by Frank Herbert, 42 percent listened", bookRowDescription(book)) + } + + @Test + fun `progress that came from the server as a whole percent reads the same`() { + val book = AudioBook(id = "1", title = "Dune", author = "Frank Herbert", progress = 42.0) + + assertEquals("Dune, by Frank Herbert, 42 percent listened", bookRowDescription(book)) + } + + @Test + fun `the chapter is said in words after the progress`() { + val book = AudioBook( + id = "1", title = "Dune", author = "Frank Herbert", progress = 0.42, + chapters = chapters(10), currentTime = 250.seconds, + ) + + assertEquals( + "Dune, by Frank Herbert, 42 percent listened, chapter 3 of 10", + bookRowDescription(book), + ) + } + + @Test + fun `a downloaded book says so`() { + val book = AudioBook(id = "1", title = "Dune", author = "Frank Herbert", isDownloaded = true) + + assertEquals("Dune, by Frank Herbert, downloaded", bookRowDescription(book)) + } + + @Test + fun `a finished book says finished instead of a percent`() { + val book = AudioBook( + id = "1", title = "Dune", author = "Frank Herbert", progress = 1.0, isFinished = true, + ) + + assertEquals("Dune, by Frank Herbert, finished", bookRowDescription(book)) + } + + @Test + fun `an archived book says so`() { + val book = AudioBook(id = "1", title = "Dune", author = "Frank Herbert", archivedAt = 5L) + + assertEquals("Dune, by Frank Herbert, archived", bookRowDescription(book)) + } + + @Test + fun `everything together reads in a steady order`() { + val book = AudioBook( + id = "1", title = "Dune", author = "Frank Herbert", progress = 0.5, + chapters = chapters(4), currentTime = 150.seconds, + isDownloaded = true, archivedAt = 5L, + ) + + assertEquals( + "Dune, by Frank Herbert, archived, 50 percent listened, chapter 2 of 4, downloaded", + bookRowDescription(book), + ) + } + + @Test + fun `no row description uses a percent sign or a bullet`() { + val book = AudioBook( + id = "1", title = "Dune", author = "Frank Herbert", progress = 0.5, + chapters = chapters(4), currentTime = 150.seconds, isDownloaded = true, + ) + + val spoken = bookRowDescription(book) + + assertFalse(spoken.contains('%')) + assertFalse(spoken.contains('•')) + } + + @Test + fun `the search box has a label that does not depend on the flavor text`() { + assertEquals("Search the library", SEARCH_FIELD_LABEL) + } +} From b5fa91c22d69f6daa6239e190f9a7a748b1d4ff0 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 12:43:48 -0400 Subject: [PATCH 07/23] Deleting a download never takes another book's files with it (#49) Delete used to wipe whatever folder the book row pointed at, recursively. Two editions with the same author and title share one folder, so deleting either one took the other's audio too, and a stale path could point anywhere. Delete now runs through the same fences as cancel cleanup: the folder must be a plain direct child of the download root, a user-picked root is left alone, and nothing is ever deleted recursively. When another downloaded book shares the folder, only this book's own tracks go and the cover stays for the other one. A partial download of another book writing there keeps everything. Delete also holds the row lock like cancel, and treats the engine being on the book as downloading. Co-Authored-By: Claude Opus 5.5 --- .../app/data/local/dao/AudioBookDao.kt | 10 + .../app/service/DownloadManager.kt | 79 ++++- .../app/service/download/CancelCleanup.kt | 181 +++++++++-- .../app/service/download/DownloadEngine.kt | 13 +- .../app/service/download/DownloadPolicies.kt | 7 + .../service/download/DownloadDeleteTest.kt | 286 ++++++++++++++++++ 6 files changed, 542 insertions(+), 34 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt index 462eeea..29d64fd 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt @@ -71,6 +71,16 @@ interface AudioBookDao { @Query("SELECT LocalPath FROM AudioBooks WHERE Id != :excludeId AND LocalPath IS NOT NULL AND LocalPath != ''") suspend fun getLocalPathsExcept(excludeId: String): List + /** + * Ids of every other book stored at a file path (not a content URI), for + * the shared-folder check on delete. Only those rows need their track lists. + */ + @Query( + "SELECT Id FROM AudioBooks WHERE Id != :excludeId AND LocalPath IS NOT NULL AND LocalPath != '' " + + "AND LocalPath NOT LIKE 'content:%'" + ) + suspend fun getFilePathBookIdsExcept(excludeId: String): List + @Query("SELECT * FROM AudioBooks WHERE Id = :id") fun observeById(id: String): Flow diff --git a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt index 34cadc2..d61443c 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt @@ -19,6 +19,9 @@ import com.ninelivesaudio.app.service.download.applyCancelCleanup import com.ninelivesaudio.app.service.download.cancelCleanupStillClear import com.ninelivesaudio.app.service.download.cancelMayTouchFiles import com.ninelivesaudio.app.service.download.decideCancelCleanup +import com.ninelivesaudio.app.service.download.deleteDownloadFiles +import com.ninelivesaudio.app.service.download.downloadedFileNames +import com.ninelivesaudio.app.service.download.FolderSharer import com.ninelivesaudio.app.service.download.DownloadEngine import com.ninelivesaudio.app.service.download.DownloadNotifications import com.ninelivesaudio.app.service.download.DownloadQueueWorker @@ -44,7 +47,6 @@ import kotlinx.coroutines.guava.await import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import java.io.File import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -662,10 +664,18 @@ class DownloadManager @Inject constructor( /** Delete a download's files and DB record. */ suspend fun deleteDownload(audioBookId: String): Unit = finishInOwnerScope(scope) { - val downloadEntity = downloadItemDao.getByAudioBookId(audioBookId) - val wasDownloading = downloadEntity?.status == DownloadStatus.Downloading.ordinal - writeAfterEngineStops(wasDownloading, { stopDrainAndAwait() }) { - deleteDownloadedFilesAndRows(audioBookId, downloadEntity?.id) + // Held through the file deletion like cancel, so no resume or drain + // start lands on this book while its files are being judged. + val wasDownloading = rowMutex.withLock { + val downloadEntity = downloadItemDao.getByAudioBookId(audioBookId) + // The drain can pick a Queued row at any moment, so the engine + // already being on the book counts as downloading too. + val downloading = downloadEntity?.status == DownloadStatus.Downloading.ordinal || + engineBookId == audioBookId + writeAfterEngineStops(downloading, { stopDrainAndAwait() }) { + deleteDownloadedFilesAndRows(audioBookId, downloadEntity?.id) + } + downloading } if (wasDownloading) { // Restart the drain for whatever else is queued. @@ -680,16 +690,14 @@ class DownloadManager @Inject constructor( // set by the engine (basePath/Author - Title), not basePath/audioBookId. val bookEntity = audioBookDao.getById(audioBookId) - withContext(Dispatchers.IO) { - val localPath = bookEntity?.localPath - if (!localPath.isNullOrEmpty()) { - File(localPath).deleteRecursively() - } + val localPath = bookEntity?.localPath + if (!localPath.isNullOrEmpty()) { + removeDownloadedFiles(bookEntity.toDomain(), localPath) } if (bookEntity != null) { - // The cover.jpg lived inside localPath and was removed by deleteRecursively - // above, so drop its reference too. + // The cover.jpg either went with the folder or belongs to another + // edition now, so this book stops pointing at it either way. audioBookDao.upsert(bookEntity.copy(isDownloaded = 0, localPath = null, localCoverPath = null)) } if (downloadId != null) { @@ -697,6 +705,53 @@ class DownloadManager @Inject constructor( } } + /** + * Delete [book]'s downloaded files from [localPath] and nothing else (#49). + * + * Two editions with the same author and title share one folder, so the + * other books stored there come along with their track lists, and only this + * book's own files go. The rules live in [deleteDownloadFiles], which shares + * cancel cleanup's fences: never outside the download root, never in a + * user-picked folder, never recursive. Best effort: a failure here leaves + * files behind but never fails the delete. + */ + private suspend fun removeDownloadedFiles(book: AudioBook, localPath: String) { + try { + val location = engine.storedDownloadLocation(localPath) ?: return + val sharers = lookUpInChunks(audioBookDao.getFilePathBookIdsExcept(book.id)) { audioBookDao.getByIds(it) } + .map { it.toDomain() } + .map { other -> + FolderSharer( + path = other.localPath.orEmpty(), + fileNames = other.audioFiles.takeIf { it.isNotEmpty() }?.let(::downloadedFileNames), + ) + } + // Unfinished downloads of other books, and where each would write. + // Their track lists are not known yet, so sharing a folder with one + // keeps everything. + val liveOtherBookIds = downloadItemDao.getAll() + .filter { it.audioBookId != book.id && it.status != DownloadStatus.Completed.ordinal } + .map { it.audioBookId } + .distinct() + val otherFolders = lookUpInChunks(liveOtherBookIds) { audioBookDao.getByIds(it) } + .map { engine.downloadLocationFor(it.toDomain()).folder } + + val decision = withContext(Dispatchers.IO) { + deleteDownloadFiles(location, downloadedFileNames(book.audioFiles), sharers, otherFolders) + } + when (decision) { + is CancelCleanupDecision.Keep -> + android.util.Log.d("DownloadManager", "delete kept files: ${decision.reason}") + is CancelCleanupDecision.Delete -> + android.util.Log.d("DownloadManager", "delete removed ${decision.files.size} files") + } + } catch (cancellation: kotlinx.coroutines.CancellationException) { + throw cancellation + } catch (e: Exception) { + android.util.Log.w("DownloadManager", "delete left files: ${e.message}") + } + } + /** Check if a book is downloaded. */ suspend fun isBookDownloaded(audioBookId: String): Boolean { val entity = downloadItemDao.getByAudioBookId(audioBookId) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt index bc12484..97dfce5 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt @@ -139,19 +139,9 @@ internal fun decideCancelCleanup( if (!location.rootIsAppOwned) return CancelCleanupDecision.Keep(CancelKeepReason.USER_FOLDER) if (bookIsDownloaded) return CancelCleanupDecision.Keep(CancelKeepReason.BOOK_DOWNLOADED) - val root = canonicalOrNull(location.root) - ?: return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) - val folderPath = location.folder.toPath() - if (Files.isSymbolicLink(folderPath)) { - return CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT) - } - val folder = canonicalOrNull(location.folder) - ?: return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) - if (folder == root || folder.parentFile != root) { - return CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT) - } - if (!Files.isDirectory(folder.toPath(), LinkOption.NOFOLLOW_LINKS)) { - return CancelCleanupDecision.Keep(CancelKeepReason.NO_FOLDER) + val folder = when (val contained = containedFolder(location)) { + is Containment.Inside -> contained.folder + is Containment.Refused -> return CancelCleanupDecision.Keep(contained.reason) } for (raw in otherBookPaths) { @@ -168,14 +158,8 @@ internal fun decideCancelCleanup( if (otherCanonical == folder) return CancelCleanupDecision.Keep(CancelKeepReason.SHARED) } - val children = try { - // DirectoryStream, not Files.list(...).toList(): Stream.toList needs API 34. - Files.newDirectoryStream(folder.toPath()).use { stream -> stream.toList() } - } catch (_: IOException) { - return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) - } catch (_: SecurityException) { - return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) - } + val children = listChildren(folder) + ?: return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) if (children.any { !Files.isRegularFile(it, LinkOption.NOFOLLOW_LINKS) }) { return CancelCleanupDecision.Keep(CancelKeepReason.UNEXPECTED_CONTENTS) } @@ -272,3 +256,158 @@ private fun pathsOverlap(a: File, b: File): Boolean { val pb = b.toPath() return pa == pb || pa.startsWith(pb) || pb.startsWith(pa) } + +// ─── Deleting a finished download (#49) ─────────────────────────────────── + +/** Another book whose stored localPath may be this folder, and the files it owns there. */ +internal data class FolderSharer( + val path: String, + /** The leaf names its track list resolves to, or null when it has no track list. */ + val fileNames: List?, +) + +/** + * Where a downloaded book's stored localPath sits, for [deleteDownloadFiles]. + * + * The stored path wins over today's folder name, since the title can change + * after the download. A folder straight inside the app's own storage counts as + * app owned even when another root is configured now, so a book downloaded + * before a path change can still be deleted. Anything else is judged against + * the configured root, the same way the engine picks it. Null for a blank + * path or a content URI, which the engine never writes. + */ +internal fun deleteLocationFor(storedPath: String, configuredPath: String, defaultRoot: File): DownloadLocation? { + val folder = bookPathAsFile(storedPath) ?: return null + val parent = canonicalOrNull(folder)?.parentFile + if (parent != null && parent == canonicalOrNull(defaultRoot)) { + return DownloadLocation(root = defaultRoot, folder = folder, rootIsAppOwned = true) + } + val (root, appOwned) = resolveDownloadRoot(configuredPath, defaultRoot) + return DownloadLocation(root = root, folder = folder, rootIsAppOwned = appOwned) +} + +/** + * Decide what deleting a downloaded book may remove from [location], then + * remove it. Uses the same rules as cancel cleanup and never recurses. + * + * Kept whole when: + * - the root is a user-picked download folder, + * - the folder is not a real direct child of the root (a symlink, the root + * itself, `..`, or anything that resolves elsewhere), + * - the folder does not exist, + * - a [sharers] path sits above or inside the folder, or will not resolve, + * - a [sharers] entry is this exact folder but has no track list, so its + * files cannot be told apart from this book's, + * - an [otherDownloadFolders] entry (where a live download row writes) is the + * folder. + * + * When another downloaded book shares this exact folder (two editions with the + * same author and title), only [ownFileNames] that no sharer also claims go, + * with their `.part` leftovers. The cover stays for the other book. When + * nobody shares it and it holds only plain files, every file goes and then the + * folder. A subfolder or link inside is never followed, so with one present + * only this book's own files and its cover go. + */ +internal fun deleteDownloadFiles( + location: DownloadLocation, + ownFileNames: List, + sharers: List, + otherDownloadFolders: List, +): CancelCleanupDecision { + val decision = decideDownloadDelete(location, ownFileNames, sharers, otherDownloadFolders) + if (decision is CancelCleanupDecision.Delete) applyCancelCleanup(decision) + return decision +} + +private fun decideDownloadDelete( + location: DownloadLocation, + ownFileNames: List, + sharers: List, + otherDownloadFolders: List, +): CancelCleanupDecision { + if (!location.rootIsAppOwned) return CancelCleanupDecision.Keep(CancelKeepReason.USER_FOLDER) + + val folder = when (val contained = containedFolder(location)) { + is Containment.Inside -> contained.folder + is Containment.Refused -> return CancelCleanupDecision.Keep(contained.reason) + } + + // Other books stored at exactly this folder. One above or inside it means + // the layout is not ours to judge, so everything stays. + val sameFolder = mutableListOf() + for (sharer in sharers) { + val other = bookPathAsFile(sharer.path) ?: continue + val otherCanonical = canonicalOrNull(other) + ?: return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) + if (otherCanonical == folder) { + sameFolder += sharer + } else if (pathsOverlap(folder, otherCanonical)) { + return CancelCleanupDecision.Keep(CancelKeepReason.SHARED) + } + } + for (other in otherDownloadFolders) { + val otherCanonical = canonicalOrNull(other) + ?: return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) + if (otherCanonical == folder) return CancelCleanupDecision.Keep(CancelKeepReason.SHARED) + } + if (sameFolder.any { it.fileNames == null }) { + return CancelCleanupDecision.Keep(CancelKeepReason.SHARED) + } + + val children = listChildren(folder) + ?: return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) + val plainFiles = children.filter { Files.isRegularFile(it, LinkOption.NOFOLLOW_LINKS) }.map(Path::toFile) + + if (sameFolder.isEmpty() && plainFiles.size == children.size) { + return CancelCleanupDecision.Delete(folder, plainFiles) + } + + val claimedByOthers = sameFolder.flatMapTo(HashSet()) { it.fileNames.orEmpty() } + val own = ownFileNames.filterNot { it in claimedByOthers } + val targets = buildSet { + own.forEach { add(it); add("$it.part") } + if (sameFolder.isEmpty()) add(COVER_FILE_NAME) + } + return CancelCleanupDecision.Delete(folder, plainFiles.filter { it.name in targets }) +} + +/** The cover the engine saves next to the audio. */ +private const val COVER_FILE_NAME = "cover.jpg" + +private sealed interface Containment { + data class Inside(val folder: File) : Containment + data class Refused(val reason: CancelKeepReason) : Containment +} + +/** + * The canonical book folder when it is a real directory sitting directly in + * the canonical root. A symlink, the root itself, `..`, a deeper folder, or + * anything that resolves elsewhere is refused. Shared by cancel and delete so + * both stay inside the same fence. + */ +private fun containedFolder(location: DownloadLocation): Containment { + val root = canonicalOrNull(location.root) + ?: return Containment.Refused(CancelKeepReason.UNRESOLVABLE) + if (Files.isSymbolicLink(location.folder.toPath())) { + return Containment.Refused(CancelKeepReason.OUTSIDE_ROOT) + } + val folder = canonicalOrNull(location.folder) + ?: return Containment.Refused(CancelKeepReason.UNRESOLVABLE) + if (folder == root || folder.parentFile != root) { + return Containment.Refused(CancelKeepReason.OUTSIDE_ROOT) + } + if (!Files.isDirectory(folder.toPath(), LinkOption.NOFOLLOW_LINKS)) { + return Containment.Refused(CancelKeepReason.NO_FOLDER) + } + return Containment.Inside(folder) +} + +/** Everything directly inside [folder], or null when it cannot be listed. */ +private fun listChildren(folder: File): List? = try { + // DirectoryStream, not Files.list(...).toList(): Stream.toList needs API 34. + Files.newDirectoryStream(folder.toPath()).use { stream -> stream.toList() } +} catch (_: IOException) { + null +} catch (_: SecurityException) { + null +} diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt index df9d413..05f5842 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt @@ -95,7 +95,7 @@ class DownloadEngine @Inject constructor( // Track order comes from the server-reported index, not response order, // and leaf names are resolved once so sanitization collisions stay distinct. val orderedFiles = book.audioFiles.sortedBy { it.index } - val fileNames = resolveDownloadFileNames(orderedFiles) + val fileNames = downloadedFileNames(book.audioFiles) // Download each audio file for (i in orderedFiles.indices) { @@ -318,6 +318,17 @@ class DownloadEngine @Inject constructor( return DownloadLocation(root = root, folder = File(root, folderName), rootIsAppOwned = appOwned) } + /** + * Where a finished download's stored [localPath] sits, for delete. Null when + * the path is not one the engine could have written. See [deleteLocationFor]. + */ + internal fun storedDownloadLocation(localPath: String): DownloadLocation? = + deleteLocationFor( + storedPath = localPath, + configuredPath = settingsManager.currentSettings.downloadPath, + defaultRoot = defaultBasePath(), + ) + /** Base storage directory for all downloads. */ private fun getBasePath(): File { // Respect user-configured path when possible, with path traversal validation. diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadPolicies.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadPolicies.kt index 452fc11..8ea5ba9 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadPolicies.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadPolicies.kt @@ -40,6 +40,13 @@ internal fun downloadFolderName(author: String, title: String, fallbackId: Strin return sanitizeDownloadFileName(raw).ifBlank { fallbackId } } +/** + * The leaf names a book's tracks get on disk, in the engine's order. Delete + * uses the same list to tell this book's files from another edition's. + */ +internal fun downloadedFileNames(files: List): List = + resolveDownloadFileNames(files.sortedBy { it.index }) + /** Stable on-disk leaf names, disambiguating only sanitization collisions. */ internal fun resolveDownloadFileNames(files: List): List { val baseNames = files.mapIndexed { index, file -> diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt new file mode 100644 index 0000000..bce93f4 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt @@ -0,0 +1,286 @@ +package com.ninelivesaudio.app.service.download + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files + +/** + * Deleting a download removes this book's files and nothing else (#49). Two + * editions can share one "Author - Title" folder, a user-picked root may hold + * the user's own audio, and a stored path can point anywhere. Every doubt + * keeps files, just like cancel cleanup. + */ +class DownloadDeleteTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private fun root(): File = tempFolder.newFolder("Audiobookshelf") + + private fun bookFolder(root: File, vararg files: String): File = + File(root, "Herman Melville - Moby-Dick").apply { + mkdirs() + files.forEach { File(this, it).writeBytes(byteArrayOf(1, 2, 3)) } + } + + private fun delete( + root: File, + folder: File, + ownFileNames: List, + appOwned: Boolean = true, + sharers: List = emptyList(), + otherDownloadFolders: List = emptyList(), + ) = deleteDownloadFiles( + location = DownloadLocation(root, folder, appOwned), + ownFileNames = ownFileNames, + sharers = sharers, + otherDownloadFolders = otherDownloadFolders, + ) + + // ─── Shared folders (#49) ──────────────────────────────────────────────── + + @Test + fun `deleting one of two editions that share every file keeps the other's files`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3", "cover.jpg") + val other = FolderSharer(folder.absolutePath, listOf("01.mp3", "02.mp3")) + + delete(root, folder, ownFileNames = listOf("01.mp3", "02.mp3"), sharers = listOf(other)) + + assertTrue(File(folder, "01.mp3").exists()) + assertTrue(File(folder, "02.mp3").exists()) + assertTrue(File(folder, "cover.jpg").exists()) + } + + @Test + fun `deleting one edition takes only its own files from a shared folder`() { + val root = root() + val folder = bookFolder(root, "Part 1.mp3", "Part 1.mp3.part", "Track 01.m4b", "cover.jpg") + val other = FolderSharer(folder.absolutePath, listOf("Track 01.m4b")) + + val decision = delete(root, folder, ownFileNames = listOf("Part 1.mp3"), sharers = listOf(other)) + + assertTrue(decision is CancelCleanupDecision.Delete) + assertFalse(File(folder, "Part 1.mp3").exists()) + assertFalse(File(folder, "Part 1.mp3.part").exists()) + // The other edition's audio and the cover its row points at both stay. + assertTrue(File(folder, "Track 01.m4b").exists()) + assertTrue(File(folder, "cover.jpg").exists()) + assertTrue(folder.exists()) + } + + @Test + fun `a sharer stored as a file uri with a trailing slash still counts`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + val other = FolderSharer(folder.toURI().toString(), listOf("01.mp3")) + + delete(root, folder, ownFileNames = listOf("01.mp3"), sharers = listOf(other)) + + assertTrue(File(folder, "01.mp3").exists()) + } + + @Test + fun `a sharer whose track list is unknown keeps everything`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3") + val other = FolderSharer(folder.absolutePath, fileNames = null) + + val decision = delete(root, folder, ownFileNames = listOf("01.mp3"), sharers = listOf(other)) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.SHARED), decision) + assertTrue(File(folder, "01.mp3").exists()) + assertTrue(File(folder, "02.mp3").exists()) + } + + @Test + fun `a book path above or inside the folder keeps everything`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + + val above = delete(root, folder, listOf("01.mp3"), sharers = listOf(FolderSharer(root.absolutePath, listOf("x.mp3")))) + val inside = delete( + root, folder, listOf("01.mp3"), + sharers = listOf(FolderSharer(File(folder, "Disc 1").absolutePath, listOf("x.mp3"))), + ) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.SHARED), above) + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.SHARED), inside) + assertTrue(File(folder, "01.mp3").exists()) + } + + @Test + fun `a download still writing to the folder keeps everything`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + + val decision = delete(root, folder, listOf("01.mp3"), otherDownloadFolders = listOf(File(folder.path))) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.SHARED), decision) + assertTrue(File(folder, "01.mp3").exists()) + } + + @Test + fun `a sibling book's folder is never touched`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "cover.jpg") + val sibling = File(root, "Other Author - Other Book").apply { mkdirs() } + File(sibling, "01.mp3").writeBytes(byteArrayOf(9)) + + delete(root, folder, listOf("01.mp3"), sharers = listOf(FolderSharer(sibling.absolutePath, listOf("01.mp3")))) + + assertFalse(folder.exists()) + assertTrue(File(sibling, "01.mp3").exists()) + } + + // ─── A folder only this book uses ──────────────────────────────────────── + + @Test + fun `a folder only this book uses goes whole`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3", "cover.jpg") + + delete(root, folder, ownFileNames = listOf("01.mp3", "02.mp3")) + + assertFalse(folder.exists()) + assertTrue(root.exists()) + } + + @Test + fun `a subfolder inside the book folder is never recursed into`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "cover.jpg") + val nested = File(folder, "extras").apply { mkdirs() } + File(nested, "bonus.mp3").writeBytes(byteArrayOf(4)) + + delete(root, folder, ownFileNames = listOf("01.mp3")) + + assertFalse(File(folder, "01.mp3").exists()) + assertTrue(File(nested, "bonus.mp3").exists()) + assertTrue(folder.exists()) + } + + // ─── User-picked folder ────────────────────────────────────────────────── + + @Test + fun `a user-picked root keeps every file`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "cover.jpg") + + val decision = delete(root, folder, listOf("01.mp3"), appOwned = false) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.USER_FOLDER), decision) + assertTrue(File(folder, "01.mp3").exists()) + assertTrue(File(folder, "cover.jpg").exists()) + } + + // ─── Root containment ──────────────────────────────────────────────────── + + @Test + fun `a stored path that escapes the root is refused`() { + val root = root() + val outside = tempFolder.newFolder("Someone Else's Music").apply { + File(this, "01.mp3").writeBytes(byteArrayOf(5)) + } + val escaping = File(root, "../Someone Else's Music") + + val decision = delete(root, escaping, listOf("01.mp3")) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decision) + assertTrue(File(outside, "01.mp3").exists()) + } + + @Test + fun `the root itself is refused`() { + val root = root() + bookFolder(root, "01.mp3") + File(root, "loose.mp3").writeBytes(byteArrayOf(6)) + + val decision = delete(root, root, listOf("loose.mp3")) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decision) + assertTrue(File(root, "loose.mp3").exists()) + assertTrue(File(root, "Herman Melville - Moby-Dick/01.mp3").exists()) + } + + @Test + fun `a folder nested deeper than one level is refused`() { + val root = root() + val deep = File(bookFolder(root), "Disc 1").apply { mkdirs() } + File(deep, "01.mp3").writeBytes(byteArrayOf(7)) + + val decision = delete(root, deep, listOf("01.mp3")) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decision) + assertTrue(File(deep, "01.mp3").exists()) + } + + @Test + fun `a symlinked book folder is never followed`() { + val root = root() + val target = tempFolder.newFolder("elsewhere").apply { File(this, "01.mp3").writeBytes(byteArrayOf(8)) } + val link = File(root, "Herman Melville - Moby-Dick") + Files.createSymbolicLink(link.toPath(), target.toPath()) + + val decision = delete(root, link, listOf("01.mp3")) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decision) + assertTrue(File(target, "01.mp3").exists()) + } + + @Test + fun `a missing folder is a no-op`() { + val root = root() + val decision = delete(root, File(root, "Gone - Gone"), listOf("01.mp3")) + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.NO_FOLDER), decision) + } + + // ─── Which root a stored path belongs to ───────────────────────────────── + + @Test + fun `a stored path in app storage is app owned even with another root configured`() { + val appRoot = root() + val userRoot = tempFolder.newFolder("MyBooks") + val folder = bookFolder(appRoot, "01.mp3") + + val location = deleteLocationFor(folder.absolutePath, userRoot.absolutePath, appRoot)!! + + assertTrue(location.rootIsAppOwned) + assertEquals(appRoot.canonicalFile, location.root.canonicalFile) + } + + @Test + fun `a stored path in a user-picked root is not app owned`() { + val appRoot = root() + val userRoot = tempFolder.newFolder("MyBooks") + val folder = File(userRoot, "Author - Title").apply { mkdirs() } + + val location = deleteLocationFor(folder.absolutePath, userRoot.absolutePath, appRoot)!! + + assertFalse(location.rootIsAppOwned) + } + + @Test + fun `a content uri or blank path has nothing to delete`() { + val appRoot = root() + assertNull(deleteLocationFor("content://com.android.externalstorage/tree/x", "", appRoot)) + assertNull(deleteLocationFor(" ", "", appRoot)) + } + + @Test + fun `a stored file uri resolves to its folder`() { + val appRoot = root() + val folder = bookFolder(appRoot, "01.mp3") + + val location = deleteLocationFor(folder.toURI().toString(), "", appRoot)!! + + assertEquals(folder.canonicalFile, location.folder.canonicalFile) + assertTrue(location.rootIsAppOwned) + } +} From 7baa5030fe029d88c6acc547e9bc4226f44a4c39 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 12:44:56 -0400 Subject: [PATCH 08/23] A pause in the last second of a server book keeps it finished (#82) The pause save on a server book goes through its listening session. The session sync carries the position but no finished flag, yet its success deleted the queued progress row, including a finished one. A finished pause save now also pushes the finished flag after the session sync and keeps its queued row until that push lands. Unfinished saves are delivered by the session sync alone, as before. Co-Authored-By: Claude Opus 5.5 --- .../app/data/repository/ProgressRepository.kt | 34 ++++-- .../PendingProgressQueueOwnerTest.kt | 12 +- .../repository/SessionProgressDeliveryTest.kt | 108 ++++++++++++++++++ 3 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/data/repository/SessionProgressDeliveryTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/ProgressRepository.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/ProgressRepository.kt index 62f000b..fe60158 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/repository/ProgressRepository.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/ProgressRepository.kt @@ -175,13 +175,23 @@ internal suspend fun insertOwnedPendingProgress( } } -internal suspend fun acknowledgePendingFallbackOnSuccess( - deliver: suspend () -> Boolean, +/** + * Deliver a session save and acknowledge its queued row only once the server + * holds everything the row does. The session sync carries the position and + * listening time but no finished flag, so a finished save also pushes the + * flag itself, after the sync so the flag is the last word. If either call + * fails the row stays queued and the reconnect flush delivers it. Returns + * whether the session sync landed, which is what acknowledges listening time. + */ +internal suspend fun deliverSessionProgress( + isFinished: Boolean, + syncSession: suspend () -> Boolean, + pushFinished: suspend () -> Boolean, acknowledge: suspend () -> Unit, ): Boolean { - val delivered = deliver() - if (delivered) acknowledge() - return delivered + val synced = syncSession() + if (synced && (!isFinished || pushFinished())) acknowledge() + return synced } @Singleton @@ -365,8 +375,9 @@ class ProgressRepository @Inject constructor( duration = duration, onPersisted = onPersisted, ) - acknowledgePendingFallbackOnSuccess( - deliver = { + deliverSessionProgress( + isFinished = isFinished, + syncSession = { try { apiService.syncSessionProgress( sessionId = sessionId, @@ -380,6 +391,15 @@ class ProgressRepository @Inject constructor( false } }, + pushFinished = { + try { + apiService.updateProgress(itemId, currentTime, true, duration) + } catch (cancellation: kotlinx.coroutines.CancellationException) { + throw cancellation + } catch (_: Exception) { + false + } + }, acknowledge = { acknowledgePendingProgressLocked(itemId) }, ) } diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/PendingProgressQueueOwnerTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/PendingProgressQueueOwnerTest.kt index 06fb276..00f3e20 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/repository/PendingProgressQueueOwnerTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/PendingProgressQueueOwnerTest.kt @@ -211,8 +211,10 @@ class PendingProgressQueueOwnerTest { fun `failed session sync retains its pending fallback`() = runBlocking { var acknowledged = false - val delivered = acknowledgePendingFallbackOnSuccess( - deliver = { false }, + val delivered = deliverSessionProgress( + isFinished = false, + syncSession = { false }, + pushFinished = { error("an unfinished save never pushes the finished flag") }, acknowledge = { acknowledged = true }, ) @@ -224,8 +226,10 @@ class PendingProgressQueueOwnerTest { fun `successful session sync acknowledges its pending fallback`() = runBlocking { var acknowledged = false - val delivered = acknowledgePendingFallbackOnSuccess( - deliver = { true }, + val delivered = deliverSessionProgress( + isFinished = false, + syncSession = { true }, + pushFinished = { error("an unfinished save never pushes the finished flag") }, acknowledge = { acknowledged = true }, ) diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/SessionProgressDeliveryTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/SessionProgressDeliveryTest.kt new file mode 100644 index 0000000..d9930da --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/SessionProgressDeliveryTest.kt @@ -0,0 +1,108 @@ +package com.ninelivesaudio.app.data.repository + +import com.ninelivesaudio.app.service.positionCountsAsFinished +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A pause on a server book saves through its listening session. The session + * sync carries the position but no finished flag, so a finished save must not + * count as delivered until the finished flag itself reaches the server. + */ +class SessionProgressDeliveryTest { + + /** The queued fallback rows for one book and what the server holds. */ + private class FakeServerBook { + val queuedFinishedFlags = mutableListOf() + var serverFinished = false + var finishedPushes = 0 + + suspend fun save( + isFinished: Boolean, + sessionAnswers: Boolean = true, + finishedPushAnswers: Boolean = true, + ): Boolean { + // The repository queues the row before trying the server. + queuedFinishedFlags += isFinished + return deliverSessionProgress( + isFinished = isFinished, + // Like the real session sync: position only, never the flag. + syncSession = { sessionAnswers }, + pushFinished = { + finishedPushes++ + if (finishedPushAnswers) serverFinished = true + finishedPushAnswers + }, + acknowledge = { queuedFinishedFlags.clear() }, + ) + } + } + + @Test + fun `pause inside the last second marks the book finished on the server`() = runBlocking { + val book = FakeServerBook() + + val delivered = book.save(isFinished = true) + + assertTrue(delivered) + assertTrue(book.serverFinished) + assertTrue(book.queuedFinishedFlags.isEmpty()) + } + + @Test + fun `finished push that fails keeps the queued finished row`() = runBlocking { + val book = FakeServerBook() + + val delivered = book.save(isFinished = true, finishedPushAnswers = false) + + // The listening time still went through the session. + assertTrue(delivered) + assertFalse(book.serverFinished) + assertEquals(listOf(true), book.queuedFinishedFlags) + } + + @Test + fun `finished save whose session sync fails keeps its row for the reconnect flush`() = runBlocking { + val book = FakeServerBook() + + val delivered = book.save(isFinished = true, sessionAnswers = false) + + assertFalse(delivered) + assertEquals(0, book.finishedPushes) + assertEquals(listOf(true), book.queuedFinishedFlags) + } + + @Test + fun `unfinished pause is delivered by the session sync alone`() = runBlocking { + val book = FakeServerBook() + + val delivered = book.save(isFinished = false) + + assertTrue(delivered) + assertEquals(0, book.finishedPushes) + assertTrue(book.queuedFinishedFlags.isEmpty()) + } + + @Test + fun `seeking back from a queued finish lets the newer unfinished pause win`() = runBlocking { + val book = FakeServerBook() + // The end was reached while the server was away. + book.save(isFinished = true, sessionAnswers = false) + + // The listener sought back and paused well before the end. + val delivered = book.save(isFinished = false) + + assertTrue(delivered) + assertFalse(book.serverFinished) + assertTrue(book.queuedFinishedFlags.isEmpty()) + } + + @Test + fun `pause thirty seconds before the end is not a finished save`() { + assertFalse(positionCountsAsFinished(currentTimeSec = 3570.0, durationSec = 3600.0)) + assertTrue(positionCountsAsFinished(currentTimeSec = 3599.5, durationSec = 3600.0)) + } +} From 112b494c965be88740a48687fe75a0991bd13aec Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 12:47:11 -0400 Subject: [PATCH 09/23] The Library has a letter rail for jumping through long sorted lists When the Library is sorted by title or author, a strip of letters runs down the right edge. Touch or drag it to jump to that letter, with a big letter shown while a finger is down. It works for the flat list and for the grouped Series, Author and Genre lists (by group name), and TalkBack gets it as one adjustable "Jump to letter" control. Each letter's position is worked out once per list change off the main thread, so dragging only looks one up. Co-Authored-By: Claude Sonnet 5.5 --- .../app/ui/library/LetterIndexRail.kt | 224 +++++++++++++ .../app/ui/library/LibraryLetterIndex.kt | 159 +++++++++ .../app/ui/library/LibraryScreen.kt | 133 +++++--- .../app/ui/library/LibraryLetterIndexTest.kt | 303 ++++++++++++++++++ 4 files changed, 770 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/ui/library/LetterIndexRail.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndex.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndexTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LetterIndexRail.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LetterIndexRail.kt new file mode 100644 index 0000000..f2e78a4 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LetterIndexRail.kt @@ -0,0 +1,224 @@ +package com.ninelivesaudio.app.ui.library + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.systemGestureExclusion +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ninelivesaudio.app.ui.theme.NineLivesTheme +import kotlin.math.roundToInt + +/** What TalkBack calls the letter rail. */ +internal const val LETTER_RAIL_LABEL = "Jump to letter" + +private val RAIL_WIDTH = 28.dp +private val MIN_LETTER_SPACING = 14.dp + +/** + * A strip of letters down the right edge of the Library list. Touch or drag + * on it jumps the list to the first row under that letter, with a big letter + * shown while a finger is down. Fill the list's box with it: it only draws + * and takes touches on the strip itself. + * + * A drag costs a lookup in [index] and a scroll request, so nothing here + * grows with the number of books. TalkBack gets the strip as one adjustable + * control, where swiping up or down steps through the letters. + * + * @param leadingItemCount list items before the first row [index] counts, such + * as a sync warning banner + */ +@Composable +internal fun LetterIndexRail( + index: LetterIndex, + listState: LazyListState, + leadingItemCount: Int, + modifier: Modifier = Modifier, + bottomInset: Dp = 0.dp, +) { + if (index.isEmpty) return + + val haptics = LocalHapticFeedback.current + val minSpacingPx = with(LocalDensity.current) { MIN_LETTER_SPACING.toPx() } + val currentIndex by rememberUpdatedState(index) + val currentLeading by rememberUpdatedState(leadingItemCount) + + // The letter under a finger, or -1 with none down. The one the list is + // sitting on is worked out from its scroll position, a binary search that + // only re-runs the rail when the answer changes. + var touchedLabel by remember { mutableIntStateOf(-1) } + // The last letter picked, since a short list cannot always scroll as far + // as the last few letters and would otherwise never report reaching them. + var pickedLabel by remember { mutableIntStateOf(-1) } + var railHeightPx by remember { mutableIntStateOf(0) } + + val scrolledLabel by remember(index, leadingItemCount) { + derivedStateOf { + val fromScroll = index.labelIndexAt(listState.firstVisibleItemIndex - leadingItemCount) + if (!listState.canScrollForward && pickedLabel > fromScroll) { + pickedLabel.coerceAtMost(index.labels.lastIndex) + } else { + fromScroll + } + } + } + + val jumpTo = remember(listState) { + { labelIdx: Int -> + val live = currentIndex + if (labelIdx in live.labels.indices) { + pickedLabel = labelIdx + listState.requestScrollToItem(live.offsets[labelIdx] + currentLeading) + } + } + } + + val shownLabel = if (touchedLabel >= 0) touchedLabel else scrolledLabel + val stride = letterRailStride(index.labels.size, railHeightPx.toFloat(), minSpacingPx) + + Box(modifier = modifier.fillMaxSize()) { + if (touchedLabel in index.labels.indices) { + LetterBubble( + label = index.labels[touchedLabel], + modifier = Modifier.align(Alignment.Center), + ) + } + + Column( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(top = 8.dp, bottom = bottomInset + 8.dp) + .fillMaxHeight() + .width(RAIL_WIDTH) + .onSizeChanged { railHeightPx = it.height } + // The strip sits on the screen edge, where the back swipe lives. + .systemGestureExclusion() + .clip(RoundedCornerShape(14.dp)) + .background( + if (touchedLabel >= 0) NineLivesTheme.colors.archiveVoidSurface.copy(alpha = 0.85f) + else Color.Transparent, + ) + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + down.consume() + var last = -1 + fun pickAt(y: Float) { + val count = currentIndex.labels.size + val picked = letterIndexAtTouch(y, size.height.toFloat(), count) + if (picked != last) { + last = picked + touchedLabel = picked + jumpTo(picked) + haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + } + pickAt(down.position.y) + while (true) { + val change = awaitPointerEvent().changes + .firstOrNull { it.id == down.id } ?: break + if (!change.pressed) break + pickAt(change.position.y) + change.consume() + } + touchedLabel = -1 + } + } + .clearAndSetSemantics { + contentDescription = LETTER_RAIL_LABEL + stateDescription = letterRailSpokenLabel(index.labels[shownLabel.coerceIn(0, index.labels.lastIndex)]) + progressBarRangeInfo = ProgressBarRangeInfo( + current = shownLabel.toFloat(), + range = 0f..index.labels.lastIndex.toFloat(), + steps = (index.labels.size - 2).coerceAtLeast(0), + ) + setProgress { target -> + jumpTo(target.roundToInt().coerceIn(0, index.labels.lastIndex)) + true + } + }, + ) { + index.labels.forEachIndexed { position, label -> + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + if (position % stride == 0) { + val active = position == shownLabel + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = if (active) NineLivesTheme.colors.goldFilament + else NineLivesTheme.colors.archiveTextSecondary, + fontWeight = if (active) FontWeight.Bold else FontWeight.Normal, + fontSize = 10.sp, + lineHeight = 10.sp, + textAlign = TextAlign.Center, + maxLines = 1, + softWrap = false, + ) + } + } + } + } + } +} + +/** The big letter shown in the middle of the list while the rail is held. */ +@Composable +private fun LetterBubble(label: String, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + color = NineLivesTheme.colors.archiveVoidElevated, + border = BorderStroke(1.dp, NineLivesTheme.colors.goldFilament.copy(alpha = 0.6f)), + ) { + Text( + text = label, + modifier = Modifier.padding(horizontal = 28.dp, vertical = 14.dp), + style = MaterialTheme.typography.headlineLarge, + color = NineLivesTheme.colors.goldFilament, + fontWeight = FontWeight.Bold, + ) + } +} diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndex.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndex.kt new file mode 100644 index 0000000..7f6a6ac --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndex.kt @@ -0,0 +1,159 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.domain.model.AudioBook +import java.text.Normalizer +import kotlin.math.ceil + +/** Where digits, symbols and anything outside A to Z are filed. */ +internal const val NUMBER_SIGN_LABEL = "#" + +/** Under this many rows the list is short enough to scroll, so no rail. */ +internal const val MIN_ROWS_FOR_LETTER_INDEX = 40 + +/** A to Z and the number sign. */ +private const val MAX_LETTER_LABELS = 27 + +/** + * The letters a sorted Library list can be jumped to. [labels] are in list + * order (reversed for a Z to A sort) and [offsets] holds, for each, the + * position of the first row under it in the list of rows (headers and books + * for a grouped list). Built once per list change by [flatLetterIndex] or + * [groupedLetterIndex], never during scrolling. + */ +internal class LetterIndex(val labels: List, val offsets: IntArray) { + val isEmpty: Boolean get() = labels.isEmpty() + + /** + * Which label covers the row at [position]: the last one whose first row + * is at or before it. A position before the first label gets the first. + */ + fun labelIndexAt(position: Int): Int { + var low = 0 + var high = offsets.size - 1 + var found = 0 + while (low <= high) { + val mid = (low + high) ushr 1 + if (offsets[mid] <= position) { + found = mid + low = mid + 1 + } else { + high = mid - 1 + } + } + return found + } + + companion object { + val Empty = LetterIndex(emptyList(), IntArray(0)) + } +} + +/** + * Whether the shelf is in alphabetical order under [sortMode], by title or + * by author. Every other sort has no meaningful letters to jump to. + */ +internal fun sortsAlphabetically(sortMode: SortMode): Boolean = when (sortMode) { + SortMode.TITLE_AZ, SortMode.TITLE_ZA, SortMode.AUTHOR_AZ, SortMode.AUTHOR_ZA -> true + else -> false +} + +private val ASCII_LABELS = Array(26) { ('A' + it).toString() } + +/** + * The letter a title or name is filed under, matching how the shelf sorts it + * (on its first character, so "The Hobbit" is under T). Accents are dropped, + * so "Émile" is under E. Digits, symbols, other alphabets and blanks share + * the number sign. + */ +internal fun letterLabelFor(text: String): String { + var i = 0 + while (i < text.length && text[i].isWhitespace()) i++ + if (i == text.length) return NUMBER_SIGN_LABEL + val c = text[i] + return when { + c in 'A'..'Z' -> ASCII_LABELS[c - 'A'] + c in 'a'..'z' -> ASCII_LABELS[c - 'a'] + c.isLetter() -> { + val base = Normalizer.normalize(c.toString(), Normalizer.Form.NFD)[0] + when (base) { + in 'A'..'Z' -> ASCII_LABELS[base - 'A'] + in 'a'..'z' -> ASCII_LABELS[base - 'a'] + else -> NUMBER_SIGN_LABEL + } + } + else -> NUMBER_SIGN_LABEL + } +} + +/** + * One pass over [rowCount] rows, keeping the first row of each label that + * [labelAt] names (null skips a row). Stops as soon as every letter has been + * seen, and returns [LetterIndex.Empty] for a short list or one with a single + * label, where a rail would be clutter. + */ +internal fun buildLetterIndex(rowCount: Int, labelAt: (Int) -> String?): LetterIndex { + if (rowCount < MIN_ROWS_FOR_LETTER_INDEX) return LetterIndex.Empty + val seen = HashSet(MAX_LETTER_LABELS * 2) + val labels = ArrayList(MAX_LETTER_LABELS) + val offsets = IntArray(MAX_LETTER_LABELS) + for (row in 0 until rowCount) { + val label = labelAt(row) ?: continue + if (seen.add(label)) { + offsets[labels.size] = row + labels.add(label) + if (labels.size == MAX_LETTER_LABELS) break + } + } + if (labels.size < 2) return LetterIndex.Empty + return LetterIndex(labels, offsets.copyOf(labels.size)) +} + +/** The rail for the flat shelf, filed by title or author to match [sortMode]. */ +internal fun flatLetterIndex(books: List, sortMode: SortMode): LetterIndex { + if (!sortsAlphabetically(sortMode)) return LetterIndex.Empty + val byAuthor = sortMode == SortMode.AUTHOR_AZ || sortMode == SortMode.AUTHOR_ZA + return buildLetterIndex(books.size) { row -> + val book = books[row] + letterLabelFor(if (byAuthor) book.author else book.title) + } +} + +/** + * The rail for a grouped shelf. Only the group headers carry letters, and the + * offsets count every header and expanded book row before them. + */ +internal fun groupedLetterIndex(items: List, sortMode: SortMode): LetterIndex { + if (!sortsAlphabetically(sortMode)) return LetterIndex.Empty + return buildLetterIndex(items.size) { row -> + (items[row] as? LibraryListItem.GroupHeader)?.let { letterLabelFor(it.title) } + } +} + +/** + * A remember key that is [value] by reference. The list of books is a key for + * the index, and comparing two lists of 8,000 books by content on the main + * thread is exactly the cost the index exists to avoid. + */ +internal class IdentityKey(private val value: Any) { + override fun equals(other: Any?): Boolean = other is IdentityKey && other.value === value + override fun hashCode(): Int = System.identityHashCode(value) +} + +/** The letter under a finger [y] pixels down a rail [heightPx] tall. */ +internal fun letterIndexAtTouch(y: Float, heightPx: Float, labelCount: Int): Int { + if (labelCount <= 0 || heightPx <= 0f) return 0 + return ((y / heightPx) * labelCount).toInt().coerceIn(0, labelCount - 1) +} + +/** + * Show every [letterRailStride]th letter, so no two shown letters sit closer + * than [minSlotPx] on a rail [heightPx] tall. Touch still maps to every letter. + */ +internal fun letterRailStride(labelCount: Int, heightPx: Float, minSlotPx: Float): Int { + if (labelCount <= 0 || heightPx <= 0f) return 1 + return ceil(minSlotPx * labelCount / heightPx - 0.0001f).toInt().coerceAtLeast(1) +} + +/** A rail letter as TalkBack should say it. */ +internal fun letterRailSpokenLabel(label: String): String = + if (label == NUMBER_SIGN_LABEL) "numbers and symbols" else label diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryScreen.kt index bb129e4..62d5af3 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.shape.RoundedCornerShape @@ -58,6 +59,8 @@ import com.ninelivesaudio.app.domain.model.SyncResult import com.ninelivesaudio.app.ui.components.ContainmentFrame import com.ninelivesaudio.app.ui.components.CornerSigils import com.ninelivesaudio.app.ui.components.FluorescentSquareProgress +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import com.ninelivesaudio.app.ui.animation.unhinged.anomalies.AnomalyHost import com.ninelivesaudio.app.ui.animation.unhinged.anomalies.AnomalyTriggerContext @@ -70,6 +73,9 @@ import com.ninelivesaudio.app.ui.theme.NineLivesTheme import com.ninelivesaudio.app.ui.unlock.UnlockViewModel import com.ninelivesaudio.app.ui.theme.unhinged.* +/** Room under the last row for the mini player, which the letter rail also stops short of. */ +private val LIBRARY_LIST_BOTTOM_PADDING = 100.dp + @OptIn(ExperimentalMaterial3Api::class) @Composable fun LibraryScreen( @@ -248,19 +254,39 @@ fun LibraryScreen( } } else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = 18.dp, - end = 18.dp, - top = 0.dp, - bottom = 100.dp, - ), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - (shelfDecision as? LibraryShelfDecision.ShowShelf) - ?.warning - ?.let { warning -> + val listState = rememberLazyListState() + val syncWarning = (shelfDecision as? LibraryShelfDecision.ShowShelf)?.warning + val flatShelf = uiState.effectiveViewMode == ViewMode.ALL + + // Where each letter starts, worked out off the main thread + // once per list change. Scrolling and dragging the rail only + // look this up, so a shelf of 8,000 books costs nothing per frame. + val flatBooks = uiState.filteredBooks + val groupedItems = uiState.groupedListItems + val sortMode = uiState.sortMode + // Keyed on the list itself, not its contents, so a new list + // is one reference check here and never a compare of 8,000 books. + val shelfKey = IdentityKey(if (flatShelf) flatBooks else groupedItems) + val letterIndex by produceState(LetterIndex.Empty, shelfKey, flatShelf, sortMode) { + value = withContext(Dispatchers.Default) { + if (flatShelf) flatLetterIndex(flatBooks, sortMode) + else groupedLetterIndex(groupedItems, sortMode) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 18.dp, + end = 18.dp, + top = 0.dp, + bottom = LIBRARY_LIST_BOTTOM_PADDING, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + syncWarning?.let { warning -> item(key = "sync-warning") { SyncWarningBanner( result = warning, @@ -268,48 +294,57 @@ fun LibraryScreen( ) } } - // The mode in effect, not the stored choice: a grouped view - // whose unlock was lost shows the flat shelf. - if (uiState.effectiveViewMode == ViewMode.ALL) { - // Flat list in ALL mode - itemsIndexed( - items = uiState.filteredBooks, - key = { _, book -> book.id }, - ) { index, book -> - ArchiveBookListItem( - book = book, - index = index, - whisperEpoch = whisperEpoch, - onClick = { onNavigateToBookDetail(book.id) }, - ) - } - } else { - // Grouped expandable list in SERIES / AUTHOR / GENRE modes - itemsIndexed( - items = uiState.groupedListItems, - key = { _, item -> - when (item) { - is LibraryListItem.GroupHeader -> "header-${item.groupKey}" - is LibraryListItem.BookRow -> "book-${item.groupKey}-${item.book.id}" - } - }, - ) { index, item -> - when (item) { - is LibraryListItem.GroupHeader -> GroupHeaderRow( - title = item.title, - count = item.count, - isExpanded = item.isExpanded, - onClick = { viewModel.onGroupExpansionToggled(item.groupKey) }, - ) - is LibraryListItem.BookRow -> ArchiveBookListItem( - book = item.book, + // The mode in effect, not the stored choice: a grouped view + // whose unlock was lost shows the flat shelf. + if (uiState.effectiveViewMode == ViewMode.ALL) { + // Flat list in ALL mode + itemsIndexed( + items = uiState.filteredBooks, + key = { _, book -> book.id }, + ) { index, book -> + ArchiveBookListItem( + book = book, index = index, whisperEpoch = whisperEpoch, - onClick = { onNavigateToBookDetail(item.book.id) }, + onClick = { onNavigateToBookDetail(book.id) }, ) } + } else { + // Grouped expandable list in SERIES / AUTHOR / GENRE modes + itemsIndexed( + items = uiState.groupedListItems, + key = { _, item -> + when (item) { + is LibraryListItem.GroupHeader -> "header-${item.groupKey}" + is LibraryListItem.BookRow -> "book-${item.groupKey}-${item.book.id}" + } + }, + ) { index, item -> + when (item) { + is LibraryListItem.GroupHeader -> GroupHeaderRow( + title = item.title, + count = item.count, + isExpanded = item.isExpanded, + onClick = { viewModel.onGroupExpansionToggled(item.groupKey) }, + ) + is LibraryListItem.BookRow -> ArchiveBookListItem( + book = item.book, + index = index, + whisperEpoch = whisperEpoch, + onClick = { onNavigateToBookDetail(item.book.id) }, + ) + } + } } } + + LetterIndexRail( + index = letterIndex, + listState = listState, + // The banner is the one list item ahead of the shelf rows. + leadingItemCount = if (syncWarning != null) 1 else 0, + bottomInset = LIBRARY_LIST_BOTTOM_PADDING, + ) } } } diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndexTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndexTest.kt new file mode 100644 index 0000000..74137ba --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndexTest.kt @@ -0,0 +1,303 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.domain.model.AudioBook +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The letter rail on the Library list. Its offsets are worked out once per + * list change, so these pin what they say and that a long shelf is cheap. + */ +class LibraryLetterIndexTest { + + private fun book(id: Int, title: String, author: String = "") = + AudioBook(id = "b$id", title = title, author = author) + + /** [perLetter] books for each letter in [letters], titled like "A title 3". */ + private fun shelf(letters: List, perLetter: Int): List = + letters.flatMapIndexed { li, letter -> + (0 until perLetter).map { n -> book(li * 1000 + n, "$letter title $n") } + } + + private fun LetterIndex.asPairs() = labels.zip(offsets.toList()) + + @Test + fun `a title's letter is its first letter in capitals`() { + assertEquals("D", letterLabelFor("dune")) + assertEquals("D", letterLabelFor("Dune")) + assertEquals("T", letterLabelFor("The Hobbit")) + } + + @Test + fun `digits and symbols share the number sign`() { + assertEquals("#", letterLabelFor("1984")) + assertEquals("#", letterLabelFor("'Salem's Lot")) + assertEquals("#", letterLabelFor("[REC]")) + } + + @Test + fun `a blank title falls under the number sign`() { + assertEquals("#", letterLabelFor("")) + assertEquals("#", letterLabelFor(" ")) + } + + @Test + fun `leading spaces are skipped`() { + assertEquals("M", letterLabelFor(" Mort")) + } + + @Test + fun `an accented first letter files under its plain letter`() { + assertEquals("E", letterLabelFor("Émile")) + assertEquals("O", letterLabelFor("Östen")) + assertEquals("N", letterLabelFor("ñandú")) + } + + @Test + fun `a letter outside the Latin alphabet falls under the number sign`() { + assertEquals("#", letterLabelFor("日本語")) + assertEquals("#", letterLabelFor("Привет")) + } + + @Test + fun `a flat title shelf gets the first row of each letter`() { + val books = shelf(listOf('A', 'B', 'D'), perLetter = 20) + + val index = flatLetterIndex(books, SortMode.TITLE_AZ) + + assertEquals(listOf("A" to 0, "B" to 20, "D" to 40), index.asPairs()) + } + + @Test + fun `numbers come first on an ascending shelf and last on a descending one`() { + val asc = listOf(book(1, "1984")) + shelf(listOf('A', 'B'), 25) + val desc = shelf(listOf('B', 'A'), 25) + listOf(book(9, "1984")) + + assertEquals(listOf("#" to 0, "A" to 1, "B" to 26), flatLetterIndex(asc, SortMode.TITLE_AZ).asPairs()) + assertEquals(listOf("B" to 0, "A" to 25, "#" to 50), flatLetterIndex(desc, SortMode.TITLE_ZA).asPairs()) + } + + @Test + fun `a letter with no books is left off`() { + val books = shelf(listOf('A', 'C'), 25) + + assertEquals(listOf("A", "C"), flatLetterIndex(books, SortMode.TITLE_AZ).labels) + } + + @Test + fun `an accented title that sorts at the end does not disturb the offsets`() { + // The shelf sorts on the lowercased title, so "Émile" lands after "z". + // The rail keeps the first run of each letter and ignores the stray. + val books = shelf(listOf('E', 'Z'), 25) + book(99, "Émile") + + val index = flatLetterIndex(books, SortMode.TITLE_AZ) + + assertEquals(listOf("E" to 0, "Z" to 25), index.asPairs()) + } + + @Test + fun `an author sort files books under the author's letter`() { + val books = (0 until 50).map { book(it, title = "Zzz $it", author = if (it < 25) "Austen" else "Brontë") } + + val index = flatLetterIndex(books, SortMode.AUTHOR_AZ) + + assertEquals(listOf("A" to 0, "B" to 25), index.asPairs()) + } + + @Test + fun `a sort that is not alphabetical gets no index`() { + val books = shelf(listOf('A', 'B', 'C'), 20) + val alphabetical = setOf( + SortMode.TITLE_AZ, SortMode.TITLE_ZA, SortMode.AUTHOR_AZ, SortMode.AUTHOR_ZA, + ) + + for (mode in SortMode.entries - alphabetical) { + assertTrue("index for $mode", flatLetterIndex(books, mode).isEmpty) + assertFalse("alphabetical $mode", sortsAlphabetically(mode)) + } + for (mode in alphabetical) { + assertTrue("alphabetical $mode", sortsAlphabetically(mode)) + } + } + + @Test + fun `a short shelf gets no index`() { + val books = shelf(listOf('A', 'B', 'C'), perLetter = 5) + + assertTrue(books.size < MIN_ROWS_FOR_LETTER_INDEX) + assertTrue(flatLetterIndex(books, SortMode.TITLE_AZ).isEmpty) + } + + @Test + fun `a shelf under a single letter gets no index`() { + val books = shelf(listOf('A'), perLetter = 60) + + assertTrue(flatLetterIndex(books, SortMode.TITLE_AZ).isEmpty) + } + + @Test + fun `an empty shelf gets no index`() { + assertTrue(flatLetterIndex(emptyList(), SortMode.TITLE_AZ).isEmpty) + assertTrue(groupedLetterIndex(emptyList(), SortMode.TITLE_AZ).isEmpty) + } + + @Test + fun `a grouped list indexes its headers and counts the rows between them`() { + // Expanded groups put book rows between headers. The offset is the + // header's position in the flat list, not its position among groups. + val sections = listOf("Apple" to 10, "Avocado" to 12, "Banana" to 20, "Cherry" to 30) + .map { (name, count) -> + GroupedSection(name, name, (0 until count).map { book(it, "$name book $it") }) + } + val items = flattenGroupedItems(sections, choices = sections.associate { it.key to true }) + + val index = groupedLetterIndex(items, SortMode.TITLE_AZ) + + assertEquals(listOf("A" to 0, "B" to 24, "C" to 45), index.asPairs()) + assertTrue(items[0] is LibraryListItem.GroupHeader) + assertTrue(items[24] is LibraryListItem.GroupHeader) + assertTrue(items[45] is LibraryListItem.GroupHeader) + } + + @Test + fun `book rows never add letters to a grouped index`() { + val sections = listOf("Alpha", "Beta", "Gamma").map { name -> + GroupedSection(name, name, (0 until 30).map { book(it, "Zebra $it") }) + } + val items = flattenGroupedItems(sections, choices = sections.associate { it.key to true }) + + assertEquals(listOf("A", "B", "G"), groupedLetterIndex(items, SortMode.TITLE_AZ).labels) + } + + @Test + fun `collapsed groups index just their headers`() { + val names = ('A'..'Z').flatMap { listOf("$it one", "$it two") } + val sections = names.map { GroupedSection(it, it, listOf(book(1, "x"))) } + val items = flattenGroupedItems(sections, choices = sections.associate { it.key to false }) + + val index = groupedLetterIndex(items, SortMode.TITLE_AZ) + + assertEquals(('A'..'Z').map { "$it" }, index.labels) + assertEquals((0 until 26).map { it * 2 }, index.offsets.toList()) + } + + @Test + fun `a grouped list that is not in alphabetical order gets no index`() { + val sections = listOf("Alpha", "Beta", "Gamma").map { name -> + GroupedSection(name, name, (0 until 30).map { book(it, "x $it") }) + } + val items = flattenGroupedItems(sections, choices = sections.associate { it.key to true }) + + assertTrue(groupedLetterIndex(items, SortMode.RECENTLY_PLAYED).isEmpty) + } + + @Test + fun `the work stops once every letter has been found`() { + var calls = 0 + val index = buildLetterIndex(rowCount = 50_000) { i -> + calls++ + // 26 letters then the number sign, then 49,973 more of the same. + if (i < 26) ('A' + i).toString() else if (i == 26) "#" else "Z" + } + + assertEquals(27, index.labels.size) + assertEquals(27, calls) + } + + @Test + fun `a 50,000 book shelf is indexed in one pass`() { + var calls = 0 + val index = buildLetterIndex(rowCount = 50_000) { i -> + calls++ + // Only ever two letters, so the pass cannot stop early. + if (i < 25_000) "A" else "B" + } + + assertEquals(listOf("A" to 0, "B" to 25_000), index.asPairs()) + assertEquals(50_000, calls) + } + + @Test + fun `the label for a scroll position is the last one that has started`() { + val index = LetterIndex(listOf("A", "B", "D"), intArrayOf(0, 20, 40)) + + assertEquals(0, index.labelIndexAt(0)) + assertEquals(0, index.labelIndexAt(19)) + assertEquals(1, index.labelIndexAt(20)) + assertEquals(1, index.labelIndexAt(39)) + assertEquals(2, index.labelIndexAt(40)) + assertEquals(2, index.labelIndexAt(10_000)) + } + + @Test + fun `a position before the first label counts as the first label`() { + val index = LetterIndex(listOf("A", "B"), intArrayOf(3, 20)) + + assertEquals(0, index.labelIndexAt(-1)) + assertEquals(0, index.labelIndexAt(0)) + } + + @Test + fun `a touch on the rail picks the letter under the finger`() { + // 27 letters in 540 px is 20 px each. + assertEquals(0, letterIndexAtTouch(y = 0f, heightPx = 540f, labelCount = 27)) + assertEquals(0, letterIndexAtTouch(y = 19.9f, heightPx = 540f, labelCount = 27)) + assertEquals(1, letterIndexAtTouch(y = 20f, heightPx = 540f, labelCount = 27)) + assertEquals(26, letterIndexAtTouch(y = 539.9f, heightPx = 540f, labelCount = 27)) + } + + @Test + fun `a drag past either end of the rail sticks to the first or last letter`() { + assertEquals(0, letterIndexAtTouch(y = -300f, heightPx = 540f, labelCount = 27)) + assertEquals(26, letterIndexAtTouch(y = 9_000f, heightPx = 540f, labelCount = 27)) + } + + @Test + fun `a rail with no height or no letters picks nothing unsafe`() { + assertEquals(0, letterIndexAtTouch(y = 10f, heightPx = 0f, labelCount = 27)) + assertEquals(0, letterIndexAtTouch(y = 10f, heightPx = 540f, labelCount = 0)) + } + + @Test + fun `every letter shows when each has room`() { + assertEquals(1, letterRailStride(labelCount = 27, heightPx = 540f, minSlotPx = 20f)) + } + + @Test + fun `a short rail shows every other letter or fewer`() { + // 27 letters at 20 px need 540 px. 270 px fits every second one. + assertEquals(2, letterRailStride(labelCount = 27, heightPx = 270f, minSlotPx = 20f)) + assertEquals(3, letterRailStride(labelCount = 27, heightPx = 180f, minSlotPx = 20f)) + } + + @Test + fun `a rail that is nearly tall enough still thins its letters`() { + // 540 px are needed and 400 px are there, so every letter would be + // crowded. Rounding the stride down to 1 would leave them all in. + assertEquals(2, letterRailStride(labelCount = 27, heightPx = 400f, minSlotPx = 20f)) + } + + @Test + fun `a rail with no height still has a stride`() { + assertEquals(1, letterRailStride(labelCount = 27, heightPx = 0f, minSlotPx = 20f)) + assertEquals(1, letterRailStride(labelCount = 0, heightPx = 100f, minSlotPx = 20f)) + } + + @Test + fun `an identity key matches the same list and never a copy`() { + val books = shelf(listOf('A', 'B'), 25) + val copy = books.toList() + + assertEquals(IdentityKey(books), IdentityKey(books)) + assertEquals(IdentityKey(books).hashCode(), IdentityKey(books).hashCode()) + assertFalse(IdentityKey(books) == IdentityKey(copy)) + } + + @Test + fun `TalkBack hears the number sign as words`() { + assertEquals("numbers and symbols", letterRailSpokenLabel("#")) + assertEquals("M", letterRailSpokenLabel("M")) + } +} From fce1f1f2f99a6e9faef01483d830578e8c662cbc Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 12:52:36 -0400 Subject: [PATCH 10/23] Book downloads wait for Wi-Fi unless you send one over mobile data (#79) A new Download on Wi-Fi only setting, on by default. Settings saved before it existed decode to the default, so upgrades get it on too. The single drain worker now carries the network rule: it waits for an unmetered network while the setting is on. Sending one download over mobile data runs the drain on any connection without touching the setting, and that drain takes only the books sent that way. When it runs out, the rest go to a follow-up drain appended behind it that waits for Wi-Fi. A drain request from before this change reads as one on any connection, so with the setting on it starts nothing on its own. Flipping the setting applies to work already queued. Turning it on stops a book streaming over mobile data and makes it wait, unless the user sent it that way. Turning it off starts a queue that was waiting, and frees a drain still bound to Wi-Fi. Every entry point goes through the same enqueue, so Book Detail, Downloads, the paused notification and entitlement changes all follow the rule. The per-download choice lives in device-local prefs, so there is no schema change. Co-Authored-By: Claude Opus 5.5 --- .../app/domain/model/AppSettings.kt | 3 + .../app/entitlement/EntitlementCachePrefs.kt | 11 ++ .../app/service/DownloadManager.kt | 185 ++++++++++++++++-- .../service/download/DownloadNetworkPolicy.kt | 119 +++++++++++ .../service/download/DownloadQueueWorker.kt | 23 ++- .../app/service/download/DownloadSlotStore.kt | 7 + .../app/domain/model/AppSettingsTest.kt | 19 ++ .../download/DownloadNetworkPolicyTest.kt | 170 ++++++++++++++++ 8 files changed, 512 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/service/download/DownloadNetworkPolicy.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/download/DownloadNetworkPolicyTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/domain/model/AppSettings.kt b/app/src/main/java/com/ninelivesaudio/app/domain/model/AppSettings.kt index 76976dc..aa04e84 100644 --- a/app/src/main/java/com/ninelivesaudio/app/domain/model/AppSettings.kt +++ b/app/src/main/java/com/ninelivesaudio/app/domain/model/AppSettings.kt @@ -12,6 +12,9 @@ data class AppSettings( val selectedLibraryId: String? = null, // persisted library selection val selectedLocalLibraryId: String? = null, // persisted local library selection val downloadPath: String = "", + // Book downloads wait for an unmetered network. On by default, and settings + // saved before this existed decode to the default, so upgrades get it too. + val downloadOnWifiOnly: Boolean = true, val autoDownloadCovers: Boolean = true, val playbackSpeed: Double = 1.0, val autoSyncProgress: Boolean = true, diff --git a/app/src/main/java/com/ninelivesaudio/app/entitlement/EntitlementCachePrefs.kt b/app/src/main/java/com/ninelivesaudio/app/entitlement/EntitlementCachePrefs.kt index 076dd0a..5e658c6 100644 --- a/app/src/main/java/com/ninelivesaudio/app/entitlement/EntitlementCachePrefs.kt +++ b/app/src/main/java/com/ninelivesaudio/app/entitlement/EntitlementCachePrefs.kt @@ -83,9 +83,20 @@ class EntitlementCachePrefs @Inject constructor( get() = prefs.getBoolean(KEY_DOWNLOADS_PAUSED, false) set(value) = prefs.edit().putBoolean(KEY_DOWNLOADS_PAUSED, value).apply() + /** + * Download row ids the user sent over mobile data while "Download on Wi-Fi + * only" is on (#79). Kept here rather than on the row, so it needs no + * schema change, and device-local for the same reason as the pause. + */ + var meteredOverrideDownloadIds: Set + // Copied out: the set getStringSet returns must never be modified. + get() = prefs.getStringSet(KEY_METERED_OVERRIDES, null)?.toSet() ?: emptySet() + set(value) = prefs.edit().putStringSet(KEY_METERED_OVERRIDES, value.toSet()).apply() + companion object { const val FILE_NAME = "nine_lives_entitlement_cache" const val KEY_DOWNLOADS_PAUSED = "downloads_paused" + const val KEY_METERED_OVERRIDES = "metered_override_download_ids" const val KEY_SLOT_WINNER = "slot_winner_audiobook_id" const val KEY_PLAY_UNLOCK = "play_unlock_cached" const val KEY_FORCE_FREE = "force_free" diff --git a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt index d61443c..00c9deb 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt @@ -1,10 +1,7 @@ package com.ninelivesaudio.app.service import android.content.Context -import androidx.work.Constraints import androidx.work.ExistingWorkPolicy -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import com.ninelivesaudio.app.data.local.converter.toDomain import com.ninelivesaudio.app.data.local.converter.toEntity @@ -33,14 +30,24 @@ import com.ninelivesaudio.app.service.download.DownloadSlotStore import com.ninelivesaudio.app.service.download.ResumeDecision import com.ninelivesaudio.app.service.download.decideResume import com.ninelivesaudio.app.service.download.selectNextDownload +import com.ninelivesaudio.app.service.download.drainRequest +import com.ninelivesaudio.app.service.download.drainWaitsForUnmetered +import com.ninelivesaudio.app.service.download.drainWorkPolicy +import com.ninelivesaudio.app.service.download.liveOverrides +import com.ninelivesaudio.app.service.download.mayDownloadOnDrain +import com.ninelivesaudio.app.service.download.overrideRestartsDrain +import com.ninelivesaudio.app.service.download.wifiRuleChangeRestartsDrain import com.ninelivesaudio.app.service.download.writeAfterEngineStops import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.guava.await @@ -146,8 +153,16 @@ class DownloadManager @Inject constructor( @Volatile private var drainRunning = false + /** + * Whether the running drain was enqueued to wait for Wi-Fi. Only + * meaningful while [drainRunning]. See [mayDownloadOnDrain]. + */ + @Volatile + private var drainUnmetered = false + /** Called by [DownloadQueueWorker] on entry to doWork(). */ - fun onDrainStarted() { + fun onDrainStarted(unmetered: Boolean) { + drainUnmetered = unmetered drainRunning = true } @@ -163,14 +178,130 @@ class DownloadManager @Inject constructor( @Volatile private var engineBookId: String? = null + /** The download row the engine is on, for the Wi-Fi only rule. */ + @Volatile + private var engineDownloadId: String? = null + /** Called by [DownloadQueueWorker] right before it runs the engine on a book. */ - fun onEngineStarted(audioBookId: String) { + fun onEngineStarted(audioBookId: String, downloadId: String) { engineBookId = audioBookId + engineDownloadId = downloadId } /** Called by [DownloadQueueWorker] from a finally once the engine returns or throws. */ fun onEngineStopped() { engineBookId = null + engineDownloadId = null + } + + // ─── Wi-Fi only (#79) ──────────────────────────────────────────────────── + + private val wifiOnly: Boolean + get() = settingsManager.currentSettings.downloadOnWifiOnly + + private val _meteredOverrides by lazy { MutableStateFlow(slotStore.meteredOverrides) } + + /** + * Download ids the user sent over mobile data while Wi-Fi only is on. + * Each one runs on any connection until it finishes, is cancelled or is + * deleted. The setting itself never changes. + */ + val meteredOverrides: StateFlow> get() = _meteredOverrides.asStateFlow() + + private fun writeOverrides(ids: Set) { + if (ids == _meteredOverrides.value) return + slotStore.meteredOverrides = ids + _meteredOverrides.value = ids + } + + private fun dropMeteredOverride(downloadId: String) { + writeOverrides(_meteredOverrides.value - downloadId) + } + + /** Overrides with a row still behind them. Finished rows let theirs go. */ + private suspend fun prunedOverrides(): Set { + val liveIds = downloadItemDao.getAll() + .filter { it.status != DownloadStatus.Completed.ordinal } + .map { it.id } + return liveOverrides(_meteredOverrides.value, liveIds).also(::writeOverrides) + } + + /** + * Start a download that is waiting for Wi-Fi over mobile data, once, + * without touching the setting. False when the row is gone or is not + * waiting to download. + */ + suspend fun startOnMobileData(downloadId: String): Boolean = finishInOwnerScope(scope) { + rowMutex.withLock { + val entity = downloadItemDao.getById(downloadId) ?: return@withLock false + val status = entity.toDomain().status + if (status != DownloadStatus.Queued && status != DownloadStatus.Downloading) return@withLock false + writeOverrides(_meteredOverrides.value + downloadId) + if (overrideRestartsDrain(drainRunning, drainUnmetered)) restartDrainHoldingRowLock() + true + } + } + + /** The books this drain may start right now. See [mayDownloadOnDrain]. */ + fun runnableOnDrain(items: List, drainUnmetered: Boolean): List { + val wifiOnly = wifiOnly + val overrides = _meteredOverrides.value + return items.filter { mayDownloadOnDrain(wifiOnly, drainUnmetered, it.id in overrides) } + } + + /** + * Called by the drain when it has nothing it may start. True when + * something runnable showed up after all, so the drain goes round again. + * + * Otherwise whatever is left is waiting for Wi-Fi, and a follow-up drain + * that waits for an unmetered network is appended behind this one. Under + * [rowMutex], so a mobile data override cannot slip in between this + * decision and the drain exiting: once this returns false the drain counts + * as stopped, and an override replaces it rather than joining it. + */ + suspend fun finishDrainOrContinue(drainUnmetered: Boolean): Boolean = rowMutex.withLock { + if (downloadsPaused) return@withLock false + val left = filterToSlotWinner(downloadItemDao.getDownloadable().map { it.toDomain() }) + if (left.isEmpty()) return@withLock false + if (selectNextDownload(runnableOnDrain(left, drainUnmetered)) != null) return@withLock true + + // The engine is off for good in this drain, so it can stop counting + // as running before doWork() actually returns. + drainRunning = false + android.util.Log.d("DownloadManager", "drain idle: ${left.size} waiting for Wi-Fi") + workManager.enqueueUniqueWork( + DOWNLOAD_WORK_NAME, + ExistingWorkPolicy.APPEND_OR_REPLACE, + drainRequest(waitForUnmetered = true), + ) + false + } + + /** + * Apply a flip of the Wi-Fi only setting to work already queued. See + * [wifiRuleChangeRestartsDrain] for what restarts. Respects a paused + * queue, and does nothing with nothing to download. + */ + suspend fun onWifiOnlyChanged(): Unit = finishInOwnerScope(scope) { + rowMutex.withLock { + if (downloadsPaused) return@withLock + if (downloadItemDao.getDownloadable().isEmpty()) return@withLock + val engineRowOverridden = engineDownloadId?.let { it in _meteredOverrides.value } ?: false + if (wifiRuleChangeRestartsDrain(wifiOnly, drainRunning, drainUnmetered, engineRowOverridden)) { + restartDrainHoldingRowLock() + } + } + } + + /** + * Stop any running drain, confirmed, and start a fresh one under the + * current rule. The stop comes first so two engines never share a book's + * `.part` file. Caller holds [rowMutex]. + */ + private suspend fun restartDrainHoldingRowLock() { + if (downloadsPaused) return + if (drainRunning) stopDrainAndAwait() + enqueueDrainHoldingRowLock(replace = true) } /** @@ -219,7 +350,11 @@ class DownloadManager @Inject constructor( * "Queued..." forever with nothing to correct it. */ sealed interface QueueResult { - data class Queued(val item: DownloadItem) : QueueResult + /** + * [waitingForWifi] is true when Wi-Fi only holds it back right now, so + * the caller can say so instead of showing a download that never moves. + */ + data class Queued(val item: DownloadItem, val waitingForWifi: Boolean = false) : QueueResult /** * The free tier already holds its one offline book. @@ -361,7 +496,10 @@ class DownloadManager @Inject constructor( // Respect a paused queue: the book waits until the user resumes. if (!downloadsPaused) enqueueDrain(replace = false) - return QueueResult.Queued(downloadItem) + return QueueResult.Queued( + item = downloadItem, + waitingForWifi = wifiOnly && connectivityMonitor.isMetered.value, + ) } finally { // NonCancellable is load-bearing. This whole function runs in the @@ -592,6 +730,7 @@ class DownloadManager @Inject constructor( var stopConfirmed = false writeAfterEngineStops(downloading, { stopConfirmed = stopDrainAndAwait() }) { downloadItemDao.deleteById(downloadId) + dropMeteredOverride(downloadId) // Still before any drain restart below, so nothing is writing here. if (entity != null && cancelMayTouchFiles(entity.status, downloading, stopConfirmed)) { removeCancelledPartialFiles(entity.audioBookId) @@ -702,6 +841,7 @@ class DownloadManager @Inject constructor( } if (downloadId != null) { downloadItemDao.deleteById(downloadId) + dropMeteredOverride(downloadId) } } @@ -787,9 +927,11 @@ class DownloadManager @Inject constructor( /** * Ensure the single download-queue worker is running. * - * [replace] = false (KEEP): used when adding work. If a drain worker is - * already running it keeps going and picks up the new Queued row on its next - * loop; otherwise a fresh one starts. + * [replace] = false: used when adding work. If a drain worker is already + * running in this process it keeps going (KEEP) and picks up the new Queued + * row on its next loop. Otherwise any idle drain still waiting on its + * network constraint is swapped (REPLACE) for one under the current Wi-Fi + * only rule. See [drainWorkPolicy]. * * [replace] = true (REPLACE): used when pausing/cancelling the active book. * It cancels the running drain (stopping the engine on the current book) and @@ -802,7 +944,7 @@ class DownloadManager @Inject constructor( } /** [enqueueDrain] for a caller that already holds [rowMutex]. */ - private fun enqueueDrainHoldingRowLock(replace: Boolean) { + private suspend fun enqueueDrainHoldingRowLock(replace: Boolean) { // One gate for every automatic restart. pauseDownload, cancelDownload // and deleteDownload all restart the drain on their own, and none of // them knew about a queue-level pause, so any of them would silently @@ -810,15 +952,16 @@ class DownloadManager @Inject constructor( // deliberate path is unaffected. if (downloadsPaused) return - val request = OneTimeWorkRequestBuilder() - .setConstraints( - Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - ) - .build() - val policy = if (replace) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP - android.util.Log.d("DownloadManager", "enqueueDrain policy=${if (replace) "REPLACE" else "KEEP"}") - workManager.enqueueUniqueWork(DOWNLOAD_WORK_NAME, policy, request) + // Every entry point lands here (Book Detail, Downloads, the paused + // notification's Resume, entitlement changes), so all of them get the + // Wi-Fi only rule. A queued book sent over mobile data lets the drain + // run on any connection, and that drain takes only such books. + val overrides = prunedOverrides() + val overrideQueued = overrides.isNotEmpty() && + downloadItemDao.getDownloadable().any { it.id in overrides } + val waitForUnmetered = drainWaitsForUnmetered(wifiOnly, overrideQueued) + val policy = drainWorkPolicy(replace, drainRunning) + android.util.Log.d("DownloadManager", "enqueueDrain policy=$policy unmetered=$waitForUnmetered") + workManager.enqueueUniqueWork(DOWNLOAD_WORK_NAME, policy, drainRequest(waitForUnmetered)) } } diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadNetworkPolicy.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadNetworkPolicy.kt new file mode 100644 index 0000000..0c07f4c --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadNetworkPolicy.kt @@ -0,0 +1,119 @@ +package com.ninelivesaudio.app.service.download + +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.workDataOf +import com.ninelivesaudio.app.domain.model.DownloadStatus + +// ─── "Download on Wi-Fi only" (#79, decisions are unit-testable) ────────── +// +// One drain worker runs the whole queue, so the network rule lives on that +// worker. With the setting on it waits for an unmetered network. A book the +// user sends over mobile data runs the drain on any connection instead, and +// that drain takes only the books sent that way. Whatever is left behind +// gets a follow-up drain that waits for Wi-Fi. + +/** Input key: true when the drain was enqueued to wait for an unmetered network. */ +internal const val KEY_DRAIN_UNMETERED = "drain_unmetered" + +/** Copy for the Wi-Fi only setting and the downloads waiting on it. */ +object WifiOnlyCopy { + const val SETTING_TITLE = "Download on Wi-Fi only" + const val SETTING_SUBTITLE = + "Books wait for Wi-Fi before they download. Tap Use mobile data in Downloads to skip the wait." + const val WAITING_STATUS = "Waiting for Wi-Fi" + const val USE_MOBILE_DATA = "Use mobile data" + const val QUEUED_NOTICE = "Waiting for Wi-Fi. Tap Use mobile data in Downloads to start it now." +} + +/** + * Whether the next drain should wait for an unmetered network: only with the + * setting on and no book queued that the user sent over mobile data. + */ +internal fun drainWaitsForUnmetered(wifiOnly: Boolean, overrideQueued: Boolean): Boolean = + wifiOnly && !overrideQueued + +/** + * The drain worker request. [waitForUnmetered] picks the network constraint + * and rides along as input, so the worker knows which kind of drain it is. + * A request from before this setting carries no input and reads as a drain + * on any connection, which then takes only overridden books. + */ +internal fun drainRequest(waitForUnmetered: Boolean): OneTimeWorkRequest = + OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(if (waitForUnmetered) NetworkType.UNMETERED else NetworkType.CONNECTED) + .build() + ) + .setInputData(workDataOf(KEY_DRAIN_UNMETERED to waitForUnmetered)) + .build() + +/** + * How to enqueue the drain. A replace stays a replace. Adding work joins a + * drain that is running in this process (it picks new rows up on its next + * loop), but replaces an idle one, which may be sitting on the old network + * rule waiting for Wi-Fi that a mobile data override no longer needs. + */ +internal fun drainWorkPolicy(replace: Boolean, drainRunning: Boolean): ExistingWorkPolicy = + if (replace || !drainRunning) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP + +/** + * Whether a drain may start this book. A drain on Wi-Fi takes everything, and + * so does any drain with the setting off. A drain on any connection with the + * setting on takes only books the user sent over mobile data. + */ +internal fun mayDownloadOnDrain(wifiOnly: Boolean, drainUnmetered: Boolean, overridden: Boolean): Boolean = + !wifiOnly || drainUnmetered || overridden + +/** + * Whether flipping the setting has to restart the drain. + * + * With no drain running, always: the queue gets re-enqueued under the new + * rule (a drain waiting for Wi-Fi starts at once once the setting is off). + * Turning the setting on stops a drain running on any connection, so a book + * already streaming over mobile data stops and waits, unless it is a book the + * user sent over mobile data on purpose. Turning it off swaps a drain bound to + * Wi-Fi for one on any connection (the book in flight restarts its current + * file), or losing Wi-Fi later would still park the queue. A drain already + * on any connection carries on, since it now takes every book. + */ +internal fun wifiRuleChangeRestartsDrain( + wifiOnly: Boolean, + drainRunning: Boolean, + drainUnmetered: Boolean, + engineRowOverridden: Boolean, +): Boolean = when { + !drainRunning -> true + !wifiOnly -> drainUnmetered + drainUnmetered -> false + else -> !engineRowOverridden +} + +/** + * Whether sending a book over mobile data has to restart the drain. A drain + * running on any connection picks it up on its next loop. Anything else (no + * drain, or one bound to Wi-Fi that is about to be stopped for losing it) + * would leave the book waiting, so the drain is replaced. + */ +internal fun overrideRestartsDrain(drainRunning: Boolean, drainUnmetered: Boolean): Boolean = + !drainRunning || drainUnmetered + +/** The overrides whose download row still exists, so the stored set cannot grow forever. */ +internal fun liveOverrides(overrides: Set, liveRowIds: Collection): Set { + val live = liveRowIds.toHashSet() + return overrides.filterTo(HashSet()) { it in live } +} + +/** + * Whether a download is held back by the setting right now, so the screen + * says "Waiting for Wi-Fi" instead of a Queued row or a frozen progress bar. + * A Downloading row counts too: it is what a book interrupted by losing Wi-Fi + * looks like until the drain picks it up again. + */ +internal fun isWaitingForWifi(status: DownloadStatus, wifiOnly: Boolean, metered: Boolean, overridden: Boolean): Boolean = + wifiOnly && metered && !overridden && + (status == DownloadStatus.Queued || status == DownloadStatus.Downloading) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadQueueWorker.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadQueueWorker.kt index 64897a7..b2d2e29 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadQueueWorker.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadQueueWorker.kt @@ -50,8 +50,16 @@ class DownloadQueueWorker( EntryPointAccessors.fromApplication(applicationContext, Deps::class.java) } + /** + * Whether this drain was enqueued to wait for Wi-Fi. A request from before + * the Wi-Fi only setting carries no input and reads false, so with the + * setting on it only takes books sent over mobile data. + */ + private val unmetered: Boolean + get() = inputData.getBoolean(KEY_DRAIN_UNMETERED, false) + override suspend fun doWork(): Result = try { - deps.downloadManager().onDrainStarted() + deps.downloadManager().onDrainStarted(unmetered) drain() } finally { // Cleared on EVERY exit path, including cancellation. Entitlement-drop @@ -87,7 +95,14 @@ class DownloadQueueWorker( // orders by age, so an unfiltered list would meet a PRESERVED loser // first and start downloading a book the free tier cannot keep. val active = manager.filterToSlotWinner(dao.getDownloadable().map { it.toDomain() }) - val item = selectNextDownload(active) ?: break + // With Wi-Fi only on, a drain running on any connection takes only + // books the user sent over mobile data. When nothing it may start + // is left, the manager hands the rest to a drain that waits for Wi-Fi. + val item = selectNextDownload(manager.runnableOnDrain(active, unmetered)) + if (item == null) { + if (manager.finishDrainOrContinue(unmetered)) continue + break + } android.util.Log.d(TAG, "next id=${item.id} status=${item.status} title=${item.title}") val bookEntity = books.getById(item.audioBookId) @@ -116,7 +131,7 @@ class DownloadQueueWorker( // get dropped and the bar appears frozen / far behind. var lastNotifiedPercent = -1 // Cancel cleanup checks this last thing before deleting files. - manager.onEngineStarted(item.audioBookId) + manager.onEngineStarted(item.audioBookId, item.id) val result = try { withContext(Dispatchers.IO) { engine.download(item, book) { id, downloaded, total -> @@ -139,7 +154,7 @@ class DownloadQueueWorker( android.util.Log.d(TAG, "done id=${item.id} result=${result.status}") } - android.util.Log.d(TAG, "drain END (queue empty)") + android.util.Log.d(TAG, "drain END (nothing left this drain may start)") // Best-effort: drop the ongoing notification when there is nothing left. // (Skipped when paused, where the standalone paused notification stands in.) if (!manager.isDownloadsPaused()) { diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadSlotStore.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadSlotStore.kt index c984d7f..cb51c53 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadSlotStore.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadSlotStore.kt @@ -48,6 +48,13 @@ class DownloadSlotStore @Inject constructor( cache.downloadsPaused = value } + /** Downloads sent over mobile data. See [EntitlementCachePrefs.meteredOverrideDownloadIds]. */ + var meteredOverrides: Set + get() = cache.meteredOverrideDownloadIds + set(value) { + cache.meteredOverrideDownloadIds = value + } + var persistedWinner: String? get() = cache.slotWinnerAudioBookId set(value) { diff --git a/app/src/test/java/com/ninelivesaudio/app/domain/model/AppSettingsTest.kt b/app/src/test/java/com/ninelivesaudio/app/domain/model/AppSettingsTest.kt index 155b9fa..1fcc569 100644 --- a/app/src/test/java/com/ninelivesaudio/app/domain/model/AppSettingsTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/domain/model/AppSettingsTest.kt @@ -153,6 +153,25 @@ class AppSettingsTest { assertFalse(AudioBook(libraryId = "local-library", isLocal = true).isInActiveLibrary(serverSettings)) } + @Test + fun `downloads wait for Wi-Fi by default`() { + assertTrue(AppSettings().downloadOnWifiOnly) + } + + @Test + fun `an upgrade from settings saved before Wi-Fi only gets it on`() { + val legacy = """{"appMode":"AUDIOBOOKSHELF","serverUrl":"https://example.com","downloadPath":"/x"}""" + val decoded = json.decodeFromString(legacy) + + assertTrue(decoded.downloadOnWifiOnly) + } + + @Test + fun `turning Wi-Fi only off survives a round trip`() { + val decoded = json.decodeFromString(json.encodeToString(AppSettings(downloadOnWifiOnly = false))) + assertFalse(decoded.downloadOnWifiOnly) + } + @Test fun `orphan book is rejected when no active library is selected`() { val settings = AppSettings( diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadNetworkPolicyTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadNetworkPolicyTest.kt new file mode 100644 index 0000000..0874040 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadNetworkPolicyTest.kt @@ -0,0 +1,170 @@ +package com.ninelivesaudio.app.service.download + +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import com.ninelivesaudio.app.domain.model.DownloadStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * "Download on Wi-Fi only" (#79). The single drain worker waits for an + * unmetered network while the setting is on, a per-download override runs it + * on any connection without touching the setting, and a waiting download says + * it is waiting instead of looking stuck. + */ +class DownloadNetworkPolicyTest { + + private fun OneTimeWorkRequest.networkType(): NetworkType = workSpec.constraints.requiredNetworkType + private fun OneTimeWorkRequest.waitsForUnmetered(): Boolean = workSpec.input.getBoolean(KEY_DRAIN_UNMETERED, false) + + private fun requestFor(wifiOnly: Boolean, overrideQueued: Boolean) = + drainRequest(drainWaitsForUnmetered(wifiOnly = wifiOnly, overrideQueued = overrideQueued)) + + // ─── The worker request ────────────────────────────────────────────────── + + @Test + fun `with Wi-Fi only on the drain waits for an unmetered network`() { + val request = requestFor(wifiOnly = true, overrideQueued = false) + + assertEquals(NetworkType.UNMETERED, request.networkType()) + assertTrue(request.waitsForUnmetered()) + } + + @Test + fun `with Wi-Fi only off the drain runs on any connection`() { + val request = requestFor(wifiOnly = false, overrideQueued = false) + + assertEquals(NetworkType.CONNECTED, request.networkType()) + assertFalse(request.waitsForUnmetered()) + } + + @Test + fun `a mobile data override runs the drain on any connection`() { + val request = requestFor(wifiOnly = true, overrideQueued = true) + + assertEquals(NetworkType.CONNECTED, request.networkType()) + assertFalse(request.waitsForUnmetered()) + } + + // ─── What a drain may download ─────────────────────────────────────────── + + @Test + fun `a drain on any connection only takes overridden books while Wi-Fi only is on`() { + assertTrue(mayDownloadOnDrain(wifiOnly = true, drainUnmetered = false, overridden = true)) + assertFalse(mayDownloadOnDrain(wifiOnly = true, drainUnmetered = false, overridden = false)) + } + + @Test + fun `a drain on Wi-Fi takes every book`() { + assertTrue(mayDownloadOnDrain(wifiOnly = true, drainUnmetered = true, overridden = false)) + } + + @Test + fun `with Wi-Fi only off every book goes, even on an old request`() { + assertTrue(mayDownloadOnDrain(wifiOnly = false, drainUnmetered = false, overridden = false)) + } + + // ─── Already queued work when the setting flips ────────────────────────── + + @Test + fun `turning Wi-Fi only on stops a book downloading on mobile data`() { + assertTrue( + wifiRuleChangeRestartsDrain(wifiOnly = true, drainRunning = true, drainUnmetered = false, engineRowOverridden = false) + ) + } + + @Test + fun `turning Wi-Fi only on leaves a book the user sent over mobile data running`() { + assertFalse( + wifiRuleChangeRestartsDrain(wifiOnly = true, drainRunning = true, drainUnmetered = false, engineRowOverridden = true) + ) + } + + @Test + fun `turning Wi-Fi only on leaves a drain already on Wi-Fi running`() { + assertFalse( + wifiRuleChangeRestartsDrain(wifiOnly = true, drainRunning = true, drainUnmetered = true, engineRowOverridden = false) + ) + } + + @Test + fun `turning Wi-Fi only off lets a drain on any connection carry on`() { + assertFalse( + wifiRuleChangeRestartsDrain(wifiOnly = false, drainRunning = true, drainUnmetered = false, engineRowOverridden = false) + ) + } + + @Test + fun `turning Wi-Fi only off frees a drain still bound to Wi-Fi`() { + // Left alone, losing Wi-Fi later would park the queue with the setting off. + assertTrue( + wifiRuleChangeRestartsDrain(wifiOnly = false, drainRunning = true, drainUnmetered = true, engineRowOverridden = false) + ) + } + + @Test + fun `with no drain running a flip re-enqueues the queue under the new rule`() { + assertTrue( + wifiRuleChangeRestartsDrain(wifiOnly = false, drainRunning = false, drainUnmetered = false, engineRowOverridden = false) + ) + assertTrue( + wifiRuleChangeRestartsDrain(wifiOnly = true, drainRunning = false, drainUnmetered = false, engineRowOverridden = false) + ) + } + + // ─── The override ──────────────────────────────────────────────────────── + + @Test + fun `an override restarts a drain that is waiting or stuck on Wi-Fi rules`() { + assertTrue(overrideRestartsDrain(drainRunning = false, drainUnmetered = false)) + assertTrue(overrideRestartsDrain(drainRunning = true, drainUnmetered = true)) + } + + @Test + fun `an override joins a drain already running on any connection`() { + assertFalse(overrideRestartsDrain(drainRunning = true, drainUnmetered = false)) + } + + @Test + fun `overrides for rows that are gone are dropped`() { + assertEquals(setOf("a"), liveOverrides(setOf("a", "gone"), liveRowIds = listOf("a", "b"))) + } + + // ─── Starting the drain ────────────────────────────────────────────────── + + @Test + fun `adding work joins a running drain and replaces an idle one`() { + assertEquals(ExistingWorkPolicy.KEEP, drainWorkPolicy(replace = false, drainRunning = true)) + // An idle drain may be sitting on the old network rule, so it is swapped. + assertEquals(ExistingWorkPolicy.REPLACE, drainWorkPolicy(replace = false, drainRunning = false)) + assertEquals(ExistingWorkPolicy.REPLACE, drainWorkPolicy(replace = true, drainRunning = true)) + } + + // ─── Saying it is waiting ──────────────────────────────────────────────── + + @Test + fun `a queued book on mobile data with Wi-Fi only on is waiting for Wi-Fi`() { + assertTrue(isWaitingForWifi(DownloadStatus.Queued, wifiOnly = true, metered = true, overridden = false)) + } + + @Test + fun `a book interrupted when Wi-Fi dropped is waiting, not stuck`() { + assertTrue(isWaitingForWifi(DownloadStatus.Downloading, wifiOnly = true, metered = true, overridden = false)) + } + + @Test + fun `nothing waits on Wi-Fi, with the setting off, or once overridden`() { + assertFalse(isWaitingForWifi(DownloadStatus.Queued, wifiOnly = true, metered = false, overridden = false)) + assertFalse(isWaitingForWifi(DownloadStatus.Queued, wifiOnly = false, metered = true, overridden = false)) + assertFalse(isWaitingForWifi(DownloadStatus.Queued, wifiOnly = true, metered = true, overridden = true)) + } + + @Test + fun `paused and failed books are not waiting for Wi-Fi`() { + assertFalse(isWaitingForWifi(DownloadStatus.Paused, wifiOnly = true, metered = true, overridden = false)) + assertFalse(isWaitingForWifi(DownloadStatus.Failed, wifiOnly = true, metered = true, overridden = false)) + } +} From debbb8005251c1167cc1a1675a0a18e737996c3d Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 12:52:43 -0400 Subject: [PATCH 11/23] A download held for Wi-Fi says so, with a way to use mobile data now (#79) Downloads shows Waiting for Wi-Fi instead of Queued, or instead of a Downloading bar frozen after Wi-Fi dropped, plus a Use mobile data button that starts that one book without changing the setting. Book Detail says the same when a book queues on mobile data. Settings gets the Download on Wi-Fi only switch in the Data group, and flipping it applies to downloads already queued. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/bookdetail/BookDetailViewModel.kt | 11 ++++- .../app/ui/downloads/DownloadsScreen.kt | 42 +++++++++++++++--- .../app/ui/downloads/DownloadsViewModel.kt | 43 ++++++++++++++++++- .../app/ui/settings/SettingsScreen.kt | 10 +++++ .../app/ui/settings/SettingsViewModel.kt | 19 ++++++++ 5 files changed, 115 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/bookdetail/BookDetailViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/bookdetail/BookDetailViewModel.kt index 066e25e..c2f3ead 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/bookdetail/BookDetailViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/bookdetail/BookDetailViewModel.kt @@ -22,6 +22,7 @@ import com.ninelivesaudio.app.service.PlaybackManager import com.ninelivesaudio.app.service.RemoteMediaAccessDecision import com.ninelivesaudio.app.service.remoteMediaAccessDecision import com.ninelivesaudio.app.service.SettingsManager +import com.ninelivesaudio.app.service.download.WifiOnlyCopy import com.ninelivesaudio.app.service.local.LocalFolderAccess import com.ninelivesaudio.app.service.local.reconcileLocalBookAccess import dagger.hilt.android.lifecycle.HiltViewModel @@ -412,8 +413,14 @@ class BookDetailViewModel @Inject constructor( // Clear any previous notice. Leaving a stale "free keeps one // book" warning up after a retry that WORKED, or after the user // unlocked and came back, would be its own small lie. - is DownloadManager.QueueResult.Queued -> - _uiState.update { it.copy(downloadNotice = null, downloadNoticeOffersUnlock = false) } + // A book held for Wi-Fi says so, or the Queued button just + // sits there on mobile data looking broken. + is DownloadManager.QueueResult.Queued -> _uiState.update { + it.copy( + downloadNotice = if (result.waitingForWifi) WifiOnlyCopy.QUEUED_NOTICE else null, + downloadNoticeOffersUnlock = false, + ) + } is DownloadManager.QueueResult.BlockedByFreeSlot -> { // The free tier holds one offline book. Say so, out loud, diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsScreen.kt index 72d1113..3f511a4 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsScreen.kt @@ -25,6 +25,7 @@ import com.ninelivesaudio.app.ui.components.BookCoverImage import com.ninelivesaudio.app.ui.components.THUMBNAIL_COVER_WIDTH_PX import com.ninelivesaudio.app.domain.model.DownloadStatus import com.ninelivesaudio.app.domain.util.toDisplaySize +import com.ninelivesaudio.app.service.download.WifiOnlyCopy import com.ninelivesaudio.app.ui.components.ArchiveScreenHeader import com.ninelivesaudio.app.ui.copy.unhinged.CopyEngine @@ -88,6 +89,8 @@ fun DownloadsScreen( ) { item -> ActiveDownloadCard( item = item, + waitingForWifi = uiState.waitingForWifi(item.download), + onUseMobileData = { viewModel.startOnMobileData(item.download.id) }, onPause = { viewModel.pauseDownload(item.download.id) }, onResume = { viewModel.resumeDownload(item.download.id) }, onCancel = { viewModel.cancelDownload(item.download.id) }, @@ -146,6 +149,8 @@ private fun SectionHeader(title: String) { @Composable private fun ActiveDownloadCard( item: DownloadsViewModel.DownloadUiItem, + waitingForWifi: Boolean, + onUseMobileData: () -> Unit, onPause: () -> Unit, onResume: () -> Unit, onCancel: () -> Unit, @@ -199,12 +204,15 @@ private fun ActiveDownloadCard( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Status badge - val (statusText, statusColor) = when (download.status) { - DownloadStatus.Queued -> "Queued" to NineLivesTheme.colors.archiveTextMuted - DownloadStatus.Downloading -> "Downloading" to NineLivesTheme.colors.goldFilament - DownloadStatus.Paused -> "Paused" to NineLivesTheme.colors.archiveWarning - DownloadStatus.Failed -> "Failed" to NineLivesTheme.colors.archiveError + // Status badge. Waiting for Wi-Fi reads as its own state: + // without it a book interrupted by losing Wi-Fi sat on + // "Downloading" with a bar that never moved. + val (statusText, statusColor) = when { + waitingForWifi -> WifiOnlyCopy.WAITING_STATUS to NineLivesTheme.colors.archiveWarning + download.status == DownloadStatus.Queued -> "Queued" to NineLivesTheme.colors.archiveTextMuted + download.status == DownloadStatus.Downloading -> "Downloading" to NineLivesTheme.colors.goldFilament + download.status == DownloadStatus.Paused -> "Paused" to NineLivesTheme.colors.archiveWarning + download.status == DownloadStatus.Failed -> "Failed" to NineLivesTheme.colors.archiveError else -> "" to NineLivesTheme.colors.archiveTextMuted } Text( @@ -314,6 +322,28 @@ private fun ActiveDownloadCard( ) } } + + // One book over mobile data, now. The setting stays as it is. + if (waitingForWifi) { + TextButton( + onClick = onUseMobileData, + modifier = Modifier.align(Alignment.End), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), + ) { + Icon( + Icons.Outlined.SignalCellularAlt, + contentDescription = null, + tint = NineLivesTheme.colors.goldFilament, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = WifiOnlyCopy.USE_MOBILE_DATA, + style = MaterialTheme.typography.labelMedium, + color = NineLivesTheme.colors.goldFilament, + ) + } + } } } } diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsViewModel.kt index 134fb61..a7ddbeb 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsViewModel.kt @@ -12,6 +12,7 @@ import com.ninelivesaudio.app.service.ConnectivityMonitor import com.ninelivesaudio.app.service.ConnectivityMonitor.ConnectionStatus import com.ninelivesaudio.app.service.DownloadManager import com.ninelivesaudio.app.service.SettingsManager +import com.ninelivesaudio.app.service.download.isWaitingForWifi import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -40,7 +41,21 @@ class DownloadsViewModel @Inject constructor( val isLocalMode: Boolean = false, /** Why the last Resume or Retry did not start, or null. */ val notice: String? = null, - ) + /** The "Download on Wi-Fi only" setting. */ + val wifiOnly: Boolean = true, + /** Whether the current network may cost money. Unknown counts as metered. */ + val onMeteredNetwork: Boolean = true, + /** Downloads the user already sent over mobile data. */ + val meteredOverrides: Set = emptySet(), + ) { + /** True when Wi-Fi only is what holds [download] back right now. */ + fun waitingForWifi(download: DownloadItem): Boolean = isWaitingForWifi( + status = download.status, + wifiOnly = wifiOnly, + metered = onMeteredNetwork, + overridden = download.id in meteredOverrides, + ) + } private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -140,7 +155,24 @@ class DownloadsViewModel @Inject constructor( // are unavailable in LOCAL mode. viewModelScope.launch { settingsManager.settings.collect { settings -> - _uiState.update { it.copy(isLocalMode = settings.appMode == AppMode.LOCAL) } + _uiState.update { + it.copy( + isLocalMode = settings.appMode == AppMode.LOCAL, + wifiOnly = settings.downloadOnWifiOnly, + ) + } + } + } + + // What decides whether a download is waiting for Wi-Fi rather than stuck. + viewModelScope.launch { + connectivityMonitor.isMetered.collect { metered -> + _uiState.update { it.copy(onMeteredNetwork = metered) } + } + } + viewModelScope.launch { + downloadManager.meteredOverrides.collect { ids -> + _uiState.update { it.copy(meteredOverrides = ids) } } } } @@ -160,6 +192,13 @@ class DownloadsViewModel @Inject constructor( } } + /** Start a download waiting for Wi-Fi over mobile data, without changing the setting. */ + fun startOnMobileData(downloadId: String) { + viewModelScope.launch { + downloadManager.startOnMobileData(downloadId) + } + } + fun dismissNotice() { _uiState.update { it.copy(notice = null) } } diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt index c82afae..bbeecb7 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt @@ -52,6 +52,7 @@ import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.entitlement.FreeTier import com.ninelivesaudio.app.domain.model.Library import com.ninelivesaudio.app.domain.model.ThemeMode +import com.ninelivesaudio.app.service.download.WifiOnlyCopy import com.ninelivesaudio.app.ui.components.ArchiveScreenHeader import com.ninelivesaudio.app.ui.components.GatedControl import com.ninelivesaudio.app.ui.components.LabeledSwitchRow @@ -686,6 +687,15 @@ fun SettingsScreen( // ═════════════════════════════════════════════════════════════ if (uiState.appMode == AppMode.AUDIOBOOKSHELF) { SettingsGroup(title = "Data") { + ArchivePreferenceRow( + title = WifiOnlyCopy.SETTING_TITLE, + subtitle = WifiOnlyCopy.SETTING_SUBTITLE, + checked = uiState.downloadOnWifiOnly, + onCheckedChange = viewModel::setDownloadOnWifiOnly, + ) + + HorizontalDivider(color = NineLivesTheme.colors.archiveVoidElevated, thickness = 1.dp) + Button( onClick = viewModel::syncNow, modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt index fc05c69..cbbec90 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt @@ -29,6 +29,7 @@ import com.ninelivesaudio.app.domain.model.Library import com.ninelivesaudio.app.domain.model.SyncResult import com.ninelivesaudio.app.domain.model.ThemeMode import com.ninelivesaudio.app.service.ConnectivityMonitor +import com.ninelivesaudio.app.service.DownloadManager import com.ninelivesaudio.app.service.ConnectivityMonitor.ConnectionStatus import com.ninelivesaudio.app.service.PlaybackManager import com.ninelivesaudio.app.service.SettingsManager @@ -188,6 +189,7 @@ class SettingsViewModel @Inject constructor( private val settingsManager: SettingsManager, private val apiService: ApiService, private val connectivityMonitor: ConnectivityMonitor, + private val downloadManager: DownloadManager, private val audioBookDao: AudioBookDao, private val libraryDao: LibraryDao, private val libraryRepository: LibraryRepository, @@ -272,6 +274,9 @@ class SettingsViewModel @Inject constructor( val sleepTimerRewindSeconds: Int = 15, val includeArchivedInStats: Boolean = true, + // Downloads + val downloadOnWifiOnly: Boolean = true, + // Feedback Report val reportType: ReportType = ReportType.BUG, val includeLogsInReport: Boolean = false, @@ -425,6 +430,7 @@ class SettingsViewModel @Inject constructor( sleepTimerShakeResetEnabled = settings.sleepTimerShakeResetEnabled, sleepTimerRewindSeconds = settings.sleepTimerRewindSeconds, includeArchivedInStats = settings.includeArchivedInStats, + downloadOnWifiOnly = settings.downloadOnWifiOnly, themeMode = settings.themeMode, ) } @@ -849,6 +855,19 @@ class SettingsViewModel @Inject constructor( } } + /** + * Saves the setting, then applies it to downloads already queued: turning + * it off starts a queue that was waiting for Wi-Fi, turning it on stops a + * book downloading over mobile data unless the user sent it that way. + */ + fun setDownloadOnWifiOnly(enabled: Boolean) { + _uiState.update { it.copy(downloadOnWifiOnly = enabled) } + viewModelScope.launch { + settingsManager.updateSettings { it.copy(downloadOnWifiOnly = enabled) } + downloadManager.onWifiOnlyChanged() + } + } + fun toggleIncludeArchivedInStats() { val enabled = !_uiState.value.includeArchivedInStats _uiState.update { it.copy(includeArchivedInStats = enabled) } From 4fa06f353aa0077fc16f10861f80c20058b5b3ce Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 13:01:32 -0400 Subject: [PATCH 12/23] Search finds books whatever their accents or capitals (#61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A search for "Emile" now finds "Émile", "dune" finds "DUNE", and "nesbo" finds "Nesbø", in the Library and in Android Auto. Each book row keeps a folded copy of its title, author, series and narrator in a new SearchText column, and search compares a folded query against it. "%" and "_" still match themselves. The database moves to version 10. The upgrade fills the column for every book already on the phone, a page at a time, so an upgraded library searches right away with no resync. Every later write fills it too. The letter rail files titles with the same folding, so "Øystein" sits under O. Android Auto search used to read title and author only. It now reads the same column as the Library, so series and narrator match there too. Co-Authored-By: Claude Opus 5.5 --- app/build.gradle.kts | 7 + .../10.json | 586 ++++++++++++++++++ .../local/MigrationUpgradeInstrumentedTest.kt | 125 ++++ .../local/ShelfProjectionInstrumentedTest.kt | 27 + .../AutoBrowseQueryInstrumentedTest.kt | 18 + .../app/data/local/AppDatabase.kt | 2 +- .../app/data/local/SearchText.kt | 28 + .../ninelivesaudio/app/data/local/SqlLike.kt | 9 + .../app/data/local/dao/AudioBookDao.kt | 9 +- .../app/data/local/entity/AudioBookEntity.kt | 14 +- .../app/data/local/migration/Migrations.kt | 52 ++ .../data/repository/AudioBookRepository.kt | 18 +- .../app/domain/util/SearchFold.kt | 37 ++ .../app/service/AutoBookItem.kt | 9 +- .../app/ui/library/LibraryLetterIndex.kt | 17 +- .../app/data/local/SearchTextTest.kt | 51 ++ .../app/data/local/SqlLikeTest.kt | 18 +- .../data/local/migration/MigrationsTest.kt | 151 ++++- .../app/domain/util/SearchFoldTest.kt | 44 ++ .../app/service/AutoSearchTest.kt | 6 + .../app/ui/library/LibraryLetterIndexTest.kt | 7 + gradle/libs.versions.toml | 1 + 22 files changed, 1199 insertions(+), 37 deletions(-) create mode 100644 app/schemas/com.ninelivesaudio.app.data.local.AppDatabase/10.json create mode 100644 app/src/androidTest/java/com/ninelivesaudio/app/data/local/MigrationUpgradeInstrumentedTest.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/data/local/SearchText.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/domain/util/SearchFold.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/data/local/SearchTextTest.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/domain/util/SearchFoldTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f889ddf..dd4f988 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,6 +89,12 @@ android { compose = true buildConfig = true } + + // The exported Room schemas, so MigrationTestHelper can build a database + // at any old version in instrumented migration tests. + sourceSets { + getByName("androidTest").assets.srcDir("$projectDir/schemas") + } } ksp { @@ -282,6 +288,7 @@ dependencies { testImplementation(libs.androidx.work.testing) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.room.testing) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) diff --git a/app/schemas/com.ninelivesaudio.app.data.local.AppDatabase/10.json b/app/schemas/com.ninelivesaudio.app.data.local.AppDatabase/10.json new file mode 100644 index 0000000..02b76f1 --- /dev/null +++ b/app/schemas/com.ninelivesaudio.app.data.local.AppDatabase/10.json @@ -0,0 +1,586 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "3b58eaef56fce5fd3a132e1a72189c37", + "entities": [ + { + "tableName": "Libraries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`Id` TEXT NOT NULL, `Name` TEXT NOT NULL, `IsLocal` INTEGER NOT NULL DEFAULT 0, `FolderUri` TEXT, `DisplayOrder` INTEGER NOT NULL DEFAULT 0, `Icon` TEXT NOT NULL DEFAULT 'audiobook', `MediaType` TEXT NOT NULL DEFAULT 'book', `FoldersJson` TEXT, PRIMARY KEY(`Id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "Id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "Name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "IsLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "folderUri", + "columnName": "FolderUri", + "affinity": "TEXT" + }, + { + "fieldPath": "displayOrder", + "columnName": "DisplayOrder", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "icon", + "columnName": "Icon", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'audiobook'" + }, + { + "fieldPath": "mediaType", + "columnName": "MediaType", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'book'" + }, + { + "fieldPath": "foldersJson", + "columnName": "FoldersJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "Id" + ] + } + }, + { + "tableName": "AudioBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`Id` TEXT NOT NULL, `LibraryId` TEXT, `IsLocal` INTEGER NOT NULL DEFAULT 0, `Title` TEXT NOT NULL, `Author` TEXT, `Narrator` TEXT, `Description` TEXT, `CoverPath` TEXT, `DurationSeconds` REAL NOT NULL DEFAULT 0, `AddedAt` TEXT, `AudioFilesJson` TEXT, `CurrentTimeSeconds` REAL NOT NULL DEFAULT 0, `Progress` REAL NOT NULL DEFAULT 0, `IsFinished` INTEGER NOT NULL DEFAULT 0, `IsDownloaded` INTEGER NOT NULL DEFAULT 0, `LocalPath` TEXT, `LocalCoverPath` TEXT, `ArchivedAt` INTEGER, `SeriesName` TEXT, `SeriesSequence` TEXT, `GenresJson` TEXT, `TagsJson` TEXT, `ChaptersJson` TEXT, `SearchText` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`Id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "Id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "libraryId", + "columnName": "LibraryId", + "affinity": "TEXT" + }, + { + "fieldPath": "isLocal", + "columnName": "IsLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "Title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "Author", + "affinity": "TEXT" + }, + { + "fieldPath": "narrator", + "columnName": "Narrator", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "Description", + "affinity": "TEXT" + }, + { + "fieldPath": "coverPath", + "columnName": "CoverPath", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "DurationSeconds", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "addedAt", + "columnName": "AddedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "audioFilesJson", + "columnName": "AudioFilesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "currentTimeSeconds", + "columnName": "CurrentTimeSeconds", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "progress", + "columnName": "Progress", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isFinished", + "columnName": "IsFinished", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDownloaded", + "columnName": "IsDownloaded", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "localPath", + "columnName": "LocalPath", + "affinity": "TEXT" + }, + { + "fieldPath": "localCoverPath", + "columnName": "LocalCoverPath", + "affinity": "TEXT" + }, + { + "fieldPath": "archivedAt", + "columnName": "ArchivedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "seriesName", + "columnName": "SeriesName", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesSequence", + "columnName": "SeriesSequence", + "affinity": "TEXT" + }, + { + "fieldPath": "genresJson", + "columnName": "GenresJson", + "affinity": "TEXT" + }, + { + "fieldPath": "tagsJson", + "columnName": "TagsJson", + "affinity": "TEXT" + }, + { + "fieldPath": "chaptersJson", + "columnName": "ChaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "searchText", + "columnName": "SearchText", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "Id" + ] + }, + "indices": [ + { + "name": "idx_audiobooks_library", + "unique": false, + "columnNames": [ + "LibraryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_audiobooks_library` ON `${TABLE_NAME}` (`LibraryId`)" + } + ] + }, + { + "tableName": "DownloadItems", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`Id` TEXT NOT NULL, `AudioBookId` TEXT NOT NULL, `Title` TEXT NOT NULL, `Status` INTEGER NOT NULL DEFAULT 0, `TotalBytes` INTEGER NOT NULL DEFAULT 0, `DownloadedBytes` INTEGER NOT NULL DEFAULT 0, `StartedAt` TEXT, `CompletedAt` TEXT, `ErrorMessage` TEXT, `FilesToDownloadJson` TEXT, PRIMARY KEY(`Id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "Id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "audioBookId", + "columnName": "AudioBookId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "Title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "Status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "totalBytes", + "columnName": "TotalBytes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "downloadedBytes", + "columnName": "DownloadedBytes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "startedAt", + "columnName": "StartedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "completedAt", + "columnName": "CompletedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "errorMessage", + "columnName": "ErrorMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "filesToDownloadJson", + "columnName": "FilesToDownloadJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "Id" + ] + }, + "indices": [ + { + "name": "idx_downloads_audiobook", + "unique": false, + "columnNames": [ + "AudioBookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_downloads_audiobook` ON `${TABLE_NAME}` (`AudioBookId`)" + } + ] + }, + { + "tableName": "PlaybackProgress", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`AudioBookId` TEXT NOT NULL, `PositionSeconds` REAL NOT NULL DEFAULT 0, `IsFinished` INTEGER NOT NULL DEFAULT 0, `UpdatedAt` TEXT, PRIMARY KEY(`AudioBookId`))", + "fields": [ + { + "fieldPath": "audioBookId", + "columnName": "AudioBookId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "positionSeconds", + "columnName": "PositionSeconds", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isFinished", + "columnName": "IsFinished", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "updatedAt", + "columnName": "UpdatedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "AudioBookId" + ] + }, + "indices": [ + { + "name": "idx_playback_progress_updated", + "unique": false, + "columnNames": [ + "UpdatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_playback_progress_updated` ON `${TABLE_NAME}` (`UpdatedAt`)" + } + ] + }, + { + "tableName": "PendingProgressUpdates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`Id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ItemId` TEXT NOT NULL, `CurrentTime` REAL NOT NULL DEFAULT 0, `IsFinished` INTEGER NOT NULL DEFAULT 0, `Duration` REAL NOT NULL DEFAULT 0, `IsAtomic` INTEGER NOT NULL DEFAULT 0, `Timestamp` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "Id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "ItemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentTime", + "columnName": "CurrentTime", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isFinished", + "columnName": "IsFinished", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "duration", + "columnName": "Duration", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isAtomic", + "columnName": "IsAtomic", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timestamp", + "columnName": "Timestamp", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "Id" + ] + }, + "indices": [ + { + "name": "idx_pending_item", + "unique": false, + "columnNames": [ + "ItemId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_pending_item` ON `${TABLE_NAME}` (`ItemId`)" + } + ] + }, + { + "tableName": "LocalListeningSessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`Id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `AudioBookId` TEXT NOT NULL, `LibraryId` TEXT NOT NULL, `StartedAt` INTEGER NOT NULL, `UpdatedAt` INTEGER NOT NULL, `TimeListening` REAL NOT NULL DEFAULT 0, `CurrentTime` REAL NOT NULL DEFAULT 0, `DisplayTitle` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "Id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "audioBookId", + "columnName": "AudioBookId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "libraryId", + "columnName": "LibraryId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "StartedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "UpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeListening", + "columnName": "TimeListening", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "currentTime", + "columnName": "CurrentTime", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "displayTitle", + "columnName": "DisplayTitle", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "Id" + ] + }, + "indices": [ + { + "name": "idx_local_session_book", + "unique": false, + "columnNames": [ + "AudioBookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_local_session_book` ON `${TABLE_NAME}` (`AudioBookId`)" + }, + { + "name": "idx_local_session_library", + "unique": false, + "columnNames": [ + "LibraryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_local_session_library` ON `${TABLE_NAME}` (`LibraryId`)" + }, + { + "name": "idx_local_session_started", + "unique": false, + "columnNames": [ + "StartedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_local_session_started` ON `${TABLE_NAME}` (`StartedAt`)" + } + ] + }, + { + "tableName": "LocalBookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`Id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `AudioBookId` TEXT NOT NULL, `Title` TEXT NOT NULL, `Time` REAL NOT NULL, `CreatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "Id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "audioBookId", + "columnName": "AudioBookId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "Title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "Time", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "CreatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "Id" + ] + }, + "indices": [ + { + "name": "idx_local_bookmark_book", + "unique": false, + "columnNames": [ + "AudioBookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_local_bookmark_book` ON `${TABLE_NAME}` (`AudioBookId`)" + }, + { + "name": "idx_local_bookmark_book_time", + "unique": true, + "columnNames": [ + "AudioBookId", + "Time" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `idx_local_bookmark_book_time` ON `${TABLE_NAME}` (`AudioBookId`, `Time`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3b58eaef56fce5fd3a132e1a72189c37')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/data/local/MigrationUpgradeInstrumentedTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/data/local/MigrationUpgradeInstrumentedTest.kt new file mode 100644 index 0000000..0fe9293 --- /dev/null +++ b/app/src/androidTest/java/com/ninelivesaudio/app/data/local/MigrationUpgradeInstrumentedTest.kt @@ -0,0 +1,125 @@ +package com.ninelivesaudio.app.data.local + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.ninelivesaudio.app.data.local.migration.ALL_MIGRATIONS +import com.ninelivesaudio.app.data.local.migration.MIGRATION_9_10 +import com.ninelivesaudio.app.data.local.migration.SEARCH_BACKFILL_PAGE_SIZE +import com.ninelivesaudio.app.data.repository.buildLibrarySql +import com.ninelivesaudio.app.data.repository.buildLibrarySqlArgs +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * A real v9 database, built from the exported 9.json, upgraded to v10. Room + * validates the result against the v10 entities, so a column that differs + * from what the entity declares fails here instead of on a user's phone. + */ +@RunWith(AndroidJUnit4::class) +class MigrationUpgradeInstrumentedTest { + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java, + ) + + @Test + fun nineToTenFillsTheSearchColumnForEveryBookAndKeepsTheData() { + helper.createDatabase(TEST_DB, 9).use { db -> + db.insert("AudioBooks", SQLiteDatabase.CONFLICT_FAIL, v9Book("emile", "Émile", "Jean-Jacques ROUSSEAU", "Œuvres", "Zoë")) + db.insert("AudioBooks", SQLiteDatabase.CONFLICT_FAIL, v9Book("dune", "DUNE", null, null, null)) + // Past one backfill page, so the paging is exercised on real SQLite. + for (n in 0 until SEARCH_BACKFILL_PAGE_SIZE + 5) { + db.insert("AudioBooks", SQLiteDatabase.CONFLICT_FAIL, v9Book("bulk-$n", "Bulk $n", "Author $n", null, null)) + } + db.insert( + "PlaybackProgress", + SQLiteDatabase.CONFLICT_FAIL, + ContentValues().apply { + put("AudioBookId", "emile") + put("PositionSeconds", 42.5) + put("IsFinished", 0) + put("UpdatedAt", "2026-09-20T08:30:00Z") + }, + ) + } + + val db = helper.runMigrationsAndValidate(TEST_DB, 10, true, MIGRATION_9_10) + + db.query("SELECT Id, Title, Author, SeriesName, Narrator, SearchText, Progress, GenresJson FROM AudioBooks").use { rows -> + assertEquals(SEARCH_BACKFILL_PAGE_SIZE + 7, rows.count) + while (rows.moveToNext()) { + val expected = bookSearchText(rows.getString(1), rows.getString(2), rows.getString(3), rows.getString(4)) + assertEquals(rows.getString(0), expected, rows.getString(5)) + assertEquals(0.25, rows.getDouble(6), 0.0) + assertEquals("[\"Horror\"]", rows.getString(7)) + } + } + db.query("SELECT Title, SearchText FROM AudioBooks WHERE Id = 'emile'").use { rows -> + rows.moveToFirst() + assertEquals("Émile", rows.getString(0)) + assertEquals("emile\u001Fjean-jacques rousseau\u001Foeuvres\u001Fzoe", rows.getString(1)) + } + db.query("SELECT PositionSeconds, UpdatedAt FROM PlaybackProgress WHERE AudioBookId = 'emile'").use { rows -> + rows.moveToFirst() + assertEquals(42.5, rows.getDouble(0), 0.0) + assertEquals("2026-09-20T08:30:00Z", rows.getString(1)) + } + } + + @Test + fun anUpgradedLibraryFindsAccentedAndUppercaseTitlesWithNoResync() = runBlocking { + helper.createDatabase(TEST_DB, 9).use { db -> + db.insert("AudioBooks", SQLiteDatabase.CONFLICT_FAIL, v9Book("emile", "Émile", "Rousseau", null, null)) + db.insert("AudioBooks", SQLiteDatabase.CONFLICT_FAIL, v9Book("dune", "DUNE", "Frank Herbert", null, null)) + db.insert("AudioBooks", SQLiteDatabase.CONFLICT_FAIL, v9Book("pct", "100% Pure", "Someone", null, null)) + } + + val database = Room.databaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + TEST_DB, + ).addMigrations(*ALL_MIGRATIONS).allowMainThreadQueries().build() + helper.closeWhenFinished(database) + + suspend fun shelfSearch(query: String) = database.audioBookDao().getFilteredBooks( + SimpleSQLiteQuery( + buildLibrarySql(tab = 0, hideFinished = false, downloadedOnly = false, hasSearch = true), + buildLibrarySqlArgs(LIBRARY, query), + ) + ).map { it.id }.toSet() + + assertEquals(setOf("emile"), shelfSearch("Emile")) + assertEquals(setOf("emile"), shelfSearch("émile")) + assertEquals(setOf("dune"), shelfSearch("dune")) + assertEquals(setOf("pct"), shelfSearch("%")) + } + + private fun v9Book(id: String, title: String, author: String?, seriesName: String?, narrator: String?) = + ContentValues().apply { + put("Id", id) + put("LibraryId", LIBRARY) + put("IsLocal", 0) + put("Title", title) + put("Author", author) + put("SeriesName", seriesName) + put("Narrator", narrator) + put("DurationSeconds", 120.0) + put("Progress", 0.25) + put("GenresJson", "[\"Horror\"]") + } + + private companion object { + const val TEST_DB = "migration-upgrade-test.db" + const val LIBRARY = "lib" + } +} diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/data/local/ShelfProjectionInstrumentedTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/data/local/ShelfProjectionInstrumentedTest.kt index 757b1ed..59da636 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/data/local/ShelfProjectionInstrumentedTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/data/local/ShelfProjectionInstrumentedTest.kt @@ -102,6 +102,33 @@ class ShelfProjectionInstrumentedTest { assertEquals(setOf("pct", "num"), shelfSearch("100")) } + @Test + fun searchIgnoresAccentsAndCaseInEveryField() = runBlocking { + val dao = database.audioBookDao() + dao.upsertAll( + listOf( + book("emile", "Émile"), + book("dune", "DUNE"), + book("by-author", "Other").copy(author = "Jo Nesbø"), + book("by-narrator", "Another").copy(narrator = "Zoë Wanamaker"), + book("plain", "Nothing Here"), + ) + ) + + suspend fun shelfSearch(query: String) = dao.getFilteredBooks( + SimpleSQLiteQuery( + buildLibrarySql(tab = 0, hideFinished = false, downloadedOnly = false, hasSearch = true), + buildLibrarySqlArgs(LIBRARY, query), + ) + ).map { it.id }.toSet() + + assertEquals(setOf("emile"), shelfSearch("Emile")) + assertEquals(setOf("emile"), shelfSearch("ÉMILE")) + assertEquals(setOf("dune"), shelfSearch("dune")) + assertEquals(setOf("by-author"), shelfSearch("nesbo")) + assertEquals(setOf("by-narrator"), shelfSearch("zoe")) + } + private fun book( id: String, title: String, diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt index e64d18c..1b216c6 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt @@ -105,6 +105,24 @@ class AutoBrowseQueryInstrumentedTest { assertEquals(1, dao.countAutoSearch(LIBRARY, 0, autoSearchPattern("wolf")!!, 1)) } + @Test + fun searchIgnoresAccentsAndCase() = runBlocking { + val dao = database.audioBookDao() + dao.upsertAll( + listOf( + book(id = "emile", title = "Émile"), + book(id = "dune", title = "DUNE"), + book(id = "other", title = "Other"), + ), + ) + + fun ids(rows: List) = rows.map { it.id } + + assertEquals(listOf("emile"), ids(dao.searchAutoPage(LIBRARY, 0, autoSearchPattern("emile")!!, 100, 0))) + assertEquals(listOf("dune"), ids(dao.searchAutoPage(LIBRARY, 0, autoSearchPattern("Dune")!!, 100, 0))) + assertEquals(1, dao.countAutoSearch(LIBRARY, 0, autoSearchPattern("EMILE")!!, 100)) + } + private suspend fun seed() { // Mixed case and distinct titles, so NOCASE and lowercase() agree on order. val titles = (0 until 40).map { index -> diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/AppDatabase.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/AppDatabase.kt index 29758fd..1607927 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/AppDatabase.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/AppDatabase.kt @@ -15,7 +15,7 @@ import com.ninelivesaudio.app.data.local.entity.* LocalListeningSessionEntity::class, LocalBookmarkEntity::class, ], - version = 9, + version = 10, exportSchema = true, ) abstract class AppDatabase : RoomDatabase() { diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/SearchText.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/SearchText.kt new file mode 100644 index 0000000..b8f6cf5 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/SearchText.kt @@ -0,0 +1,28 @@ +package com.ninelivesaudio.app.data.local + +import com.ninelivesaudio.app.domain.util.foldForSearch + +/** + * Keeps the searched fields apart in the search column. A query cannot type + * it, so "dune frank" never matches across a title and an author. + */ +private const val SEARCH_FIELD_SEPARATOR = '\u001F' + +/** + * The AudioBooks.SearchText value for a book: title, author, series and + * narrator, each folded by [foldForSearch]. The Library search and Android + * Auto search match a folded query against this one column, so accents and + * case never cause a miss (issue #61). Filled on every write by + * [com.ninelivesaudio.app.data.local.entity.AudioBookEntity], and for rows + * that predate it by the 9 to 10 migration. + */ +internal fun bookSearchText(title: String?, author: String?, seriesName: String?, narrator: String?): String = + buildString { + append(foldForSearch(title.orEmpty())) + append(SEARCH_FIELD_SEPARATOR) + append(foldForSearch(author.orEmpty())) + append(SEARCH_FIELD_SEPARATOR) + append(foldForSearch(seriesName.orEmpty())) + append(SEARCH_FIELD_SEPARATOR) + append(foldForSearch(narrator.orEmpty())) + } diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/SqlLike.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/SqlLike.kt index 1edeb29..3ed32ad 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/SqlLike.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/SqlLike.kt @@ -1,5 +1,7 @@ package com.ninelivesaudio.app.data.local +import com.ninelivesaudio.app.domain.util.foldForSearch + /** * User text as a LIKE literal, for a LIKE that declares ESCAPE '\'. Without * it a search for "%" matched every book (and loaded the whole shelf) and @@ -14,3 +16,10 @@ internal fun escapeLike(text: String): String = buildString(text.length + 4) { /** A LIKE pattern that finds [text] anywhere, for a LIKE that declares ESCAPE '\'. */ internal fun containsLikePattern(text: String): String = "%${escapeLike(text)}%" + +/** + * A LIKE pattern that finds a typed [query] anywhere in AudioBooks.SearchText, + * folded the way that column is (see [bookSearchText]), for a LIKE that + * declares ESCAPE '\'. + */ +internal fun searchTextLikePattern(query: String): String = containsLikePattern(foldForSearch(query)) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt index 462eeea..4cb8579 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt @@ -208,8 +208,9 @@ interface AudioBookDao { suspend fun getAutoDownloadedPage(libraryId: String, isLocal: Int, limit: Int, offset: Int): List /** - * One page of Android Auto search hits in one library and source: title or - * author contains [pattern], an escaped LIKE pattern (backslash escapes). + * One page of Android Auto search hits in one library and source: the + * folded search column (title, author, series, narrator) contains + * [pattern], a folded and escaped LIKE pattern (backslash escapes). */ @Query( """ @@ -217,7 +218,7 @@ interface AudioBookDao { CoverPath AS coverPath, LocalCoverPath AS localCoverPath, GenresJson AS genresJson FROM AudioBooks WHERE LibraryId = :libraryId AND IsLocal = :isLocal AND ArchivedAt IS NULL - AND (Title LIKE :pattern ESCAPE '\' OR Author LIKE :pattern ESCAPE '\') + AND SearchText LIKE :pattern ESCAPE '\' ORDER BY Title COLLATE NOCASE, Id LIMIT :limit OFFSET :offset """ @@ -230,7 +231,7 @@ interface AudioBookDao { SELECT COUNT(*) FROM ( SELECT 1 FROM AudioBooks WHERE LibraryId = :libraryId AND IsLocal = :isLocal AND ArchivedAt IS NULL - AND (Title LIKE :pattern ESCAPE '\' OR Author LIKE :pattern ESCAPE '\') + AND SearchText LIKE :pattern ESCAPE '\' LIMIT :cap ) """ diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/AudioBookEntity.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/AudioBookEntity.kt index c943481..3fdaea0 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/AudioBookEntity.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/AudioBookEntity.kt @@ -4,6 +4,7 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey +import com.ninelivesaudio.app.data.local.bookSearchText @Entity( tableName = "AudioBooks", @@ -79,4 +80,15 @@ data class AudioBookEntity( @ColumnInfo(name = "ChaptersJson") val chaptersJson: String? = null, -) +) { + /** + * Title, author, series and narrator folded for search (see + * [bookSearchText]). Worked out from those fields whenever a row is + * built, a copy included, so every write fills it and no caller can + * forget to. Room sets it on read only to hand back the stored value, so + * a change to how search folds text needs a migration that backfills + * every row again, the way 9 to 10 does. + */ + @ColumnInfo(name = "SearchText", defaultValue = "''") + var searchText: String = bookSearchText(title, author, seriesName, narrator) +} diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/migration/Migrations.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/migration/Migrations.kt index c4317cb..8f207d0 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/migration/Migrations.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/migration/Migrations.kt @@ -2,6 +2,7 @@ package com.ninelivesaudio.app.data.local.migration import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import com.ninelivesaudio.app.data.local.bookSearchText /** * Room migration definitions. @@ -106,6 +107,56 @@ val MIGRATION_8_9 = object : Migration(8, 9) { } } +val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + // Folded title, author, series and narrator, so search ignores + // accents and case (issue #61). Filled here for every book already + // cached, so an upgraded library searches right away with no resync. + db.execSQL("ALTER TABLE AudioBooks ADD COLUMN SearchText TEXT NOT NULL DEFAULT ''") + backfillSearchText(db) + } +} + +/** Rows read per backfill page, so a huge library never sits in memory whole. */ +internal const val SEARCH_BACKFILL_PAGE_SIZE = 500 + +/** + * Fills AudioBooks.SearchText for every row, a page at a time in rowid order. + * Each page is read in full and its cursor closed before any row is written, + * so the read never sees its own table change under it. SQLite has no accent + * folding of its own, so the values come from [bookSearchText], the same + * function every later write uses. Runs inside the migration's transaction. + */ +internal fun backfillSearchText(db: SupportSQLiteDatabase) { + val update = db.compileStatement("UPDATE AudioBooks SET SearchText = ? WHERE rowid = ?") + try { + var after = Long.MIN_VALUE + while (true) { + val page = ArrayList>(SEARCH_BACKFILL_PAGE_SIZE) + db.query( + "SELECT rowid, Id, Title, Author, SeriesName, Narrator FROM AudioBooks " + + "WHERE rowid > ? ORDER BY rowid LIMIT ?", + arrayOf(after, SEARCH_BACKFILL_PAGE_SIZE), + ).use { rows -> + while (rows.moveToNext()) { + page += rows.getLong(0) to + bookSearchText(rows.getString(2), rows.getString(3), rows.getString(4), rows.getString(5)) + } + } + for ((rowId, text) in page) { + update.bindString(1, text) + update.bindLong(2, rowId) + update.executeUpdateDelete() + update.clearBindings() + } + if (page.size < SEARCH_BACKFILL_PAGE_SIZE) break + after = page.last().first + } + } finally { + update.close() + } +} + /** * All migrations to register with Room, in order. * Add new migrations here as they are created. @@ -119,4 +170,5 @@ val ALL_MIGRATIONS: Array = arrayOf( MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, + MIGRATION_9_10, ) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/AudioBookRepository.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/AudioBookRepository.kt index 3e32753..36e1aeb 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/repository/AudioBookRepository.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/AudioBookRepository.kt @@ -4,7 +4,7 @@ import android.content.Context import androidx.room.withTransaction import androidx.sqlite.db.SimpleSQLiteQuery import com.ninelivesaudio.app.data.local.AppDatabase -import com.ninelivesaudio.app.data.local.containsLikePattern +import com.ninelivesaudio.app.data.local.searchTextLikePattern import com.ninelivesaudio.app.data.local.converter.toDomain import com.ninelivesaudio.app.data.local.converter.toEntity import com.ninelivesaudio.app.data.local.dao.AudioBookDao @@ -1062,11 +1062,12 @@ internal fun buildLibrarySql( append(" AND ab.IsDownloaded = 1") } - // Search. The bound patterns are escaped (see buildLibrarySqlArgs), so - // "%" and "_" typed in the search box match themselves. + // Search title, author, series and narrator through the folded search + // column, so accents and case never cause a miss. The bound pattern is + // folded and escaped (see buildLibrarySqlArgs), so "%" and "_" typed in + // the search box match themselves. if (hasSearch) { - append(" AND (ab.Title LIKE ? ESCAPE '\\' OR ab.Author LIKE ? ESCAPE '\\'") - append(" OR ab.SeriesName LIKE ? ESCAPE '\\' OR ab.Narrator LIKE ? ESCAPE '\\')") + append(" AND ab.SearchText LIKE ? ESCAPE '\\'") } // No ORDER BY: the Library sorts in Kotlin (sortBooks), so sorting here @@ -1074,11 +1075,10 @@ internal fun buildLibrarySql( } /** - * The arguments for [buildLibrarySql]: the library, then one escaped pattern - * per searched column when there is a search. + * The arguments for [buildLibrarySql]: the library, then the folded, escaped + * search pattern when there is a search. */ internal fun buildLibrarySqlArgs(libraryId: String, searchQuery: String): Array { if (searchQuery.isBlank()) return arrayOf(libraryId) - val pattern = containsLikePattern(searchQuery) - return arrayOf(libraryId, pattern, pattern, pattern, pattern) + return arrayOf(libraryId, searchTextLikePattern(searchQuery)) } diff --git a/app/src/main/java/com/ninelivesaudio/app/domain/util/SearchFold.kt b/app/src/main/java/com/ninelivesaudio/app/domain/util/SearchFold.kt new file mode 100644 index 0000000..c815418 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/domain/util/SearchFold.kt @@ -0,0 +1,37 @@ +package com.ninelivesaudio.app.domain.util + +import java.text.Normalizer + +/** + * [text] the way search compares it: lowercase with accents dropped, so + * "Émile" and "emile", or "DUNE" and "dune", fold to the same thing. SQLite + * LIKE folds case for ASCII only and never drops accents (issue #61), so the + * stored search column and the typed query are both folded by this one + * function. The Library's letter rail files titles with it too. + * + * Letters with no accent to drop (ø, ł, ß, æ, and friends) get their plain + * spelling, so a search for "nesbo" finds "Nesbø". Everything else, digits, + * punctuation, spaces and other alphabets, is kept as it is. + */ +fun foldForSearch(text: String): String { + if (text.all { it in ' '..'@' || it in '['..'~' }) return text + val decomposed = Normalizer.normalize(text, Normalizer.Form.NFD) + return buildString(decomposed.length) { + for (c in decomposed) { + if (Character.getType(c) == Character.NON_SPACING_MARK.toInt()) continue + when (c) { + 'Ø', 'ø' -> append('o') + 'Ł', 'ł' -> append('l') + 'Đ', 'đ' -> append('d') + 'Ħ', 'ħ' -> append('h') + 'ı' -> append('i') + 'ß', 'ẞ' -> append("ss") + 'Æ', 'æ' -> append("ae") + 'Œ', 'œ' -> append("oe") + 'Þ', 'þ' -> append("th") + 'ς' -> append('σ') + else -> append(c.lowercaseChar()) + } + } + } +} diff --git a/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt b/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt index 4c52f8d..cc0d04a 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt @@ -1,6 +1,6 @@ package com.ninelivesaudio.app.service -import com.ninelivesaudio.app.data.local.containsLikePattern +import com.ninelivesaudio.app.data.local.searchTextLikePattern import com.ninelivesaudio.app.data.local.entity.AutoBrowseRow import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.AppSettings @@ -86,12 +86,13 @@ internal suspend fun autoBrowsePage( internal const val AUTO_SEARCH_RESULT_CAP = 100 /** - * [query] as a LIKE "contains" pattern escaped by [containsLikePattern], so - * `%`, `_` and `\` match themselves. Null for a blank query, which searches + * [query] as a LIKE "contains" pattern for the folded search column, made by + * [searchTextLikePattern], so accents and case never cause a miss and `%`, + * `_` and `\` match themselves. Null for a blank query, which searches * nothing. */ internal fun autoSearchPattern(query: String): String? = - query.trim().takeIf { it.isNotEmpty() }?.let(::containsLikePattern) + query.trim().takeIf { it.isNotEmpty() }?.let(::searchTextLikePattern) /** * One page of Auto search hits in the active library and source, never past diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndex.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndex.kt index 7f6a6ac..07492f4 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndex.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndex.kt @@ -1,7 +1,7 @@ package com.ninelivesaudio.app.ui.library import com.ninelivesaudio.app.domain.model.AudioBook -import java.text.Normalizer +import com.ninelivesaudio.app.domain.util.foldForSearch import kotlin.math.ceil /** Where digits, symbols and anything outside A to Z are filed. */ @@ -61,9 +61,9 @@ private val ASCII_LABELS = Array(26) { ('A' + it).toString() } /** * The letter a title or name is filed under, matching how the shelf sorts it - * (on its first character, so "The Hobbit" is under T). Accents are dropped, - * so "Émile" is under E. Digits, symbols, other alphabets and blanks share - * the number sign. + * (on its first character, so "The Hobbit" is under T). Accents are dropped + * by [foldForSearch], so "Émile" is under E and "Øystein" under O. Digits, + * symbols, other alphabets and blanks share the number sign. */ internal fun letterLabelFor(text: String): String { var i = 0 @@ -74,12 +74,9 @@ internal fun letterLabelFor(text: String): String { c in 'A'..'Z' -> ASCII_LABELS[c - 'A'] c in 'a'..'z' -> ASCII_LABELS[c - 'a'] c.isLetter() -> { - val base = Normalizer.normalize(c.toString(), Normalizer.Form.NFD)[0] - when (base) { - in 'A'..'Z' -> ASCII_LABELS[base - 'A'] - in 'a'..'z' -> ASCII_LABELS[base - 'a'] - else -> NUMBER_SIGN_LABEL - } + // Filed the way search folds it, so the rail and search agree. + val base = foldForSearch(c.toString()).firstOrNull() + if (base != null && base in 'a'..'z') ASCII_LABELS[base - 'a'] else NUMBER_SIGN_LABEL } else -> NUMBER_SIGN_LABEL } diff --git a/app/src/test/java/com/ninelivesaudio/app/data/local/SearchTextTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/local/SearchTextTest.kt new file mode 100644 index 0000000..b83974b --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/local/SearchTextTest.kt @@ -0,0 +1,51 @@ +package com.ninelivesaudio.app.data.local + +import com.ninelivesaudio.app.data.local.converter.toEntity +import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.domain.model.AudioBook +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** Every book row carries one folded copy of the fields the search reads. */ +class SearchTextTest { + + @Test + fun `the searched fields are folded and kept apart`() { + assertEquals( + "l'etranger\u001Falbert camus\u001Fclassiques\u001Fjerome", + bookSearchText("L'Étranger", "Albert Camus", "Classiques", "Jérôme"), + ) + assertEquals("dune\u001F\u001F\u001F", bookSearchText("DUNE", null, null, null)) + } + + @Test + fun `a search cannot match across two fields`() { + // "dune frank" would match "Dune Frank Herbert" if the fields ran together. + val text = bookSearchText("Dune", "Frank Herbert", null, null) + assertFalse(text.contains("dune frank")) + assertFalse(text.contains("dune ")) + } + + @Test + fun `a row is filled on write`() { + val entity = AudioBook( + id = "b1", + title = "Émile", + author = "Jean-Jacques ROUSSEAU", + narrator = "Zoë", + seriesName = "Œuvres", + ).toEntity() + + assertEquals(bookSearchText("Émile", "Jean-Jacques ROUSSEAU", "Œuvres", "Zoë"), entity.searchText) + } + + @Test + fun `a copied row follows its new fields`() { + val entity = AudioBookEntity(id = "b1", title = "Dune", author = "Author") + + val renamed = entity.copy(title = "Élan", author = "Wolfgang") + + assertEquals(bookSearchText("Élan", "Wolfgang", null, null), renamed.searchText) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/data/local/SqlLikeTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/local/SqlLikeTest.kt index 152b7f8..bd7c173 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/local/SqlLikeTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/local/SqlLikeTest.kt @@ -19,21 +19,27 @@ class SqlLikeTest { } @Test - fun `every searched column declares the escape character`() { + fun `the search reads the folded search column and declares the escape character`() { val sql = buildLibrarySql(tab = 0, hideFinished = false, downloadedOnly = false, hasSearch = true) val likes = Regex("LIKE \\?").findAll(sql).count() - val escaped = Regex("LIKE \\? ESCAPE '\\\\'").findAll(sql).count() - assertEquals(4, likes) + val escaped = Regex("ab\\.SearchText LIKE \\? ESCAPE '\\\\'").findAll(sql).count() + assertEquals(1, likes) assertEquals(likes, escaped) } @Test - fun `the search binds escaped patterns, one per column`() { - val pattern = "%50\\% off\\_sale%" + fun `the search binds one escaped pattern`() { assertArrayEquals( - arrayOf("lib", pattern, pattern, pattern, pattern), + arrayOf("lib", "%50\\% off\\_sale%"), buildLibrarySqlArgs("lib", "50% off_sale"), ) assertArrayEquals(arrayOf("lib"), buildLibrarySqlArgs("lib", " ")) } + + @Test + fun `the search pattern is folded the way the column is`() { + assertArrayEquals(arrayOf("lib", "%emile%"), buildLibrarySqlArgs("lib", "Émile")) + assertArrayEquals(arrayOf("lib", "%dune%"), buildLibrarySqlArgs("lib", "DUNE")) + assertArrayEquals(arrayOf("lib", "%nesbo\\_%"), buildLibrarySqlArgs("lib", "Nesbø_")) + } } diff --git a/app/src/test/java/com/ninelivesaudio/app/data/local/migration/MigrationsTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/local/migration/MigrationsTest.kt index c3141a6..21b193d 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/local/migration/MigrationsTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/local/migration/MigrationsTest.kt @@ -1,6 +1,9 @@ package com.ninelivesaudio.app.data.local.migration +import android.database.Cursor import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteStatement +import com.ninelivesaudio.app.data.local.bookSearchText import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -10,7 +13,8 @@ import java.lang.reflect.Proxy /** * Room refuses to open an upgraded database when a migration is missing or * builds a different schema than the entities describe. Neither shows up until - * a real install upgrades, so these pin the chain and the 8 to 9 SQL here. + * a real install upgrades, so these pin the chain and the SQL here. The + * instrumented MigrationUpgradeInstrumentedTest runs 9 to 10 on real SQLite. */ class MigrationsTest { @@ -23,7 +27,7 @@ class MigrationsTest { .mapNotNull { it.name.removeSuffix(".json").toIntOrNull() } .maxOrNull() - assertEquals((1 until 9).map { it to it + 1 }, steps) + assertEquals((1 until 10).map { it to it + 1 }, steps) assertEquals(newestSchema, steps.last().second) } @@ -41,6 +45,149 @@ class MigrationsTest { assertEquals(listOf(expected), executed) } + @Test + fun `9 to 10 adds the search column the way the exported v10 schema describes it`() { + val executed = mutableListOf() + MIGRATION_9_10.migrate(backfillDatabase(executed, rows = emptyList(), updates = mutableListOf())) + + val field = Regex("\\{[^{}]*\"columnName\":\\s*\"SearchText\"[^{}]*\\}") + .find(File(schemaDir, "10.json").readText()) + ?.value + assertTrue("SearchText missing from the exported v10 schema", field != null) + assertTrue(field, Regex("\"affinity\":\\s*\"TEXT\"").containsMatchIn(field!!)) + assertTrue(field, Regex("\"notNull\":\\s*true").containsMatchIn(field)) + assertTrue(field, Regex("\"defaultValue\":\\s*\"''\"").containsMatchIn(field)) + assertEquals(listOf("ALTER TABLE AudioBooks ADD COLUMN SearchText TEXT NOT NULL DEFAULT ''"), executed) + } + + @Test + fun `9 to 10 fills the search column for every existing book`() { + // More rows than one backfill page, so the paging is exercised too. + val rows = (1..(SEARCH_BACKFILL_PAGE_SIZE + 3)).map { n -> + BookRow( + rowId = n * 2L, + id = "book-$n", + title = if (n == 1) "Émile" else "Title $n", + author = if (n == 2) null else "AUTHOR $n", + seriesName = if (n == 3) "Œuvres" else null, + narrator = if (n == 4) "Zoë" else null, + ) + } + val updates = mutableListOf>() + + MIGRATION_9_10.migrate(backfillDatabase(mutableListOf(), rows, updates)) + + assertEquals(rows.map { it.id }, updates.map { it.first }) + rows.zip(updates).forEach { (row, update) -> + assertEquals(bookSearchText(row.title, row.author, row.seriesName, row.narrator), update.second) + } + assertEquals("emile", updates.first().second.substringBefore('\u001F')) + } + + private data class BookRow( + val rowId: Long, + val id: String, + val title: String, + val author: String?, + val seriesName: String?, + val narrator: String?, + ) + + /** + * A database holding [rows] in the AudioBooks table: it records execSQL, + * answers the backfill's paged read, and records each UPDATE as + * (Id, SearchText) in [updates]. + */ + private fun backfillDatabase( + executed: MutableList, + rows: List, + updates: MutableList>, + ): SupportSQLiteDatabase = Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + when (method.name) { + "execSQL" -> { + executed += args!![0] as String + null + } + "query" -> { + val sql = args!![0] as String + assertTrue(sql, sql.startsWith("SELECT rowid, Id, Title, Author, SeriesName, Narrator FROM AudioBooks")) + val bind = args[1] as Array<*> + val after = (bind[0] as Number).toLong() + val limit = (bind[1] as Number).toInt() + cursorOf(rows.filter { it.rowId > after }.sortedBy { it.rowId }.take(limit)) + } + "compileStatement" -> { + assertEquals("UPDATE AudioBooks SET SearchText = ? WHERE rowid = ?", args!![0]) + recordingUpdate(rows, updates) + } + "toString" -> "BackfillDatabase" + else -> error("unexpected call ${method.name}") + } + } as SupportSQLiteDatabase + + private fun cursorOf(page: List): Cursor { + var position = -1 + return Proxy.newProxyInstance(Cursor::class.java.classLoader, arrayOf(Cursor::class.java)) { _, method, args -> + when (method.name) { + "moveToNext" -> ++position < page.size + "getLong" -> { + assertEquals(0, args!![0]) + page[position].rowId + } + "getString" -> page[position].let { row -> + when (args!![0] as Int) { + 1 -> row.id + 2 -> row.title + 3 -> row.author + 4 -> row.seriesName + 5 -> row.narrator + else -> error("no column ${args[0]}") + } + } + "close" -> null + "toString" -> "PageCursor" + else -> error("unexpected cursor call ${method.name}") + } + } as Cursor + } + + private fun recordingUpdate(rows: List, updates: MutableList>): SupportSQLiteStatement { + var text: String? = null + var rowId: Long? = null + return Proxy.newProxyInstance( + SupportSQLiteStatement::class.java.classLoader, + arrayOf(SupportSQLiteStatement::class.java), + ) { _, method, args -> + when (method.name) { + "bindString" -> { + assertEquals(1, args!![0]) + text = args[1] as String + null + } + "bindLong" -> { + assertEquals(2, args!![0]) + rowId = args[1] as Long + null + } + "executeUpdateDelete" -> { + updates += rows.single { it.rowId == rowId }.id to text!! + 1 + } + "clearBindings" -> { + text = null + rowId = null + null + } + "close" -> null + "toString" -> "RecordingUpdate" + else -> error("unexpected statement call ${method.name}") + } + } as SupportSQLiteStatement + } + /** A database that records execSQL and fails on anything else. */ private fun recordingDatabase(executed: MutableList): SupportSQLiteDatabase = Proxy.newProxyInstance( diff --git a/app/src/test/java/com/ninelivesaudio/app/domain/util/SearchFoldTest.kt b/app/src/test/java/com/ninelivesaudio/app/domain/util/SearchFoldTest.kt new file mode 100644 index 0000000..f84578d --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/domain/util/SearchFoldTest.kt @@ -0,0 +1,44 @@ +package com.ninelivesaudio.app.domain.util + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * SQLite LIKE folds case for ASCII only and never drops accents, so search + * compares folded text on both sides instead (issue #61). + */ +class SearchFoldTest { + + @Test + fun `case folds, ASCII and beyond`() { + assertEquals("dune", foldForSearch("DUNE")) + assertEquals("привет", foldForSearch("ПРИВЕТ")) + } + + @Test + fun `accents are dropped`() { + assertEquals("emile", foldForSearch("Émile")) + assertEquals("elan", foldForSearch("élan")) + assertEquals("osten", foldForSearch("Östen")) + assertEquals("nandu", foldForSearch("ñandú")) + // Already decomposed: E plus a combining acute. + assertEquals("emile", foldForSearch("Émile")) + } + + @Test + fun `Nordic and other letters with no decomposition fold to their plain spelling`() { + assertEquals("jo nesbo", foldForSearch("Jo Nesbø")) + assertEquals("lodz", foldForSearch("Łódź")) + assertEquals("strasse", foldForSearch("Straße")) + assertEquals("aesir", foldForSearch("Æsir")) + assertEquals("oeuvre", foldForSearch("Œuvre")) + } + + @Test + fun `everything else is left as typed`() { + assertEquals("100% pure_a\\b", foldForSearch("100% Pure_A\\b")) + assertEquals(" two spaces ", foldForSearch(" Two Spaces ")) + assertEquals("日本語", foldForSearch("日本語")) + assertEquals("", foldForSearch("")) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/AutoSearchTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/AutoSearchTest.kt index ceb2268..9484bc7 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/AutoSearchTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/AutoSearchTest.kt @@ -35,6 +35,12 @@ class AutoSearchTest { assertEquals("%c:\\\\x%", autoSearchPattern("c:\\x")) } + @Test + fun `the pattern is folded the way the search column is`() { + assertEquals("%emile%", autoSearchPattern("Émile")) + assertEquals("%dune%", autoSearchPattern("DUNE")) + } + @Test fun `a blank query searches nothing`() = runBlocking { assertNull(autoSearchPattern("")) diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndexTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndexTest.kt index 74137ba..092f8a7 100644 --- a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndexTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndexTest.kt @@ -55,6 +55,13 @@ class LibraryLetterIndexTest { assertEquals("N", letterLabelFor("ñandú")) } + @Test + fun `the rail files a letter the way search folds it`() { + assertEquals("O", letterLabelFor("Øystein")) + assertEquals("L", letterLabelFor("Łódź")) + assertEquals("A", letterLabelFor("Æsir")) + } + @Test fun `a letter outside the Latin alphabet falls under the number sign`() { assertEquals("#", letterLabelFor("日本語")) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bf08d6b..bf9d9f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,6 +64,7 @@ hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-com room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } # Retrofit + OkHttp (Network) retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } From e22640020eee601243c25734356cab51e4348221 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 13:03:32 -0400 Subject: [PATCH 13/23] A series in Series view lists its books in reading order (#63) Each series now reads by its sequence number whatever the shelf sort, so book 2 comes before book 10 and a book 1.5 sits between 1 and 2. A book with no sequence, or one that is not a plain number, goes last, by title. The series themselves are still ordered by the shelf sort, so Recently Added still lists the series with the newest book first. Books with no series keep the shelf order. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/library/LibraryViewModel.kt | 47 +++++- .../app/ui/library/LibrarySeriesOrderTest.kt | 136 ++++++++++++++++++ 2 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySeriesOrderTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt index ba6abdf..462980c 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt @@ -1407,7 +1407,8 @@ internal suspend fun arrangeShelf( /** * Groups shelf books. [books] must already be in [sortMode] order (the shelf * sorts once before grouping), and each group keeps that order, so groups are - * not sorted again. + * not sorted again. The one exception is a series in Series view, which reads + * in [seriesReadingOrder]. */ internal fun buildGroupedSections( books: List, @@ -1423,10 +1424,52 @@ internal fun buildGroupedSections( keys.forEach { key -> grouped.getOrPut(key) { mutableListOf() }.add(book) } } - return grouped.entries + val sections = grouped.entries .map { (key, values) -> SectionSortEntry(GroupedSection(key = key, title = key, books = values)) } .sortedWith(groupedSectionComparator(sortMode)) .map { it.section } + if (viewMode != ViewMode.SERIES) return sections + + // Only now, after the groups are ordered by their books in shelf order, + // does each series switch to reading order. A Recently Added shelf still + // lists the series with the newest book first. Books with no series are + // not a series, so they keep the shelf order. + return sections.map { section -> + if (section.key == UNKNOWN_SERIES_GROUP) section + else section.copy(books = seriesReadingOrder(section.books)) + } +} + +/** + * A series' books in reading order (issue #63): by sequence, compared as + * numbers, so 2 comes before 10 and 1.5 sits between 1 and 2. A book with no + * sequence, or one that is not a plain number, goes last. Ties go by title, + * and books that still tie keep the order they came in. + */ +internal fun seriesReadingOrder(books: List): List { + if (books.size < 2) return books + return books + .map { SeriesOrderEntry(it) } + .sortedWith(compareBy(nullsLast()) { it.sequence }.thenBy { it.titleKey }) + .map { it.book } +} + +private val PLAIN_SEQUENCE_NUMBER = Regex("""\d+(?:[.,]\d+)?""") + +/** + * A series sequence as a number: "10", "1.5", or "1,5" as some tags write + * it. Null for anything else ("Book 3", "1-3", blank), which sorts last. + */ +internal fun seriesSequenceNumber(sequence: String?): Double? { + val text = sequence?.trim() ?: return null + if (!PLAIN_SEQUENCE_NUMBER.matches(text)) return null + return text.replace(',', '.').toDoubleOrNull() +} + +/** A book with its reading order keys worked out once, not on every compare. */ +private class SeriesOrderEntry(val book: AudioBook) { + val sequence: Double? = seriesSequenceNumber(book.seriesSequence) + val titleKey: String = book.title.lowercase() } /** Grouped views start collapsed when they have more groups than this. */ diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySeriesOrderTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySeriesOrderTest.kt new file mode 100644 index 0000000..836471f --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySeriesOrderTest.kt @@ -0,0 +1,136 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.domain.model.AudioBook +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Series view lists each series in reading order, by sequence, whatever the + * shelf sort (issue #63). It used to reuse the shelf sort, so a series read + * in title or date order. + */ +class LibrarySeriesOrderTest { + + @Test + fun `sequences compare as numbers, so 2 comes before 10`() { + val books = listOf(book("10"), book("2"), book("1"), book("11"), book("3")) + + assertEquals(listOf("1", "2", "3", "10", "11"), seriesReadingOrder(books).map { it.seriesSequence }) + } + + @Test + fun `a half step sits between its neighbours`() { + val books = listOf(book("2"), book("1.5"), book("1"), book("0.5"), book("1,25")) + + assertEquals(listOf("0.5", "1", "1,25", "1.5", "2"), seriesReadingOrder(books).map { it.seriesSequence }) + } + + @Test + fun `missing and non-numeric sequences go last, by title`() { + val books = listOf( + book(null, title = "Zeta"), + book("Prequel", title = "Alpha"), + book("2"), + book("", title = "Beta"), + book("1"), + ) + + assertEquals( + listOf("1", "2", "Alpha", "Beta", "Zeta"), + seriesReadingOrder(books).map { if (it.title == "Book") it.seriesSequence else it.title }, + ) + } + + @Test + fun `equal sequences go by title, and equal titles keep the order they came in`() { + val books = listOf( + book("1", title = "beta", id = "b"), + book("1", title = "Alpha", id = "a2"), + book("1", title = "alpha", id = "a1"), + ) + + assertEquals(listOf("a2", "a1", "b"), seriesReadingOrder(books).map { it.id }) + } + + @Test + fun `only a plain number is a number`() { + assertEquals(10.0, seriesSequenceNumber(" 10 ")!!, 0.0) + assertEquals(1.5, seriesSequenceNumber("1.5")!!, 0.0) + assertEquals(1.5, seriesSequenceNumber("1,5")!!, 0.0) + assertNull(seriesSequenceNumber(null)) + assertNull(seriesSequenceNumber("")) + assertNull(seriesSequenceNumber("Book 3")) + assertNull(seriesSequenceNumber("1-3")) + assertNull(seriesSequenceNumber("NaN")) + assertNull(seriesSequenceNumber("1e3")) + } + + @Test + fun `Series view uses reading order whatever the shelf sort`() { + val books = listOf( + book("3", title = "Ashes", id = "s3", addedAt = 3), + book("1", title = "Cinders", id = "s1", addedAt = 1), + book("2", title = "Blaze", id = "s2", addedAt = 2), + ) + for (mode in SortMode.entries) { + val sections = buildGroupedSections(sortBooks(books, mode), ViewMode.SERIES, mode) + + assertEquals("for $mode", listOf("s1", "s2", "s3"), sections.single().books.map { it.id }) + } + } + + @Test + fun `series are still ordered by the shelf sort, newest book first for Recently Added`() { + val books = listOf( + // Old series whose book 3 was added last of everything. + book("1", series = "Old", id = "old1", addedAt = 1), + book("3", series = "Old", id = "old3", addedAt = 30), + // New series, every book added after Old's first. + book("1", series = "New", id = "new1", addedAt = 10), + book("2", series = "New", id = "new2", addedAt = 20), + ) + + val sections = buildGroupedSections(sortBooks(books, SortMode.RECENTLY_ADDED), ViewMode.SERIES, SortMode.RECENTLY_ADDED) + + assertEquals(listOf("Old", "New"), sections.map { it.key }) + assertEquals(listOf("old1", "old3"), sections[0].books.map { it.id }) + assertEquals(listOf("new1", "new2"), sections[1].books.map { it.id }) + } + + @Test + fun `books with no series keep the shelf order`() { + val books = listOf( + book(null, series = null, title = "Zulu", id = "z"), + book(null, series = null, title = "Alpha", id = "a"), + ) + + val sections = buildGroupedSections(sortBooks(books, SortMode.TITLE_ZA), ViewMode.SERIES, SortMode.TITLE_ZA) + + assertEquals(listOf("z", "a"), sections.single().books.map { it.id }) + } + + @Test + fun `other views keep the shelf order inside each group`() { + val books = listOf(book("2", title = "Alpha", id = "a"), book("1", title = "Beta", id = "b")) + + val sections = buildGroupedSections(sortBooks(books, SortMode.TITLE_AZ), ViewMode.AUTHOR, SortMode.TITLE_AZ) + + assertEquals(listOf("a", "b"), sections.single().books.map { it.id }) + } + + private fun book( + sequence: String?, + title: String = "Book", + id: String = "id-$sequence-$title", + series: String? = "Saga", + addedAt: Long? = null, + ) = AudioBook( + id = id, + title = title, + author = "Author", + seriesName = series, + seriesSequence = sequence, + addedAt = addedAt, + ) +} From 8d67600e8afb1eba455570f09066757056991003 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 13:05:18 -0400 Subject: [PATCH 14/23] A co-authored book shows under each of its authors in Author view (#64) Audiobookshelf's library list sends a co-authored book's authors as one line, "Terry Pratchett, Neil Gaiman", and Author view filed it under a group of that whole line. It is now split on the ", " Audiobookshelf joins with, so the book is listed under Terry Pratchett and under Neil Gaiman, and the row still shows the full line. A suffix piece like "Jr.", "III" or "PhD" stays with the name before it. Local books keep their author tag whole, since a tag like "Tolkien, J.R.R." is one person. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/library/LibraryViewModel.kt | 39 +++++++++- .../ui/library/LibraryAuthorGroupingTest.kt | 75 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryAuthorGroupingTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt index 462980c..652ce1d 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt @@ -1521,7 +1521,8 @@ internal fun flattenGroupedItems( private fun groupingKeysForBook(book: AudioBook, viewMode: ViewMode): List = when (viewMode) { ViewMode.SERIES -> listOf(book.seriesName?.takeIf { it.isNotBlank() } ?: UNKNOWN_SERIES_GROUP) - ViewMode.AUTHOR -> listOf(book.author.takeIf { it.isNotBlank() } ?: UNKNOWN_AUTHOR_GROUP) + // Multi-placement too: a co-authored book appears under each author. + ViewMode.AUTHOR -> authorGroupNames(book).ifEmpty { listOf(UNKNOWN_AUTHOR_GROUP) } ViewMode.GENRE -> book.genres .asSequence() .map { it.trim() } @@ -1532,6 +1533,42 @@ private fun groupingKeysForBook(book: AudioBook, viewMode: ViewMode): List emptyList() } +/** + * The authors a book is listed under in Author view (issue #64). The book + * itself keeps its full author line for display. Audiobookshelf's library + * list sends a co-authored book's authors as one string joined with ", ", so + * a server book is split there ([splitServerAuthorNames]). A local book's + * author comes from its tags or folder, where "Tolkien, J.R.R." is one + * person written last name first, so it stays whole. Empty for no author. + */ +internal fun authorGroupNames(book: AudioBook): List { + val author = book.author.trim() + if (author.isEmpty()) return emptyList() + return if (book.isLocal) listOf(author) else splitServerAuthorNames(author) +} + +/** + * The names in an Audiobookshelf author line, which joins its authors with + * ", ". The library list sends nothing more structured, so a piece that is + * only a name suffix ("Jr.", "III", "PhD") is put back on the name before it: + * "Martin Luther King, Jr., Coretta Scott King" is two people, not three. + */ +internal fun splitServerAuthorNames(authorName: String): List { + val names = ArrayList() + for (piece in authorName.split(", ")) { + val name = piece.trim() + if (name.isEmpty()) continue + if (names.isNotEmpty() && isNameSuffix(name)) names[names.lastIndex] = "${names.last()}, $name" + else names += name + } + return names.distinct() +} + +/** Name suffixes that follow a comma inside one author's name. */ +private val NAME_SUFFIXES = setOf("jr", "sr", "ii", "iii", "iv", "phd", "ph.d", "md", "m.d", "esq", "inc", "llc", "ltd") + +private fun isNameSuffix(piece: String): Boolean = piece.lowercase().removeSuffix(".") in NAME_SUFFIXES + /** A section with its lowercased title worked out once, not on every compare. */ private class SectionSortEntry(val section: GroupedSection) { val titleKey: String = section.title.lowercase() diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryAuthorGroupingTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryAuthorGroupingTest.kt new file mode 100644 index 0000000..3eb3964 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryAuthorGroupingTest.kt @@ -0,0 +1,75 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.domain.model.AudioBook +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Audiobookshelf's library list sends a co-authored book's authors as one + * string, "Terry Pratchett, Neil Gaiman". Author view filed that under a + * group of its own. It now lists the book under each author (issue #64). + */ +class LibraryAuthorGroupingTest { + + @Test + fun `a co-authored server book is listed under each author`() { + val books = listOf( + book("good-omens", "Terry Pratchett, Neil Gaiman"), + book("mort", "Terry Pratchett"), + book("coraline", "Neil Gaiman"), + ) + + val sections = buildGroupedSections(sortBooks(books, SortMode.TITLE_AZ), ViewMode.AUTHOR, SortMode.TITLE_AZ) + + assertEquals(listOf("Neil Gaiman", "Terry Pratchett"), sections.map { it.key }) + assertEquals(listOf("coraline", "good-omens"), sections[0].books.map { it.id }) + assertEquals(listOf("good-omens", "mort"), sections[1].books.map { it.id }) + } + + @Test + fun `the book keeps its full author line for display`() { + val sections = buildGroupedSections( + listOf(book("good-omens", "Terry Pratchett, Neil Gaiman")), + ViewMode.AUTHOR, + SortMode.TITLE_AZ, + ) + + sections.forEach { section -> + assertEquals("Terry Pratchett, Neil Gaiman", section.books.single().author) + } + } + + @Test + fun `a name suffix stays with the name before it`() { + assertEquals( + listOf("Martin Luther King, Jr.", "Coretta Scott King"), + splitServerAuthorNames("Martin Luther King, Jr., Coretta Scott King"), + ) + assertEquals(listOf("John Smith, III"), splitServerAuthorNames("John Smith, III")) + assertEquals(listOf("Jane Doe, PhD", "Ann Lee, M.D."), splitServerAuthorNames("Jane Doe, PhD, Ann Lee, M.D.")) + } + + @Test + fun `blank pieces and repeats are dropped`() { + assertEquals(listOf("Ann", "Bob"), splitServerAuthorNames("Ann, , Bob, Ann")) + assertEquals(listOf("Solo"), splitServerAuthorNames("Solo")) + } + + @Test + fun `a local book's author tag is one name, commas and all`() { + // Tags often put the last name first. + val local = book("hobbit", "Tolkien, J.R.R.", isLocal = true) + + assertEquals(listOf("Tolkien, J.R.R."), authorGroupNames(local)) + } + + @Test + fun `a book with no author is under Unknown Author`() { + val sections = buildGroupedSections(listOf(book("x", " ")), ViewMode.AUTHOR, SortMode.TITLE_AZ) + + assertEquals(listOf("Unknown Author"), sections.map { it.key }) + } + + private fun book(id: String, author: String, isLocal: Boolean = false) = + AudioBook(id = id, title = id, author = author, isLocal = isLocal) +} From 0e168fdd581b8075ea2a5506f60e8e747a2a9482 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 13:10:23 -0400 Subject: [PATCH 15/23] A restarted download never fights the old one for its files Stopping the drain gives up after ten seconds, and WorkManager calls a cancelled drain finished long before its engine unwinds. A read stuck on a stalled server only gives up at the 60 second read timeout, and its cleanup then deleted the .part file the replacement drain was writing, so finalizing failed and the book downloaded again and again. Any restart could hit this: the Wi-Fi only flip, pause, cancel and delete. Engine runs now hold one process-wide lock from start to the end of cleanup, so a replacement drain waits until the old engine has really exited. The book recorded for cancel cleanup is set only once the lock is held, and the row is read again after the wait so a book cancelled or finished meanwhile is skipped rather than written back. Co-Authored-By: Claude Opus 5.5 --- .../app/service/DownloadManager.kt | 31 +++++--- .../service/download/DownloadQueueWorker.kt | 18 +++-- .../app/service/download/DownloadWork.kt | 28 +++++++ .../download/EngineExclusiveRunTest.kt | 73 +++++++++++++++++++ 4 files changed, 133 insertions(+), 17 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/download/EngineExclusiveRunTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt index 00c9deb..b061124 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt @@ -38,6 +38,7 @@ import com.ninelivesaudio.app.service.download.mayDownloadOnDrain import com.ninelivesaudio.app.service.download.overrideRestartsDrain import com.ninelivesaudio.app.service.download.wifiRuleChangeRestartsDrain import com.ninelivesaudio.app.service.download.writeAfterEngineStops +import com.ninelivesaudio.app.service.download.runEngineExclusively import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -182,17 +183,27 @@ class DownloadManager @Inject constructor( @Volatile private var engineDownloadId: String? = null - /** Called by [DownloadQueueWorker] right before it runs the engine on a book. */ - fun onEngineStarted(audioBookId: String, downloadId: String) { - engineBookId = audioBookId - engineDownloadId = downloadId - } + /** Held across every engine run, cleanup included. See [runEngineExclusively]. */ + private val engineLock = Mutex() - /** Called by [DownloadQueueWorker] from a finally once the engine returns or throws. */ - fun onEngineStopped() { - engineBookId = null - engineDownloadId = null - } + /** + * Run [block] (the engine on [downloadId]) once no other engine run is + * still unwinding, recording the book for cancel cleanup and the Wi-Fi + * rule while it runs. Called by [DownloadQueueWorker]. + */ + suspend fun runEngine(audioBookId: String, downloadId: String, block: suspend () -> T): T = + runEngineExclusively( + lock = engineLock, + onStart = { + engineBookId = audioBookId + engineDownloadId = downloadId + }, + onStop = { + engineBookId = null + engineDownloadId = null + }, + block = block, + ) // ─── Wi-Fi only (#79) ──────────────────────────────────────────────────── diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadQueueWorker.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadQueueWorker.kt index b2d2e29..27fa39e 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadQueueWorker.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadQueueWorker.kt @@ -130,11 +130,17 @@ class DownloadQueueWorker( // and Android rate-limits notification posts, so unthrottled updates // get dropped and the bar appears frozen / far behind. var lastNotifiedPercent = -1 - // Cancel cleanup checks this last thing before deleting files. - manager.onEngineStarted(item.audioBookId, item.id) - val result = try { + // One engine at a time across drains: a replacement waits here until + // a cancelled drain's engine has really exited. Cancel cleanup checks + // the book recorded on entry last thing before deleting files. + val result = manager.runEngine(item.audioBookId, item.id) { + // The wait can outlast an old engine or a user action, so the row + // is read again and only a still downloadable one goes ahead. + val fresh = dao.getById(item.id)?.toDomain() + ?.takeIf { it.status == DownloadStatus.Queued || it.status == DownloadStatus.Downloading } + ?: return@runEngine null withContext(Dispatchers.IO) { - engine.download(item, book) { id, downloaded, total -> + engine.download(fresh, book) { id, downloaded, total -> manager.publishProgress(id, downloaded, total) val percent = if (total > 0) { ((downloaded.toDouble() / total) * 100).toInt() @@ -147,9 +153,7 @@ class DownloadQueueWorker( } } } - } finally { - manager.onEngineStopped() - } + } ?: continue manager.notifyTerminal(result) android.util.Log.d(TAG, "done id=${item.id} result=${result.status}") } diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadWork.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadWork.kt index cca1412..26ab4de 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadWork.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadWork.kt @@ -4,6 +4,8 @@ import com.ninelivesaudio.app.domain.model.DownloadItem import com.ninelivesaudio.app.domain.model.DownloadStatus import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock // ─── WorkManager identifier + queue selection ───────────────────────────── // @@ -47,6 +49,32 @@ internal suspend fun writeAfterEngineStops( return write() } +/** + * Run the engine with [lock] held, bracketed by [onStart] and [onStop]. + * + * WorkManager marks a cancelled drain finished as soon as the cancel is + * recorded, and the stop wait gives up after ten seconds, but a read stuck on + * a stalled server only unwinds at the 60 second read timeout. Its cleanup + * then deletes the book's `.part` file, the one a replacement drain may be + * writing by then. Holding one process-wide lock across the whole run, cleanup + * included, means a replacement engine cannot start until the old one has + * actually exited. [onStart] runs only once the lock is held, so whatever it + * records names the engine that is really running. + */ +internal suspend fun runEngineExclusively( + lock: Mutex, + onStart: () -> Unit, + onStop: () -> Unit, + block: suspend () -> T, +): T = lock.withLock { + onStart() + try { + block() + } finally { + onStop() + } +} + /** * Run a user's pause, cancel or delete in [owner] and await it. * diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/EngineExclusiveRunTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/EngineExclusiveRunTest.kt new file mode 100644 index 0000000..2e23712 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/EngineExclusiveRunTest.kt @@ -0,0 +1,73 @@ +package com.ninelivesaudio.app.service.download + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicBoolean + +/** + * WorkManager reports a cancelled drain as stopped long before its engine has + * unwound. A read stuck on a stalled server can hold on for the 60 second read + * timeout, and its cleanup then deletes the `.part` file a replacement drain is + * writing. Engine runs are serialized, so a replacement only starts once the + * old run has fully exited, cleanup included. + */ +class EngineExclusiveRunTest { + + @Test + fun `a replacement run waits for the cancelled run's cleanup`() = runBlocking { + val lock = Mutex() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val oldStarted = CompletableDeferred() + val oldCleanedUp = AtomicBoolean(false) + + val old = scope.launch { + runEngineExclusively(lock, onStart = {}, onStop = {}) { + oldStarted.complete(Unit) + try { + awaitCancellation() + } finally { + // The stalled read finally gives up and deletes its .part. + withContext(NonCancellable) { delay(200) } + oldCleanedUp.set(true) + } + } + } + oldStarted.await() + // Cancellation is recorded at once, the old run is still unwinding. + old.cancel() + + val sawCleanupFirst = withTimeout(5_000) { + scope.async { + runEngineExclusively(lock, onStart = {}, onStop = {}) { oldCleanedUp.get() } + }.await() + } + + assertTrue("the replacement started while the old run was still cleaning up", sawCleanupFirst) + } + + @Test + fun `start and stop bracket the run, even when it throws`() = runBlocking { + val events = mutableListOf() + runCatching { + runEngineExclusively(Mutex(), onStart = { events += "start" }, onStop = { events += "stop" }) { + events += "run" + error("boom") + } + } + assertEquals(listOf("start", "run", "stop"), events) + } +} From 75f940cb784e4b7dd9ad2041730bfc58b959d16a Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 13:10:29 -0400 Subject: [PATCH 16/23] Deleting one edition while the other is finishing keeps the other's audio The engine writes Completed, then waits on the cover, and only then saves the book's localPath. Deleting another edition that shares the folder in that window missed the finishing book on both counts: no stored path made it a sharer, and its Completed row did not count as unfinished. So every plain file went, and the finishing book was then marked downloaded with its tracks gone. A Completed row now counts as still owning its folder until the book row holds a saved local copy, the book the engine is on always counts, and the folder the engine is really writing to is protected too, in case the title changed under it. Any doubt keeps the files. Co-Authored-By: Claude Opus 5.5 --- .../app/service/DownloadManager.kt | 28 +++++--- .../app/service/download/CancelCleanup.kt | 31 +++++++++ .../app/service/download/DownloadEngine.kt | 25 ++++++- .../service/download/DownloadDeleteTest.kt | 67 +++++++++++++++++++ 4 files changed, 142 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt index b061124..9de3c6e 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt @@ -39,6 +39,8 @@ import com.ninelivesaudio.app.service.download.overrideRestartsDrain import com.ninelivesaudio.app.service.download.wifiRuleChangeRestartsDrain import com.ninelivesaudio.app.service.download.writeAfterEngineStops import com.ninelivesaudio.app.service.download.runEngineExclusively +import com.ninelivesaudio.app.service.download.unfinishedDownloadOwners +import com.ninelivesaudio.app.service.download.DownloadOwnerRow import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -877,15 +879,25 @@ class DownloadManager @Inject constructor( fileNames = other.audioFiles.takeIf { it.isNotEmpty() }?.let(::downloadedFileNames), ) } - // Unfinished downloads of other books, and where each would write. - // Their track lists are not known yet, so sharing a folder with one - // keeps everything. - val liveOtherBookIds = downloadItemDao.getAll() - .filter { it.audioBookId != book.id && it.status != DownloadStatus.Completed.ordinal } - .map { it.audioBookId } + // Other books that may still be writing, and where each would write. + // Their track lists are not settled yet, so sharing a folder with + // one keeps everything. That includes a Completed row whose book has + // no saved local copy yet (the engine is still on the cover) and the + // book the engine is on, row or not. + val rows = downloadItemDao.getAll().map { DownloadOwnerRow(it.audioBookId, it.status) } + val candidateIds = (rows.map { it.audioBookId } + listOfNotNull(engineBookId)) + .filter { it != book.id } .distinct() - val otherFolders = lookUpInChunks(liveOtherBookIds) { audioBookDao.getByIds(it) } - .map { engine.downloadLocationFor(it.toDomain()).folder } + val candidates = lookUpInChunks(candidateIds) { audioBookDao.getByIds(it) }.associateBy { it.id } + val withLocalCopy = candidates.values + .filter { it.isDownloaded == 1 && !it.localPath.isNullOrEmpty() } + .mapTo(HashSet()) { it.id } + val otherFolders = unfinishedDownloadOwners(rows, withLocalCopy, book.id, engineBookId) + // No book row means nothing the engine could be writing for it. + .mapNotNull { candidates[it] } + .map { engine.downloadLocationFor(it.toDomain()).folder } + + // The folder the engine really writes to, in case the title moved. + listOfNotNull(engine.activeDownloadDir.takeIf { engineBookId != book.id }) val decision = withContext(Dispatchers.IO) { deleteDownloadFiles(location, downloadedFileNames(book.audioFiles), sharers, otherFolders) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt index 97dfce5..cc33bbd 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt @@ -371,6 +371,37 @@ private fun decideDownloadDelete( return CancelCleanupDecision.Delete(folder, plainFiles.filter { it.name in targets }) } +/** One download row, as the ownership check needs it. */ +internal data class DownloadOwnerRow(val audioBookId: String, val status: Int) + +/** + * Books other than [excludeBookId] that may still be writing to their folder, + * so a delete must treat that folder as taken (see `otherDownloadFolders` on + * [deleteDownloadFiles]). + * + * Any row short of Completed counts. So does a Completed row whose book is not + * in [booksWithLocalCopy] (marked downloaded with a stored localPath): the + * engine writes Completed, then fetches the cover, and only then saves the + * localPath, so in that window the book has audio on disk and no stored path + * that would make it a sharer. The book the engine is on ([engineBookId]) + * always counts, row or not. Any doubt keeps the files. + */ +internal fun unfinishedDownloadOwners( + rows: List, + booksWithLocalCopy: Set, + excludeBookId: String, + engineBookId: String?, +): List { + val owners = rows + .filter { row -> + row.status != DownloadStatus.Completed.ordinal || row.audioBookId !in booksWithLocalCopy + } + .map { it.audioBookId } + return (owners + listOfNotNull(engineBookId)) + .filter { it != excludeBookId } + .distinct() +} + /** The cover the engine saves next to the audio. */ private const val COVER_FILE_NAME = "cover.jpg" diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt index 05f5842..2884f8d 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt @@ -56,16 +56,39 @@ class DownloadEngine @Inject constructor( * Returns the terminal [DownloadItem] (Completed / Failed / Paused); the * caller owns any completion/failure event emission. */ + /** + * The folder the running download writes to, from the moment it is picked + * until the book row holds its localPath. Delete treats it as taken. Engine + * runs are serialized (see [runEngineExclusively]), so one slot is enough. + */ + @Volatile + internal var activeDownloadDir: File? = null + private set + suspend fun download( item: DownloadItem, audioBook: AudioBook, onProgress: suspend (downloadId: String, downloaded: Long, total: Long) -> Unit, + ): DownloadItem { + val downloadDir = getDownloadPath(audioBook) + activeDownloadDir = downloadDir + return try { + downloadInto(downloadDir, item, audioBook, onProgress) + } finally { + activeDownloadDir = null + } + } + + private suspend fun downloadInto( + downloadDir: File, + item: DownloadItem, + audioBook: AudioBook, + onProgress: suspend (downloadId: String, downloaded: Long, total: Long) -> Unit, ): DownloadItem { var download = item.copy(status = DownloadStatus.Downloading) downloadItemDao.upsert(download.toEntity()) // Create download directory - val downloadDir = getDownloadPath(audioBook) downloadDir.mkdirs() // Get full book details if audio files are missing diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt index bce93f4..76ab038 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt @@ -1,5 +1,6 @@ package com.ninelivesaudio.app.service.download +import com.ninelivesaudio.app.domain.model.DownloadStatus import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -241,6 +242,72 @@ class DownloadDeleteTest { assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.NO_FOLDER), decision) } + // ─── Downloads that still own a folder ────────────────────────────────── + + private fun row(bookId: String, status: DownloadStatus) = DownloadOwnerRow(bookId, status.ordinal) + + @Test + fun `an edition whose row says Completed before its local copy is saved still owns the folder`() { + // The engine writes Completed, then fetches the cover, then saves + // localPath. Deleting the other edition in that window must not + // count this one as finished. + val owners = unfinishedDownloadOwners( + rows = listOf(row("A", DownloadStatus.Completed), row("B", DownloadStatus.Completed)), + booksWithLocalCopy = setOf("A"), + excludeBookId = "A", + engineBookId = null, + ) + assertEquals(listOf("B"), owners) + } + + @Test + fun `the book the engine is on owns its folder even with no row`() { + val owners = unfinishedDownloadOwners( + rows = emptyList(), + booksWithLocalCopy = emptySet(), + excludeBookId = "A", + engineBookId = "B", + ) + assertEquals(listOf("B"), owners) + } + + @Test + fun `unfinished rows own their folder and finished ones with a local copy do not`() { + val owners = unfinishedDownloadOwners( + rows = listOf( + row("A", DownloadStatus.Queued), + row("B", DownloadStatus.Paused), + row("C", DownloadStatus.Failed), + row("D", DownloadStatus.Completed), + row("E", DownloadStatus.Downloading), + ), + booksWithLocalCopy = setOf("D"), + excludeBookId = "A", + engineBookId = "A", + ) + assertEquals(listOf("B", "C", "E"), owners) + } + + @Test + fun `deleting one edition while the other is finishing keeps the other's audio`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3") + val owners = unfinishedDownloadOwners( + rows = listOf(row("B", DownloadStatus.Completed)), + booksWithLocalCopy = emptySet(), + excludeBookId = "A", + engineBookId = "B", + ) + // B has no localPath yet, so it is not a sharer. Only its ownership protects it. + val ownerFolders = owners.map { folder } + + val decision = delete(root, folder, listOf("01.mp3", "02.mp3"), otherDownloadFolders = ownerFolders) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.SHARED), decision) + assertTrue(File(folder, "01.mp3").exists()) + assertTrue(File(folder, "02.mp3").exists()) + } + // ─── Which root a stored path belongs to ───────────────────────────────── @Test From 849ebce76b16356aa22cf076f86806b6c7ad9d0a Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 13:33:59 -0400 Subject: [PATCH 17/23] Deleting or cancelling a download never races the engine for another book's files Delete, cancel and the engine each decided who owned a download folder from several separate reads while the others kept changing it, and each earlier fix narrowed one interleaving without closing the next. Three were still open: deleting one edition while the other saved its finish wiped the other's audio, cancelling a paused book deleted the folder another book's engine was still writing after a rename moved its row elsewhere, and a queued book cancelled as the engine picked it came back and downloaded. Now one lock covers every change to what the engine owns (claiming a row, saving the finish, letting the folder go) and every delete, cancel or pause from its decision through its last deleted file. The engine claims a row only while it still exists and is still waiting, and saves a finish only while the row still exists, with the Completed row and the book's local copy written together. The folder the engine is really writing is never deleted, whichever book it belongs to. No network call ever runs under the lock, so a stalled read cannot freeze delete, cancel or the screen. Co-Authored-By: Claude Opus 5.5 --- .../app/service/DownloadManager.kt | 229 +++++------- .../app/service/download/CancelCleanup.kt | 38 +- .../app/service/download/DownloadEngine.kt | 81 ++-- .../app/service/download/DownloadOwnership.kt | 213 +++++++++++ .../service/download/DownloadQueueWorker.kt | 14 +- .../app/service/download/DownloadWork.kt | 18 - .../app/service/download/CancelCleanupTest.kt | 40 -- .../service/download/DownloadDeleteTest.kt | 20 +- .../service/download/DownloadOwnershipTest.kt | 351 ++++++++++++++++++ .../download/UserDownloadWriteOrderTest.kt | 36 -- 10 files changed, 710 insertions(+), 330 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/service/download/DownloadOwnership.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/download/DownloadOwnershipTest.kt delete mode 100644 app/src/test/java/com/ninelivesaudio/app/service/download/UserDownloadWriteOrderTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt index 9de3c6e..8fba79f 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt @@ -12,14 +12,10 @@ import com.ninelivesaudio.app.domain.model.DownloadItem import com.ninelivesaudio.app.domain.model.DownloadStatus import com.ninelivesaudio.app.service.download.CancelCleanupDecision import com.ninelivesaudio.app.service.download.DOWNLOAD_WORK_NAME -import com.ninelivesaudio.app.service.download.applyCancelCleanup -import com.ninelivesaudio.app.service.download.cancelCleanupStillClear import com.ninelivesaudio.app.service.download.cancelMayTouchFiles -import com.ninelivesaudio.app.service.download.decideCancelCleanup -import com.ninelivesaudio.app.service.download.deleteDownloadFiles -import com.ninelivesaudio.app.service.download.downloadedFileNames -import com.ninelivesaudio.app.service.download.FolderSharer import com.ninelivesaudio.app.service.download.DownloadEngine +import com.ninelivesaudio.app.service.download.DownloadOwnership +import com.ninelivesaudio.app.service.download.FolderReads import com.ninelivesaudio.app.service.download.DownloadNotifications import com.ninelivesaudio.app.service.download.DownloadQueueWorker import com.ninelivesaudio.app.service.download.estimateTotalBytes @@ -37,9 +33,9 @@ import com.ninelivesaudio.app.service.download.liveOverrides import com.ninelivesaudio.app.service.download.mayDownloadOnDrain import com.ninelivesaudio.app.service.download.overrideRestartsDrain import com.ninelivesaudio.app.service.download.wifiRuleChangeRestartsDrain -import com.ninelivesaudio.app.service.download.writeAfterEngineStops import com.ninelivesaudio.app.service.download.runEngineExclusively -import com.ninelivesaudio.app.service.download.unfinishedDownloadOwners +import com.ninelivesaudio.app.service.download.removeCancelledPartialFiles +import com.ninelivesaudio.app.service.download.removeDownloadedBookFiles import com.ninelivesaudio.app.service.download.DownloadOwnerRow import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -77,6 +73,7 @@ class DownloadManager @Inject constructor( private val downloadItemDao: DownloadItemDao, private val audioBookDao: AudioBookDao, private val engine: DownloadEngine, + private val ownership: DownloadOwnership, private val slotStore: DownloadSlotStore, private val settingsManager: SettingsManager, private val connectivityMonitor: ConnectivityMonitor, @@ -97,15 +94,16 @@ class DownloadManager @Inject constructor( /** * Keeps cancel cleanup from racing anything that brings a download back. * - * Cancel holds it from the row read through the engine stop, the row delete, - * the ownership snapshot and the file deletion. Resume holds it across its + * Cancel, delete and pause hold it from the row read through the engine + * stop, the write and any file deletion. Resume holds it across its * read, its write and the drain start. Promotion in [queueDownload] holds it * for the write that makes a row pickable, and every drain start goes through * [enqueueDrain], which takes it too. Without it a resume could read a * Paused row, lose the race to cancel, write the row back and start the * engine, and cleanup would then delete files the resumed engine reuses. * - * Lock order: this one first, [slotMutex] inside it, never the reverse. + * Lock order: this one first, [slotMutex] or [DownloadOwnership]'s lock + * inside it, never the reverse. */ private val rowMutex = Mutex() private val workManager: WorkManager by lazy { WorkManager.getInstance(context) } @@ -174,38 +172,16 @@ class DownloadManager @Inject constructor( drainRunning = false } - /** - * The book the engine is streaming right now in this process, or null. - * Cancel cleanup checks it one last time before deleting anything. - */ - @Volatile - private var engineBookId: String? = null - - /** The download row the engine is on, for the Wi-Fi only rule. */ - @Volatile - private var engineDownloadId: String? = null - /** Held across every engine run, cleanup included. See [runEngineExclusively]. */ private val engineLock = Mutex() /** - * Run [block] (the engine on [downloadId]) once no other engine run is - * still unwinding, recording the book for cancel cleanup and the Wi-Fi - * rule while it runs. Called by [DownloadQueueWorker]. + * Run [block] (one engine run) once no other engine run is still + * unwinding. Which book the engine owns is tracked by [DownloadOwnership], + * which the engine claims itself. Called by [DownloadQueueWorker]. */ - suspend fun runEngine(audioBookId: String, downloadId: String, block: suspend () -> T): T = - runEngineExclusively( - lock = engineLock, - onStart = { - engineBookId = audioBookId - engineDownloadId = downloadId - }, - onStop = { - engineBookId = null - engineDownloadId = null - }, - block = block, - ) + suspend fun runEngine(block: suspend () -> T): T = + runEngineExclusively(lock = engineLock, onStart = {}, onStop = {}, block = block) // ─── Wi-Fi only (#79) ──────────────────────────────────────────────────── @@ -299,7 +275,7 @@ class DownloadManager @Inject constructor( rowMutex.withLock { if (downloadsPaused) return@withLock if (downloadItemDao.getDownloadable().isEmpty()) return@withLock - val engineRowOverridden = engineDownloadId?.let { it in _meteredOverrides.value } ?: false + val engineRowOverridden = ownership.engineDownloadId?.let { it in _meteredOverrides.value } ?: false if (wifiRuleChangeRestartsDrain(wifiOnly, drainRunning, drainUnmetered, engineRowOverridden)) { restartDrainHoldingRowLock() } @@ -664,19 +640,24 @@ class DownloadManager @Inject constructor( /** Pause a download: mark it Paused; restart the drain if it was the active one. */ suspend fun pauseDownload(downloadId: String): Unit = finishInOwnerScope(scope) { - val entity = downloadItemDao.getById(downloadId) ?: return@finishInOwnerScope - val wasDownloading = entity.status == DownloadStatus.Downloading.ordinal - writeAfterEngineStops(wasDownloading, { stopDrainAndAwait() }) { - // Re-read after the stop: the engine's last tick moved the bytes on, - // and it may have finished the book in the meantime. A Completed row - // only counts once the book itself is marked downloaded, since the - // stop can land between those two writes. - val latest = downloadItemDao.getById(downloadId) ?: return@writeAfterEngineStops - val book = audioBookDao.getById(latest.audioBookId) - if (pauseKeepsCompletedRow(latest.status, book?.isDownloaded, book?.localPath)) { - return@writeAfterEngineStops + // Held like cancel, so no drain starts between the stop and the write. + val wasDownloading = rowMutex.withLock { + val entity = downloadItemDao.getById(downloadId) ?: return@withLock false + ownership.actOnceEngineIsOff( + audioBookId = entity.audioBookId, + rowIsDownloading = { isDownloadingRow(downloadId) }, + stopEngine = { stopDrainAndAwait() }, + ) { stop -> + // Re-read after any stop: the engine's last tick moved the bytes on, + // and it may have finished the book in the meantime. A Completed row + // that predates the book's saved local copy still gets Paused. + val latest = downloadItemDao.getById(downloadId) ?: return@actOnceEngineIsOff stop.wasDownloading + val book = audioBookDao.getById(latest.audioBookId) + if (!pauseKeepsCompletedRow(latest.status, book?.isDownloaded, book?.localPath)) { + downloadItemDao.upsert(latest.copy(status = DownloadStatus.Paused.ordinal)) + } + stop.wasDownloading } - downloadItemDao.upsert(latest.copy(status = DownloadStatus.Paused.ordinal)) } if (wasDownloading) { // The engine was stopped above; let the drain continue with the rest. @@ -736,20 +717,26 @@ class DownloadManager @Inject constructor( suspend fun cancelDownload(downloadId: String): Unit = finishInOwnerScope(scope) { // Held from the read to the last deleted file. Resume, promotion and // every drain start wait here, so nothing can bring this book's row - // back or start the engine on it while its files are being judged. + // back or start a drain while its files are being judged. val wasDownloading = rowMutex.withLock { - val entity = downloadItemDao.getById(downloadId) - val downloading = entity?.status == DownloadStatus.Downloading.ordinal - var stopConfirmed = false - writeAfterEngineStops(downloading, { stopConfirmed = stopDrainAndAwait() }) { + val audioBookId = downloadItemDao.getById(downloadId)?.audioBookId + if (audioBookId == null) { + dropMeteredOverride(downloadId) + return@withLock false + } + ownership.actOnceEngineIsOff( + audioBookId = audioBookId, + rowIsDownloading = { isDownloadingRow(downloadId) }, + stopEngine = { stopDrainAndAwait() }, + ) { stop -> + val entity = downloadItemDao.getById(downloadId) downloadItemDao.deleteById(downloadId) dropMeteredOverride(downloadId) - // Still before any drain restart below, so nothing is writing here. - if (entity != null && cancelMayTouchFiles(entity.status, downloading, stopConfirmed)) { + if (entity != null && cancelMayTouchFiles(entity.status, stop.wasDownloading, stop.stopConfirmed)) { removeCancelledPartialFiles(entity.audioBookId) } + stop.wasDownloading } - downloading } if (downloadItemDao.getDownloadable().isEmpty()) { @@ -763,49 +750,23 @@ class DownloadManager @Inject constructor( } } + private suspend fun isDownloadingRow(downloadId: String): Boolean = + downloadItemDao.getById(downloadId)?.status == DownloadStatus.Downloading.ordinal + /** - * Delete the partial folder a cancelled download left behind (#51). - * - * Runs only once the engine is known to be off this book, and after this - * book's row is gone, with [rowMutex] held by the caller. The rules live in - * [decideCancelCleanup] and keep the files on any doubt, including when - * another book or download points at the same folder (#49). Right before - * deleting, [cancelCleanupStillClear] checks again that no row for the book - * or folder came back and the engine is not on the book. Best effort: a - * failure here never fails the cancel. + * Delete the partial folder a cancelled download left behind (#51). Runs + * inside [DownloadOwnership.actOnceEngineIsOff], after this book's row is + * gone. See [removeCancelledPartialFiles]. Best effort: a failure here + * never fails the cancel. */ private suspend fun removeCancelledPartialFiles(audioBookId: String) { try { - val bookEntity = audioBookDao.getById(audioBookId) ?: return - val location = engine.downloadLocationFor(bookEntity.toDomain()) - val otherBookPaths = audioBookDao.getLocalPathsExcept(audioBookId) - // Every download row still around, this book's included if it was - // queued again, mapped to the folder it would write to. - suspend fun liveRows() = downloadItemDao.getAll().map { it.audioBookId }.distinct() - suspend fun foldersOf(bookIds: List) = - lookUpInChunks(bookIds) { audioBookDao.getByIds(it) } - .map { engine.downloadLocationFor(it.toDomain()).folder } - val otherFolders = foldersOf(liveRows()) - val bookIsDownloaded = bookEntity.isDownloaded == 1 || !bookEntity.localPath.isNullOrEmpty() - - val decision = withContext(Dispatchers.IO) { - decideCancelCleanup(location, bookIsDownloaded, otherBookPaths, otherFolders) - } - when (decision) { + when (val decision = removeCancelledPartialFiles(folderReads, ownership, audioBookId)) { + null -> Unit is CancelCleanupDecision.Keep -> android.util.Log.d("DownloadManager", "cancel cleanup kept files: ${decision.reason}") - is CancelCleanupDecision.Delete -> { - val rowBookIds = liveRows() - val clear = withContext(Dispatchers.IO) { - cancelCleanupStillClear(audioBookId, decision.folder, rowBookIds, foldersOf(rowBookIds), engineBookId) - } - if (!clear) { - android.util.Log.d("DownloadManager", "cancel cleanup kept files: a row or the engine came back") - return - } - val count = withContext(Dispatchers.IO) { applyCancelCleanup(decision) } - android.util.Log.d("DownloadManager", "cancel cleanup removed $count files") - } + is CancelCleanupDecision.Delete -> + android.util.Log.d("DownloadManager", "cancel cleanup removed ${decision.files.size} files") } } catch (cancellation: kotlinx.coroutines.CancellationException) { throw cancellation @@ -819,15 +780,16 @@ class DownloadManager @Inject constructor( // Held through the file deletion like cancel, so no resume or drain // start lands on this book while its files are being judged. val wasDownloading = rowMutex.withLock { - val downloadEntity = downloadItemDao.getByAudioBookId(audioBookId) - // The drain can pick a Queued row at any moment, so the engine - // already being on the book counts as downloading too. - val downloading = downloadEntity?.status == DownloadStatus.Downloading.ordinal || - engineBookId == audioBookId - writeAfterEngineStops(downloading, { stopDrainAndAwait() }) { - deleteDownloadedFilesAndRows(audioBookId, downloadEntity?.id) + ownership.actOnceEngineIsOff( + audioBookId = audioBookId, + rowIsDownloading = { + downloadItemDao.getByAudioBookId(audioBookId)?.status == DownloadStatus.Downloading.ordinal + }, + stopEngine = { stopDrainAndAwait() }, + ) { stop -> + deleteDownloadedFilesAndRows(audioBookId, downloadItemDao.getByAudioBookId(audioBookId)?.id) + stop.wasDownloading } - downloading } if (wasDownloading) { // Restart the drain for whatever else is queued. @@ -860,49 +822,14 @@ class DownloadManager @Inject constructor( /** * Delete [book]'s downloaded files from [localPath] and nothing else (#49). - * - * Two editions with the same author and title share one folder, so the - * other books stored there come along with their track lists, and only this - * book's own files go. The rules live in [deleteDownloadFiles], which shares - * cancel cleanup's fences: never outside the download root, never in a - * user-picked folder, never recursive. Best effort: a failure here leaves - * files behind but never fails the delete. + * Runs inside [DownloadOwnership.actOnceEngineIsOff]. See + * [removeDownloadedBookFiles]. Best effort: a failure here leaves files + * behind but never fails the delete. */ private suspend fun removeDownloadedFiles(book: AudioBook, localPath: String) { try { - val location = engine.storedDownloadLocation(localPath) ?: return - val sharers = lookUpInChunks(audioBookDao.getFilePathBookIdsExcept(book.id)) { audioBookDao.getByIds(it) } - .map { it.toDomain() } - .map { other -> - FolderSharer( - path = other.localPath.orEmpty(), - fileNames = other.audioFiles.takeIf { it.isNotEmpty() }?.let(::downloadedFileNames), - ) - } - // Other books that may still be writing, and where each would write. - // Their track lists are not settled yet, so sharing a folder with - // one keeps everything. That includes a Completed row whose book has - // no saved local copy yet (the engine is still on the cover) and the - // book the engine is on, row or not. - val rows = downloadItemDao.getAll().map { DownloadOwnerRow(it.audioBookId, it.status) } - val candidateIds = (rows.map { it.audioBookId } + listOfNotNull(engineBookId)) - .filter { it != book.id } - .distinct() - val candidates = lookUpInChunks(candidateIds) { audioBookDao.getByIds(it) }.associateBy { it.id } - val withLocalCopy = candidates.values - .filter { it.isDownloaded == 1 && !it.localPath.isNullOrEmpty() } - .mapTo(HashSet()) { it.id } - val otherFolders = unfinishedDownloadOwners(rows, withLocalCopy, book.id, engineBookId) - // No book row means nothing the engine could be writing for it. - .mapNotNull { candidates[it] } - .map { engine.downloadLocationFor(it.toDomain()).folder } + - // The folder the engine really writes to, in case the title moved. - listOfNotNull(engine.activeDownloadDir.takeIf { engineBookId != book.id }) - - val decision = withContext(Dispatchers.IO) { - deleteDownloadFiles(location, downloadedFileNames(book.audioFiles), sharers, otherFolders) - } - when (decision) { + when (val decision = removeDownloadedBookFiles(folderReads, ownership, book, localPath)) { + null -> Unit is CancelCleanupDecision.Keep -> android.util.Log.d("DownloadManager", "delete kept files: ${decision.reason}") is CancelCleanupDecision.Delete -> @@ -915,6 +842,20 @@ class DownloadManager @Inject constructor( } } + /** Room and the engine's path rules, as delete and cancel read them. */ + private val folderReads = object : FolderReads { + override suspend fun book(id: String) = audioBookDao.getById(id)?.toDomain() + override suspend fun books(ids: List) = + lookUpInChunks(ids) { audioBookDao.getByIds(it) }.map { it.toDomain() } + override suspend fun filePathBookIdsExcept(audioBookId: String) = + audioBookDao.getFilePathBookIdsExcept(audioBookId) + override suspend fun localPathsExcept(audioBookId: String) = audioBookDao.getLocalPathsExcept(audioBookId) + override suspend fun downloadRows() = + downloadItemDao.getAll().map { DownloadOwnerRow(it.audioBookId, it.status) } + override fun plannedLocation(book: AudioBook) = engine.downloadLocationFor(book) + override fun storedLocation(localPath: String) = engine.storedDownloadLocation(localPath) + } + /** Check if a book is downloaded. */ suspend fun isBookDownloaded(audioBookId: String): Boolean { val entity = downloadItemDao.getByAudioBookId(audioBookId) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt index cc33bbd..a7a3e25 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt @@ -167,26 +167,6 @@ internal fun decideCancelCleanup( return CancelCleanupDecision.Delete(folder, children.map(Path::toFile)) } -/** - * The last look before a [CancelCleanupDecision.Delete] runs: true only when - * no download row exists for [audioBookId], none would write to [folder], and - * the engine is not on the book ([engineBookId]). [liveRowBookIds] and - * [liveRowFolders] are read fresh right before the delete. A folder that will - * not resolve counts as a match, so any doubt keeps the files. - */ -internal fun cancelCleanupStillClear( - audioBookId: String, - folder: File, - liveRowBookIds: List, - liveRowFolders: List, - engineBookId: String?, -): Boolean { - if (engineBookId == audioBookId) return false - if (audioBookId in liveRowBookIds) return false - val target = canonicalOrNull(folder) ?: return false - return liveRowFolders.none { other -> canonicalOrNull(other)?.let { it == target } ?: true } -} - /** * Carry out a [CancelCleanupDecision.Delete]. Deletes each listed path without * following links, then the folder only if it is empty by then. Never recurses. @@ -380,27 +360,25 @@ internal data class DownloadOwnerRow(val audioBookId: String, val status: Int) * [deleteDownloadFiles]). * * Any row short of Completed counts. So does a Completed row whose book is not - * in [booksWithLocalCopy] (marked downloaded with a stored localPath): the - * engine writes Completed, then fetches the cover, and only then saves the - * localPath, so in that window the book has audio on disk and no stored path - * that would make it a sharer. The book the engine is on ([engineBookId]) - * always counts, row or not. Any doubt keeps the files. + * in [booksWithLocalCopy] (marked downloaded with a stored localPath). The + * engine writes both in one step now, but a row left that way by an older + * build or a process death between the two writes still has audio on disk and + * no stored path that would make it a sharer. Any doubt keeps the files. The + * folder the engine is writing right now is protected separately, see + * [DownloadOwnership]. */ internal fun unfinishedDownloadOwners( rows: List, booksWithLocalCopy: Set, excludeBookId: String, - engineBookId: String?, -): List { - val owners = rows +): List = + rows .filter { row -> row.status != DownloadStatus.Completed.ordinal || row.audioBookId !in booksWithLocalCopy } .map { it.audioBookId } - return (owners + listOfNotNull(engineBookId)) .filter { it != excludeBookId } .distinct() -} /** The cover the engine saves next to the audio. */ private const val COVER_FILE_NAME = "cover.jpg" diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt index 2884f8d..9f2b141 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt @@ -44,6 +44,7 @@ class DownloadEngine @Inject constructor( private val audioBookDao: AudioBookDao, private val api: AudiobookshelfApi, private val settingsManager: SettingsManager, + private val ownership: DownloadOwnership, ) { companion object { private const val TAG = "DownloadEngine" @@ -55,27 +56,28 @@ class DownloadEngine @Inject constructor( * progress to Room throughout and invokes [onProgress] for UI liveliness. * Returns the terminal [DownloadItem] (Completed / Failed / Paused); the * caller owns any completion/failure event emission. + * + * Returns null without touching anything when the row is gone or no longer + * Queued or Downloading by the time the engine claims it. The claim and the + * completion go through [DownloadOwnership], see the rule there. */ - /** - * The folder the running download writes to, from the moment it is picked - * until the book row holds its localPath. Delete treats it as taken. Engine - * runs are serialized (see [runEngineExclusively]), so one slot is enough. - */ - @Volatile - internal var activeDownloadDir: File? = null - private set - suspend fun download( item: DownloadItem, audioBook: AudioBook, onProgress: suspend (downloadId: String, downloaded: Long, total: Long) -> Unit, - ): DownloadItem { + ): DownloadItem? { val downloadDir = getDownloadPath(audioBook) - activeDownloadDir = downloadDir + val claimed = ownership.claim( + downloadId = item.id, + audioBookId = audioBook.id, + folder = downloadDir, + readRow = { downloadItemDao.getById(item.id)?.toDomain() }, + writeRow = { downloadItemDao.upsert(it.toEntity()) }, + ) ?: return null return try { - downloadInto(downloadDir, item, audioBook, onProgress) + downloadInto(downloadDir, claimed, audioBook, onProgress) } finally { - activeDownloadDir = null + ownership.release() } } @@ -85,8 +87,8 @@ class DownloadEngine @Inject constructor( audioBook: AudioBook, onProgress: suspend (downloadId: String, downloaded: Long, total: Long) -> Unit, ): DownloadItem { - var download = item.copy(status = DownloadStatus.Downloading) - downloadItemDao.upsert(download.toEntity()) + // Already written as Downloading by the claim. + var download = item // Create download directory downloadDir.mkdirs() @@ -241,34 +243,41 @@ class DownloadEngine @Inject constructor( } } + // Persist the cover next to the audio so it renders offline. Best-effort: + // a cover failure must never fail an otherwise-complete download. A + // network call, so it runs before the completion commit, not inside it. + val localCoverUri = persistCover(book, downloadDir) + // All files downloaded successfully download = download.copy( status = DownloadStatus.Completed, downloadedBytes = maxOf(downloadedBytes, totalBytes), completedAt = System.currentTimeMillis(), ) - downloadItemDao.upsert(download.toEntity()) - - // Persist the cover next to the audio so it renders offline. Best-effort: - // a cover failure must never fail an otherwise-complete download. - val localCoverUri = persistCover(book, downloadDir) - - // Mark the book downloaded and keep the track list and chapters that were - // just downloaded. List-synced rows hold an empty manifest, and offline - // playback falls back to sorting files by name without one. - val bookEntity = audioBookDao.getById(audioBook.id) - if (bookEntity != null) { - audioBookDao.upsert( - completedDownloadBook( - row = bookEntity.toDomain(), - downloaded = book, - localPath = downloadDir.absolutePath, - localCoverUri = localCoverUri, - ).toEntity() - ) + val completed = download + // The row turns Completed and the book gets its local copy in one step, + // so a delete never sees one without the other. Mark the book downloaded + // and keep the track list and chapters that were just downloaded. + // List-synced rows hold an empty manifest, and offline playback falls + // back to sorting files by name without one. + val committed = ownership.commitCompletion( + rowExists = { downloadItemDao.getById(completed.id) != null }, + ) { + downloadItemDao.upsert(completed.toEntity()) + val bookEntity = audioBookDao.getById(audioBook.id) + if (bookEntity != null) { + audioBookDao.upsert( + completedDownloadBook( + row = bookEntity.toDomain(), + downloaded = book, + localPath = downloadDir.absolutePath, + localCoverUri = localCoverUri, + ).toEntity() + ) + } } - - return download + // Cancelled or deleted while finishing: nothing was written back. + return if (committed) completed else completed.copy(status = DownloadStatus.Cancelled) } /** diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadOwnership.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadOwnership.kt new file mode 100644 index 0000000..0bc94a2 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadOwnership.kt @@ -0,0 +1,213 @@ +package com.ninelivesaudio.app.service.download + +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.DownloadItem +import com.ninelivesaudio.app.domain.model.DownloadStatus +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** The book the engine is downloading right now, and the folder it writes to. */ +internal data class EngineClaim(val audioBookId: String, val downloadId: String, val folder: File) + +/** How [DownloadOwnership.actOnceEngineIsOff] dealt with the engine. */ +internal data class EngineStop( + /** The engine was (or may have been) on the book, so it was stopped first. */ + val wasDownloading: Boolean, + /** The stop was confirmed. False when it timed out or was not needed. */ + val stopConfirmed: Boolean, +) + +/** + * Who owns a download folder, and the one rule that keeps delete and cancel + * from taking another book's files (#49). + * + * The rule: every change to what the engine owns, and every delete or cancel + * decision together with its file removal, runs under [lock]. The engine's + * changes are claiming a row (only while it still exists and is still Queued + * or Downloading) along with the folder it will write, committing completion + * (row Completed and the book's local copy in one step, only while the row + * still exists), and letting the folder go. Delete, cancel and pause act + * through [actOnceEngineIsOff]: with the engine on the book they stop it + * first, outside the lock, then act under it. The folder the engine is + * writing is never deleted, whichever book it belongs to. No network I/O + * ever runs under the lock, and any doubt keeps the files. + * + * Lock order: DownloadManager's row lock and the engine lock come first, this + * one innermost. + */ +@Singleton +class DownloadOwnership @Inject constructor() { + private val lock = Mutex() + + @Volatile + private var current: EngineClaim? = null + + /** What the engine holds right now. Only stable under the lock. */ + internal val engineClaim: EngineClaim? get() = current + + /** The download row the engine is on, for the Wi-Fi only rule. A plain read, no lock. */ + val engineDownloadId: String? get() = current?.downloadId + + /** + * The engine claims [downloadId] and [folder]: under the lock it reads the + * row fresh and, only while it is still Queued or Downloading, writes it + * back as Downloading. Returns that row, or null when the row is gone or + * moved on, in which case nothing is written and nothing is claimed. A + * cancelled engine never claims. + */ + internal suspend fun claim( + downloadId: String, + audioBookId: String, + folder: File, + readRow: suspend () -> DownloadItem?, + writeRow: suspend (DownloadItem) -> Unit, + ): DownloadItem? = lock.withLock { + currentCoroutineContext().ensureActive() + val fresh = readRow() + ?.takeIf { it.status == DownloadStatus.Queued || it.status == DownloadStatus.Downloading } + ?: return@withLock null + val downloading = fresh.copy(status = DownloadStatus.Downloading) + writeRow(downloading) + current = EngineClaim(audioBookId, downloadId, folder) + downloading + } + + /** + * The engine commits a finished download: [commit] (the Completed row and + * the book's local copy) runs under the lock, and only while [rowExists]. + * False when the download was cancelled or deleted meanwhile. + */ + internal suspend fun commitCompletion(rowExists: suspend () -> Boolean, commit: suspend () -> Unit): Boolean = + lock.withLock { + if (!rowExists()) return@withLock false + commit() + true + } + + /** The engine lets its folder go. Runs even when the engine was cancelled. */ + internal suspend fun release() { + withContext(NonCancellable) { lock.withLock { current = null } } + } + + /** + * Run [act] (a delete, cancel or pause of [audioBookId]) under the lock, + * once the engine is off the book. + * + * When the engine is not on the book and its row is not Downloading, [act] + * runs in the same lock section as that check, so the engine cannot claim + * the book halfway through. Otherwise [stopEngine] runs first, outside the + * lock (the engine needs the lock to let go), and [act] after it. Nothing + * can claim the book again in between: the stopped drain is cancelled, a + * cancelled engine never claims, and callers hold the row lock that every + * drain start needs. + */ + internal suspend fun actOnceEngineIsOff( + audioBookId: String, + rowIsDownloading: suspend () -> Boolean, + stopEngine: suspend () -> Boolean, + act: suspend (EngineStop) -> T, + ): T { + lock.withLock { + if (current?.audioBookId != audioBookId && !rowIsDownloading()) { + return act(EngineStop(wasDownloading = false, stopConfirmed = false)) + } + } + val confirmed = stopEngine() + return lock.withLock { act(EngineStop(wasDownloading = true, stopConfirmed = confirmed)) } + } +} + +/** + * What delete and cancel read to judge a folder. DownloadManager backs it with + * Room and the engine's path rules. + */ +internal interface FolderReads { + suspend fun book(id: String): AudioBook? + /** Books by id, any order, missing ones left out. */ + suspend fun books(ids: List): List + /** Ids of other books stored at a file path. See `AudioBookDao.getFilePathBookIdsExcept`. */ + suspend fun filePathBookIdsExcept(audioBookId: String): List + /** Every other book's stored localPath. */ + suspend fun localPathsExcept(audioBookId: String): List + suspend fun downloadRows(): List + /** Where [book] would download to under the current settings. */ + fun plannedLocation(book: AudioBook): DownloadLocation + /** Where a stored localPath sits, or null when the engine never wrote it. */ + fun storedLocation(localPath: String): DownloadLocation? +} + +/** + * Delete [book]'s downloaded files from [localPath] and nothing else (#49). + * Call it inside [DownloadOwnership.actOnceEngineIsOff], so what it reads + * cannot change before the files go. + * + * Two editions with the same author and title share one folder, so the other + * books stored there come along with their track lists, and only this book's + * own files go. A folder another download may still be writing keeps + * everything: any unfinished row's folder (see [unfinishedDownloadOwners]) and + * the folder the engine is really writing, even when a title change moved + * where its row says it goes. The rules live in [deleteDownloadFiles]. + */ +internal suspend fun removeDownloadedBookFiles( + reads: FolderReads, + ownership: DownloadOwnership, + book: AudioBook, + localPath: String, +): CancelCleanupDecision? { + val location = reads.storedLocation(localPath) ?: return null + val sharers = reads.books(reads.filePathBookIdsExcept(book.id)).map { other -> + FolderSharer( + path = other.localPath.orEmpty(), + fileNames = other.audioFiles.takeIf { it.isNotEmpty() }?.let(::downloadedFileNames), + ) + } + val claim = ownership.engineClaim + val rows = reads.downloadRows() + val candidates = reads.books(rows.map { it.audioBookId }.filter { it != book.id }.distinct()) + .associateBy { it.id } + val withLocalCopy = candidates.values + .filter { it.isDownloaded && !it.localPath.isNullOrEmpty() } + .mapTo(HashSet()) { it.id } + val otherFolders = unfinishedDownloadOwners(rows, withLocalCopy, book.id) + // No book row means nothing the engine could be writing for it. + .mapNotNull { candidates[it] } + .map { reads.plannedLocation(it).folder } + + listOfNotNull(claim?.folder) + return deleteDownloadFiles(location, downloadedFileNames(book.audioFiles), sharers, otherFolders) +} + +/** + * Delete the partial folder a cancelled download of [audioBookId] left behind + * (#51), after its row is gone. Call it inside + * [DownloadOwnership.actOnceEngineIsOff], so what it reads cannot change before + * the files go. The rules live in [decideCancelCleanup] and keep the files on + * any doubt, including when another book or download points at the same + * folder, or the engine is really writing there (whatever its row now says). + */ +internal suspend fun removeCancelledPartialFiles( + reads: FolderReads, + ownership: DownloadOwnership, + audioBookId: String, +): CancelCleanupDecision? { + val claim = ownership.engineClaim + // The engine still on this book means its stop never confirmed. + if (claim?.audioBookId == audioBookId) return CancelCleanupDecision.Keep(CancelKeepReason.SHARED) + val book = reads.book(audioBookId) ?: return null + val location = reads.plannedLocation(book) + val otherBookPaths = reads.localPathsExcept(audioBookId) + // Every download row still around, mapped to the folder it would write to. + val rowBookIds = reads.downloadRows().map { it.audioBookId }.distinct() + val otherFolders = reads.books(rowBookIds).map { reads.plannedLocation(it).folder } + listOfNotNull(claim?.folder) + val bookIsDownloaded = book.isDownloaded || !book.localPath.isNullOrEmpty() + + val decision = decideCancelCleanup(location, bookIsDownloaded, otherBookPaths, otherFolders) + if (decision is CancelCleanupDecision.Delete) applyCancelCleanup(decision) + return decision +} diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadQueueWorker.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadQueueWorker.kt index 27fa39e..71f7d03 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadQueueWorker.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadQueueWorker.kt @@ -131,16 +131,12 @@ class DownloadQueueWorker( // get dropped and the bar appears frozen / far behind. var lastNotifiedPercent = -1 // One engine at a time across drains: a replacement waits here until - // a cancelled drain's engine has really exited. Cancel cleanup checks - // the book recorded on entry last thing before deleting files. - val result = manager.runEngine(item.audioBookId, item.id) { - // The wait can outlast an old engine or a user action, so the row - // is read again and only a still downloadable one goes ahead. - val fresh = dao.getById(item.id)?.toDomain() - ?.takeIf { it.status == DownloadStatus.Queued || it.status == DownloadStatus.Downloading } - ?: return@runEngine null + // a cancelled drain's engine has really exited. The wait can outlast + // an old engine or a user action, so the engine claims the row only + // if it is still downloadable, and returns null otherwise. + val result = manager.runEngine { withContext(Dispatchers.IO) { - engine.download(fresh, book) { id, downloaded, total -> + engine.download(item, book) { id, downloaded, total -> manager.publishProgress(id, downloaded, total) val percent = if (total > 0) { ((downloaded.toDouble() / total) * 100).toInt() diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadWork.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadWork.kt index 26ab4de..7450a9e 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadWork.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadWork.kt @@ -31,24 +31,6 @@ fun selectNextDownload(items: List): DownloadItem? = .thenBy { it.startedAt ?: Long.MAX_VALUE } ) -/** - * Run a user's pause, cancel or delete write so the engine cannot undo it. - * - * DownloadEngine upserts a stale Downloading snapshot on every progress tick - * and marks the book downloaded when it finishes. A write that lands while it - * is still streaming this book gets clobbered straight back. So when the - * engine is on this book, [stopEngine] (cancel plus confirmed termination) - * runs first, and only then [write]. - */ -internal suspend fun writeAfterEngineStops( - engineActive: Boolean, - stopEngine: suspend () -> Unit, - write: suspend () -> T, -): T { - if (engineActive) stopEngine() - return write() -} - /** * Run the engine with [lock] held, bracketed by [onStart] and [onStop]. * diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/CancelCleanupTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/CancelCleanupTest.kt index 06b8191..e4e255f 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/download/CancelCleanupTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/CancelCleanupTest.kt @@ -272,46 +272,6 @@ class CancelCleanupTest { assertFalse(cancelMayTouchFiles(DownloadStatus.Completed.ordinal, wasDownloading = false, stopConfirmed = false)) } - // ─── Last look before deleting ─────────────────────────────────────────── - - @Test - fun `cleanup goes ahead only when no row and no engine came back for the book`() { - val root = root() - val folder = bookFolder(root, "01.mp3.part") - val elsewhere = File(root, "Other Author - Other Book") - val book = "moby" - - assertTrue(cancelCleanupStillClear(book, folder, listOf("other"), listOf(elsewhere), engineBookId = "other")) - assertTrue(cancelCleanupStillClear(book, folder, emptyList(), emptyList(), engineBookId = null)) - } - - @Test - fun `a resumed row for the book keeps its files`() { - val root = root() - val folder = bookFolder(root, "01.mp3.part") - - // Resume wrote the row back between the snapshot and the delete. - assertFalse(cancelCleanupStillClear("moby", folder, listOf("moby"), listOf(folder), engineBookId = null)) - } - - @Test - fun `another row writing to the same folder keeps its files`() { - val root = root() - val folder = bookFolder(root, "01.mp3.part") - - assertFalse( - cancelCleanupStillClear("moby", folder, listOf("twin"), listOf(File(root, folder.name)), engineBookId = null), - ) - } - - @Test - fun `the engine running on the book keeps its files`() { - val root = root() - val folder = bookFolder(root, "01.mp3.part") - - assertFalse(cancelCleanupStillClear("moby", folder, emptyList(), emptyList(), engineBookId = "moby")) - } - // ─── Root resolution ───────────────────────────────────────────────────── @Test diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt index 76ab038..17778b4 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt @@ -248,25 +248,13 @@ class DownloadDeleteTest { @Test fun `an edition whose row says Completed before its local copy is saved still owns the folder`() { - // The engine writes Completed, then fetches the cover, then saves - // localPath. Deleting the other edition in that window must not - // count this one as finished. + // An older build wrote Completed before the localPath, and a process + // death between the two leaves it that way. Deleting the other + // edition must not count this one as finished. val owners = unfinishedDownloadOwners( rows = listOf(row("A", DownloadStatus.Completed), row("B", DownloadStatus.Completed)), booksWithLocalCopy = setOf("A"), excludeBookId = "A", - engineBookId = null, - ) - assertEquals(listOf("B"), owners) - } - - @Test - fun `the book the engine is on owns its folder even with no row`() { - val owners = unfinishedDownloadOwners( - rows = emptyList(), - booksWithLocalCopy = emptySet(), - excludeBookId = "A", - engineBookId = "B", ) assertEquals(listOf("B"), owners) } @@ -283,7 +271,6 @@ class DownloadDeleteTest { ), booksWithLocalCopy = setOf("D"), excludeBookId = "A", - engineBookId = "A", ) assertEquals(listOf("B", "C", "E"), owners) } @@ -296,7 +283,6 @@ class DownloadDeleteTest { rows = listOf(row("B", DownloadStatus.Completed)), booksWithLocalCopy = emptySet(), excludeBookId = "A", - engineBookId = "B", ) // B has no localPath yet, so it is not a sharer. Only its ownership protects it. val ownerFolders = owners.map { folder } diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadOwnershipTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadOwnershipTest.kt new file mode 100644 index 0000000..67d38e4 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadOwnershipTest.kt @@ -0,0 +1,351 @@ +package com.ninelivesaudio.app.service.download + +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.AudioFile +import com.ninelivesaudio.app.domain.model.DownloadItem +import com.ninelivesaudio.app.domain.model.DownloadStatus +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Who owns a download folder changes only under one lock, and delete and + * cancel judge and remove files under that same lock (#49). Each race here is + * driven step by step: a hook inside one side's reads starts the other side + * and lets it run as far as it can before the first side carries on. All of + * it runs on one thread, so the interleaving is the same on every run. + */ +class DownloadOwnershipTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private fun track(name: String, index: Int = 0) = AudioFile(id = name, ino = name, index = index, filename = name) + + /** Room and the path rules, in memory. Every book folder is "Author - Title" under [root]. */ + private class FakeReads(val root: File) : FolderReads { + val books = mutableMapOf() + val rows = mutableMapOf() + var afterSharerIdsRead: (suspend () -> Unit)? = null + + override suspend fun book(id: String) = books[id] + override suspend fun books(ids: List) = ids.mapNotNull { books[it] } + override suspend fun filePathBookIdsExcept(audioBookId: String): List { + val ids = books.values + .filter { it.id != audioBookId && !it.localPath.isNullOrEmpty() && !it.localPath.startsWith("content:") } + .map { it.id } + afterSharerIdsRead?.invoke() + return ids + } + override suspend fun localPathsExcept(audioBookId: String) = + books.values.filter { it.id != audioBookId }.mapNotNull { it.localPath?.takeIf(String::isNotEmpty) } + override suspend fun downloadRows() = rows.values.map { DownloadOwnerRow(it.audioBookId, it.status.ordinal) } + override fun plannedLocation(book: AudioBook) = + DownloadLocation(root, File(root, downloadFolderName(book.author, book.title, book.id)), true) + override fun storedLocation(localPath: String) = deleteLocationFor(localPath, "", root) + } + + private fun folder(root: File, name: String, vararg files: String) = File(root, name).apply { + mkdirs() + files.forEach { File(this, it).writeBytes(byteArrayOf(1, 2, 3)) } + } + + private suspend fun DownloadOwnership.claimFor(reads: FakeReads, downloadId: String, folder: File) = + claim( + downloadId = downloadId, + audioBookId = reads.rows[downloadId]?.audioBookId ?: downloadId.removePrefix("d"), + folder = folder, + readRow = { reads.rows[downloadId] }, + writeRow = { reads.rows[it.id] = it }, + ) + + /** The engine's completion from its in-memory [row], as DownloadEngine writes it. */ + private suspend fun DownloadOwnership.finish(reads: FakeReads, row: DownloadItem, folder: File): Boolean { + val committed = commitCompletion(rowExists = { row.id in reads.rows }) { + reads.rows[row.id] = row.copy(status = DownloadStatus.Completed) + val book = reads.books.getValue(row.audioBookId) + reads.books[book.id] = book.copy(isDownloaded = true, localPath = folder.absolutePath) + } + release() + return committed + } + + private suspend fun DownloadOwnership.noEngineOn(audioBookId: String, act: suspend () -> Unit) = + actOnceEngineIsOff(audioBookId, rowIsDownloading = { false }, stopEngine = { error("engine was not on $audioBookId") }) { + act() + } + + // ─── Reported races ────────────────────────────────────────────────────── + + @Test + fun `deleting one edition while the other commits its finish keeps the other's audio`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val shared = folder(root, "Herman Melville - Moby-Dick", "a1.mp3", "b1.mp3", "cover.jpg") + val reads = FakeReads(root) + val a = AudioBook( + id = "A", title = "Moby-Dick", author = "Herman Melville", + isDownloaded = true, localPath = shared.absolutePath, audioFiles = listOf(track("a1.mp3")), + ) + reads.books["A"] = a + reads.books["B"] = a.copy(id = "B", isDownloaded = false, localPath = null, audioFiles = listOf(track("b1.mp3"))) + reads.rows["dB"] = DownloadItem(id = "dB", audioBookId = "B", status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + val engineRow = ownership.claimFor(reads, "dB", shared)!! + + // B finishes right after delete has looked for books stored there + // (B has no localPath yet, so it is not one of them). + var finish: Job? = null + reads.afterSharerIdsRead = { + reads.afterSharerIdsRead = null + finish = launch { ownership.finish(reads, engineRow, shared) } + yield() + } + ownership.noEngineOn("A") { removeDownloadedBookFiles(reads, ownership, a, shared.absolutePath) } + finish?.join() + + assertTrue("B's audio went with A's delete", File(shared, "b1.mp3").exists()) + } + + @Test + fun `cancelling a paused book never deletes the folder the engine is really writing`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + // A paused here with a partial track. B started into the same folder, + // then a sync renamed B, so its row now names another folder while its + // engine keeps writing to this one. + val shared = folder(root, "Herman Melville - Moby-Dick", "01.mp3", "b-01.mp3.part") + val reads = FakeReads(root) + reads.books["A"] = AudioBook(id = "A", title = "Moby-Dick", author = "Herman Melville") + reads.books["B"] = AudioBook(id = "B", title = "Moby-Dick", author = "Herman Melville") + reads.rows["dA"] = DownloadItem(id = "dA", audioBookId = "A", status = DownloadStatus.Paused) + reads.rows["dB"] = DownloadItem(id = "dB", audioBookId = "B", status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + assertNotNull(ownership.claimFor(reads, "dB", shared)) + reads.books["B"] = reads.books.getValue("B").copy(title = "Moby-Dick (Unabridged)") + + ownership.noEngineOn("A") { + reads.rows.remove("dA") + removeCancelledPartialFiles(reads, ownership, "A") + } + + assertTrue("B's in-progress track went with A's cancel", File(shared, "b-01.mp3.part").exists()) + } + + @Test + fun `deleting an edition never deletes the folder the engine is really writing`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val shared = folder(root, "Herman Melville - Moby-Dick", "a1.mp3", "b1.mp3.part") + val reads = FakeReads(root) + val a = AudioBook( + id = "A", title = "Moby-Dick", author = "Herman Melville", + isDownloaded = true, localPath = shared.absolutePath, audioFiles = listOf(track("a1.mp3")), + ) + reads.books["A"] = a + reads.books["B"] = AudioBook(id = "B", title = "Moby-Dick", author = "Herman Melville") + reads.rows["dB"] = DownloadItem(id = "dB", audioBookId = "B", status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + assertNotNull(ownership.claimFor(reads, "dB", shared)) + // A sync renamed B while its engine keeps writing here. + reads.books["B"] = reads.books.getValue("B").copy(title = "Moby-Dick (Unabridged)") + + ownership.noEngineOn("A") { removeDownloadedBookFiles(reads, ownership, a, shared.absolutePath) } + + assertTrue("B's in-progress track went with A's delete", File(shared, "b1.mp3.part").exists()) + } + + @Test + fun `a queued download cancelled as the engine picks it never comes back`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + reads.rows["dA"] = DownloadItem(id = "dA", audioBookId = "A", status = DownloadStatus.Queued) + reads.rows["dC"] = DownloadItem(id = "dC", audioBookId = "C", status = DownloadStatus.Queued, startedAt = 2) + val ownership = DownloadOwnership() + var stopRequested = false + + // The user cancels A right as the engine reads its row. C keeps the + // drain alive, so nothing else stops it. + var cancel: Job? = null + val claimed = ownership.claim( + downloadId = "dA", + audioBookId = "A", + folder = File(root, "A"), + readRow = { + val row = reads.rows["dA"] + cancel = launch { + ownership.actOnceEngineIsOff( + audioBookId = "A", + rowIsDownloading = { reads.rows["dA"]?.status == DownloadStatus.Downloading }, + stopEngine = { stopRequested = true; true }, + ) { reads.rows.remove("dA") } + } + yield() + row + }, + writeRow = { reads.rows[it.id] = it }, + ) + cancel?.join() + + assertNull("the cancelled row came back", reads.rows["dA"]) + assertTrue("the engine kept downloading a cancelled book", claimed == null || stopRequested) + } + + @Test + fun `a queued download cancelled before the engine claims it never starts`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + reads.rows["dA"] = DownloadItem(id = "dA", audioBookId = "A", status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + + // The drain picked A from an older read, and the cancel got in first. + ownership.noEngineOn("A") { reads.rows.remove("dA") } + val claimed = ownership.claimFor(reads, "dA", File(root, "A")) + + assertNull(claimed) + assertNull(reads.rows["dA"]) + assertNull(ownership.engineClaim) + } + + @Test + fun `a download deleted while it finishes is not marked downloaded`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val dir = folder(root, "Herman Melville - Moby-Dick", "01.mp3") + val reads = FakeReads(root) + reads.books["A"] = AudioBook(id = "A", title = "Moby-Dick", author = "Herman Melville") + reads.rows["dA"] = DownloadItem(id = "dA", audioBookId = "A", status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + val engineRow = ownership.claimFor(reads, "dA", dir)!! + + // The stop timed out, the row went anyway, and then the engine finished. + reads.rows.remove("dA") + val committed = ownership.finish(reads, engineRow, dir) + + assertFalse(committed) + assertNull(reads.rows["dA"]) + assertNull(reads.books.getValue("A").localPath) + } + + @Test + fun `cancel keeps the files when the stop never confirmed`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val dir = folder(root, "Herman Melville - Moby-Dick", "01.mp3", "02.mp3.part") + val reads = FakeReads(root) + reads.books["A"] = AudioBook(id = "A", title = "Moby-Dick", author = "Herman Melville") + reads.rows["dA"] = DownloadItem(id = "dA", audioBookId = "A", status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + assertNotNull(ownership.claimFor(reads, "dA", dir)) + + // A read stuck on a stalled server: the stop gives up, the engine stays. + ownership.actOnceEngineIsOff("A", rowIsDownloading = { true }, stopEngine = { false }) { + reads.rows.remove("dA") + removeCancelledPartialFiles(reads, ownership, "A") + } + + assertTrue(File(dir, "02.mp3.part").exists()) + } + + // ─── Everyday paths still work ─────────────────────────────────────────── + + @Test + fun `deleting a book nobody shares removes its folder`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val dir = folder(root, "Herman Melville - Moby-Dick", "01.mp3", "02.mp3", "cover.jpg") + val reads = FakeReads(root) + val a = AudioBook( + id = "A", title = "Moby-Dick", author = "Herman Melville", isDownloaded = true, + localPath = dir.absolutePath, audioFiles = listOf(track("01.mp3", 0), track("02.mp3", 1)), + ) + reads.books["A"] = a + val ownership = DownloadOwnership() + + ownership.noEngineOn("A") { removeDownloadedBookFiles(reads, ownership, a, dir.absolutePath) } + + assertFalse(dir.exists()) + } + + @Test + fun `cancelling a paused book nobody shares removes its partial folder`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val dir = folder(root, "Herman Melville - Moby-Dick", "01.mp3", "02.mp3.part") + val reads = FakeReads(root) + reads.books["A"] = AudioBook(id = "A", title = "Moby-Dick", author = "Herman Melville") + reads.rows["dA"] = DownloadItem(id = "dA", audioBookId = "A", status = DownloadStatus.Paused) + val ownership = DownloadOwnership() + + ownership.noEngineOn("A") { + reads.rows.remove("dA") + removeCancelledPartialFiles(reads, ownership, "A") + } + + assertFalse(dir.exists()) + } + + @Test + fun `deleting a book while an unrelated one downloads goes ahead at once`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val dir = folder(root, "Herman Melville - Moby-Dick", "01.mp3") + val other = folder(root, "Jane Austen - Emma", "01.mp3.part") + val reads = FakeReads(root) + val a = AudioBook( + id = "A", title = "Moby-Dick", author = "Herman Melville", isDownloaded = true, + localPath = dir.absolutePath, audioFiles = listOf(track("01.mp3")), + ) + reads.books["A"] = a + reads.books["E"] = AudioBook(id = "E", title = "Emma", author = "Jane Austen") + reads.rows["dE"] = DownloadItem(id = "dE", audioBookId = "E", status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + assertNotNull(ownership.claimFor(reads, "dE", other)) + + // The engine stays on E the whole time and never lets go. + withTimeout(2_000) { + ownership.noEngineOn("A") { removeDownloadedBookFiles(reads, ownership, a, dir.absolutePath) } + } + + assertFalse(dir.exists()) + assertTrue(File(other, "01.mp3.part").exists()) + } + + @Test + fun `the engine on the book is stopped outside the lock so it can let go`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + reads.rows["dA"] = DownloadItem(id = "dA", audioBookId = "A", status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + assertNotNull(ownership.claimFor(reads, "dA", File(root, "A"))) + + // The stop waits for the engine to exit, and exiting releases the claim. + val stop = withTimeout(2_000) { + ownership.actOnceEngineIsOff( + audioBookId = "A", + rowIsDownloading = { false }, + stopEngine = { ownership.release(); true }, + ) { it } + } + + assertEquals(EngineStop(wasDownloading = true, stopConfirmed = true), stop) + assertNull(ownership.engineClaim) + } + + @Test + fun `a paused or finished row is never claimed`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + reads.rows["dP"] = DownloadItem(id = "dP", audioBookId = "P", status = DownloadStatus.Paused) + reads.rows["dD"] = DownloadItem(id = "dD", audioBookId = "D", status = DownloadStatus.Completed) + val ownership = DownloadOwnership() + + assertNull(ownership.claimFor(reads, "dP", File(root, "P"))) + assertNull(ownership.claimFor(reads, "dD", File(root, "D"))) + assertEquals(DownloadStatus.Paused, reads.rows.getValue("dP").status) + assertNull(ownership.engineClaim) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/UserDownloadWriteOrderTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/UserDownloadWriteOrderTest.kt deleted file mode 100644 index 21579d4..0000000 --- a/app/src/test/java/com/ninelivesaudio/app/service/download/UserDownloadWriteOrderTest.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.ninelivesaudio.app.service.download - -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Pause, cancel and delete on the book the engine is streaming must stop the - * engine BEFORE writing. DownloadEngine upserts a stale Downloading snapshot - * about ten times a second, so a write that lands first gets clobbered by the - * next progress tick (a cancelled row comes back, a paused one keeps going). - */ -class UserDownloadWriteOrderTest { - - @Test - fun `an active download is stopped before the user's write lands`() = runBlocking { - val events = mutableListOf() - writeAfterEngineStops( - engineActive = true, - stopEngine = { events += "stop" }, - write = { events += "write" }, - ) - assertEquals(listOf("stop", "write"), events) - } - - @Test - fun `an idle row is written without stopping the drain`() = runBlocking { - val events = mutableListOf() - writeAfterEngineStops( - engineActive = false, - stopEngine = { events += "stop" }, - write = { events += "write" }, - ) - assertEquals(listOf("write"), events) - } -} From b1a6764df153437e5e321912a2f32d2e74352bfa Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 13:42:18 -0400 Subject: [PATCH 18/23] The grouped Library no longer crashes while a big library syncs On a big library the shelf read spans several cursor windows, and each window was read afresh. A sync page saved in between gives the re-saved books new rowids, so they came back again at the end of the read (and as many other books went missing). A book twice in one group is two list rows with one key, and the grouped list crashed when they scrolled into view. The shelf now reads in one transaction, so it is one snapshot. The shelf also keeps each book once whatever the read returns, the grouped list drops any repeated row instead of crashing, and a row key can no longer run two group and book names together. Author, series and genre names that differ only in capitals or spacing ("John Smith, john smith") are one group, so a co-authored book is listed once per author and no two groups share a key. Co-Authored-By: Claude Opus 5.5 --- .../app/data/local/dao/AudioBookDao.kt | 8 + .../app/ui/library/LibraryScreen.kt | 7 +- .../app/ui/library/LibraryViewModel.kt | 58 ++++++- .../app/ui/library/LibraryDuplicateRowTest.kt | 157 ++++++++++++++++++ 4 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryDuplicateRowTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt index 2eb1e4a..ae9afdd 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.RawQuery +import androidx.room.Transaction import androidx.sqlite.db.SupportSQLiteQuery import com.ninelivesaudio.app.data.local.entity.AudioBookEntity import com.ninelivesaudio.app.data.local.entity.AutoBrowseRow @@ -346,7 +347,14 @@ interface AudioBookDao { /** * Dynamic filtered shelf query, built by AudioBookRepository.getFilteredBooks(). * Light rows only (see [ShelfBookRow]), never `ab.*`. + * + * One transaction, so the whole read is one snapshot. A big shelf spans + * several cursor windows, and each window outside a transaction is read + * afresh: a sync page saved in between (REPLACE gives a re-saved book a + * new rowid, the end of this unordered read) came back twice, with + * another book missing, and the grouped list crashed on the repeat. */ + @Transaction @RawQuery(observedEntities = [AudioBookEntity::class, PlaybackProgressEntity::class]) suspend fun getFilteredBooks(query: SupportSQLiteQuery): List diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryScreen.kt index 62d5af3..4fd467a 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryScreen.kt @@ -313,12 +313,7 @@ fun LibraryScreen( // Grouped expandable list in SERIES / AUTHOR / GENRE modes itemsIndexed( items = uiState.groupedListItems, - key = { _, item -> - when (item) { - is LibraryListItem.GroupHeader -> "header-${item.groupKey}" - is LibraryListItem.BookRow -> "book-${item.groupKey}-${item.book.id}" - } - }, + key = { _, item -> item.listKey }, ) { index, item -> when (item) { is LibraryListItem.GroupHeader -> GroupHeaderRow( diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt index 652ce1d..dacc38d 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt @@ -79,6 +79,17 @@ sealed class LibraryListItem { ) : LibraryListItem() } +/** + * The grouped list's LazyColumn key for this row. A row's key carries the + * group key's length, so a group "Smith-Jones" with book "x" and a group + * "Smith" with book "Jones-x" can never run together into one key. + */ +internal val LibraryListItem.listKey: String + get() = when (this) { + is LibraryListItem.GroupHeader -> "header-$groupKey" + is LibraryListItem.BookRow -> "book-${groupKey.length}-$groupKey-${book.id}" + } + data class GroupedSection( val key: String, val title: String, @@ -1395,7 +1406,9 @@ internal suspend fun arrangeShelf( val sort = FreeTier.effectiveSort(storedSort, isUnlocked) val viewMode = FreeTier.effectiveViewMode(storedViewMode, isUnlocked) currentCoroutineContext().ensureActive() - val sorted = sortBooks(books, sort) + // One row per book, whatever the read returned. A book twice is two list + // rows with one key, and the list crashes on the second. + val sorted = sortBooks(withoutRepeatedIds(books), sort) currentCoroutineContext().ensureActive() return ArrangedShelf( books = sorted, @@ -1418,10 +1431,19 @@ internal fun buildGroupedSections( if (viewMode == ViewMode.ALL) return emptyList() // Genre view uses multi-placement: a book appears in every genre group it belongs to. + // Names that differ only in capitals or spacing ("John Smith", "john smith") + // are one group, named by the first spelling seen. So no two groups share + // a key, and a book is in each group at most once. val grouped = linkedMapOf>() + val keyByFoldedName = HashMap() + val bookKeys = LinkedHashSet() books.forEach { book -> - val keys = groupingKeysForBook(book, viewMode) - keys.forEach { key -> grouped.getOrPut(key) { mutableListOf() }.add(book) } + bookKeys.clear() + groupingKeysForBook(book, viewMode).forEach { name -> + val spelling = collapseSpaces(name) + if (spelling.isNotEmpty()) bookKeys += keyByFoldedName.getOrPut(spelling.lowercase()) { spelling } + } + bookKeys.forEach { key -> grouped.getOrPut(key) { mutableListOf() }.add(book) } } val sections = grouped.entries @@ -1497,15 +1519,22 @@ internal fun toggledGroupChoices( /** * Headers and rows for a grouped view. A group the user expanded or collapsed * by hand ([choices]) stays that way, any other follows [groupsStartExpanded]. + * + * Every row has its own [listKey]: a repeat (which the groups should never + * hold) is dropped and the first kept, so a stray one cannot crash the list. */ internal fun flattenGroupedItems( groupedSections: List, choices: Map, ): List = buildList { val startExpanded = groupsStartExpanded(groupedSections.size) + val usedKeys = HashSet() + fun addOnce(item: LibraryListItem) { + if (usedKeys.add(item.listKey)) add(item) + } groupedSections.forEach { section -> val expanded = choices[section.key] ?: startExpanded - add( + addOnce( LibraryListItem.GroupHeader( groupKey = section.key, title = section.title, @@ -1514,7 +1543,26 @@ internal fun flattenGroupedItems( ) ) if (expanded) { - section.books.forEach { add(LibraryListItem.BookRow(groupKey = section.key, book = it)) } + section.books.forEach { addOnce(LibraryListItem.BookRow(groupKey = section.key, book = it)) } + } + } +} + +/** [books] with each id once, the first kept, in order. */ +internal fun withoutRepeatedIds(books: List): List { + val seen = HashSet(books.size * 2) + return books.filter { seen.add(it.id) } +} + +/** [name] trimmed, with each run of spaces inside it made one space. */ +private fun collapseSpaces(name: String): String { + val trimmed = name.trim() + return buildString(trimmed.length) { + var lastWasSpace = false + for (c in trimmed) { + val isSpace = c.isWhitespace() + if (!isSpace) append(c) else if (!lastWasSpace) append(' ') + lastWasSpace = isSpace } } } diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryDuplicateRowTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryDuplicateRowTest.kt new file mode 100644 index 0000000..dc3b8a2 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryDuplicateRowTest.kt @@ -0,0 +1,157 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.domain.model.AudioBook +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The grouped Library crashed with "Key book-Caleb Ibarra-li_mock_00105 was + * already used" while a big library synced. The shelf read spans several + * cursor windows, and a sync page saved between two of them moved re-saved + * books to the end of the read, so the read returned them twice. One book + * twice in a group is two list rows with one key, and the list throws. + */ +class LibraryDuplicateRowTest { + + @Test + fun `a book the shelf read returned twice shows once`() = runBlocking { + // What the read returns when a sync page lands between cursor + // windows: the re-saved book again at the end, identical. + val caleb = book("li_mock_00105", "Mockbook 0105 Ashen Tide Hollow", "Caleb Ibarra") + val stored = listOf( + caleb, + book("li_mock_00106", "Mockbook 0106 Iron Saint", "Mara Vance"), + book("li_mock_00412", "Mockbook 0412 Glass River", "Caleb Ibarra"), + caleb, + ) + + val shelf = arrangeShelf(stored, SortMode.TITLE_AZ, ViewMode.AUTHOR, isUnlocked = true) + val items = flattenGroupedItems(shelf.sections, mapOf("Caleb Ibarra" to true, "Mara Vance" to true)) + + assertEquals(listOf("li_mock_00105", "li_mock_00106", "li_mock_00412"), shelf.books.map { it.id }) + assertNoRepeatedRows(items) + val calebHeader = items.filterIsInstance().single { it.groupKey == "Caleb Ibarra" } + assertEquals(2, calebHeader.count) + } + + @Test + fun `an author line that repeats a name lists the book once`() { + val books = listOf( + book("a", "A", "John Smith, John Smith"), + book("b", "B", "John Smith, john smith"), + book("c", "C", " John Smith , JOHN SMITH"), + ) + + val sections = buildGroupedSections(books, ViewMode.AUTHOR, SortMode.TITLE_AZ) + + assertEquals(listOf("John Smith"), sections.map { it.key }) + assertEquals(listOf("a", "b", "c"), sections.single().books.map { it.id }) + assertNoRepeatedRows(flattenGroupedItems(sections, mapOf("John Smith" to true))) + } + + @Test + fun `names that differ only in capitals or spacing are one group`() { + val books = listOf( + book("a", "A", "Ursula K. Le Guin"), + book("b", "B", "ursula k. le guin"), + book("c", "C", "Ursula K. Le Guin, Neil Gaiman"), + book("d", "D", "NEIL GAIMAN"), + ) + + val sections = buildGroupedSections(books, ViewMode.AUTHOR, SortMode.TITLE_AZ) + + // The first spelling seen names the group. + assertEquals(listOf("Neil Gaiman", "Ursula K. Le Guin"), sections.map { it.key }) + assertEquals(listOf("c", "d"), sections[0].books.map { it.id }) + assertEquals(listOf("a", "b", "c"), sections[1].books.map { it.id }) + } + + @Test + fun `genre and series variants never make two groups with one key`() { + val books = listOf( + book("a", "A", "X", genres = listOf("Fantasy", "fantasy ", "Sci Fi")), + book("b", "B", "X", genres = listOf(" FANTASY"), series = "The Expanse"), + book("c", "C", "X", series = "the expanse"), + ) + + for (mode in listOf(ViewMode.GENRE, ViewMode.SERIES, ViewMode.AUTHOR)) { + val sections = buildGroupedSections(books, mode, SortMode.TITLE_AZ) + assertEquals("$mode keys", sections.map { it.key }.distinct(), sections.map { it.key }) + sections.forEach { section -> + assertEquals("$mode ${section.key}", section.books.map { it.id }.distinct(), section.books.map { it.id }) + } + } + assertEquals( + listOf("Fantasy", "Sci Fi", "Uncategorized Genre"), + buildGroupedSections(books, ViewMode.GENRE, SortMode.TITLE_AZ).map { it.key }, + ) + } + + @Test + fun `a stray repeat in the groups never repeats a list row`() { + val one = AudioBook(id = "one", title = "One") + val two = AudioBook(id = "two", title = "Two") + val sections = listOf( + GroupedSection(key = "Ann", title = "Ann", books = listOf(one, one, two)), + GroupedSection(key = "Ann", title = "Ann", books = listOf(two)), + GroupedSection(key = "Bob", title = "Bob", books = listOf(one)), + ) + + val items = flattenGroupedItems(sections, emptyMap()) + + assertNoRepeatedRows(items) + // The first of each is kept, in order. + assertEquals( + listOf("header-Ann", "Ann/one", "Ann/two", "header-Bob", "Bob/one"), + items.map { + when (it) { + is LibraryListItem.GroupHeader -> "header-${it.groupKey}" + is LibraryListItem.BookRow -> "${it.groupKey}/${it.book.id}" + } + }, + ) + } + + @Test + fun `list keys cannot run together`() { + val a = LibraryListItem.BookRow(groupKey = "Smith-Jones", book = AudioBook(id = "x")) + val b = LibraryListItem.BookRow(groupKey = "Smith", book = AudioBook(id = "Jones-x")) + val header = LibraryListItem.GroupHeader(groupKey = "Smith", title = "Smith", count = 1, isExpanded = true) + + assertEquals(3, setOf(a.listKey, b.listKey, header.listKey).size) + } + + @Test + fun `an eight thousand book library has one row per book per group`() = runBlocking { + val firsts = listOf("Mara", "Jonas", "Ilse", "Theo", "Ruth", "Caleb", "Wren", "Otto", "Nadia", "Silas", "Petra", "Hugo") + val lasts = listOf("Vance", "Okafor", "Lindqvist", "Marsh", "Delacroix", "Hale", "Ibarra", "Kowal", "Strand", "Achebe") + val books = (1..8000).map { i -> + val author = "${firsts[i % firsts.size]} ${lasts[(i / firsts.size) % lasts.size]}" + book("li_mock_%05d".format(i), "Mockbook %04d".format(i), author) + } + // Two pages saved mid-read: their books came back again at the end. + val stored = books + books.subList(99, 299) + + val shelf = arrangeShelf(stored, SortMode.TITLE_AZ, ViewMode.AUTHOR, isUnlocked = true) + val everyGroupOpen = shelf.sections.associate { it.key to true } + val items = flattenGroupedItems(shelf.sections, everyGroupOpen) + + assertEquals(8000, shelf.books.size) + assertEquals(8000, items.count { it is LibraryListItem.BookRow }) + assertNoRepeatedRows(items) + } + + private fun assertNoRepeatedRows(items: List) { + val keys = items.map { it.listKey } + assertEquals("repeated list keys", emptyList(), keys.groupBy { it }.filterValues { it.size > 1 }.keys.toList()) + } + + private fun book( + id: String, + title: String, + author: String, + genres: List = emptyList(), + series: String? = null, + ) = AudioBook(id = id, title = title, author = author, genres = genres, seriesName = series) +} From 0be83f482129b663b8717e52ab25cbd85e25f1b1 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 13:49:03 -0400 Subject: [PATCH 19/23] Each new download gets its own folder, and deleting from a custom folder removes the book's files New downloads go to "Author - Title [id]", where id is the last eight letters and digits of the ABS item id, so two editions of one title never share a folder: a cancelled partial of one edition can no longer become the other's audio, and deleting one while the other downloads takes all of its files. The name is capped at 200 UTF-8 bytes with the id always kept. Existing downloads keep their stored path and are not moved. A download that started before this (bytes on record, no folder of its own yet) resumes in its old "Author - Title" folder. Delete and cancel find the folder another download may be writing with the same rule, and the shared-folder checks stay in place for the old folders. Deleting a download in a user-picked download folder used to leave its files on disk while dropping the record. It now removes this book's own named tracks, their .part leftovers and its cover, and the folder only once empty. When the files cannot be proven this book's, the book stays downloaded and the delete says so instead of pretending it worked. Co-Authored-By: Claude Opus 5.5 --- .../app/service/DownloadManager.kt | 45 +++-- .../app/service/download/CancelCleanup.kt | 55 +++++- .../app/service/download/DownloadEngine.kt | 21 ++- .../app/service/download/DownloadOwnership.kt | 25 ++- .../app/service/download/DownloadPolicies.kt | 64 ++++++- .../app/ui/bookdetail/BookDetailViewModel.kt | 6 +- .../app/ui/downloads/DownloadsViewModel.kt | 9 +- .../service/download/DownloadDeleteTest.kt | 73 +++++++- .../service/download/DownloadOwnershipTest.kt | 162 +++++++++++++++++- .../service/download/DownloadPoliciesTest.kt | 65 +++++++ 10 files changed, 465 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt index 8fba79f..a822c56 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt @@ -36,6 +36,7 @@ import com.ninelivesaudio.app.service.download.wifiRuleChangeRestartsDrain import com.ninelivesaudio.app.service.download.runEngineExclusively import com.ninelivesaudio.app.service.download.removeCancelledPartialFiles import com.ninelivesaudio.app.service.download.removeDownloadedBookFiles +import com.ninelivesaudio.app.service.download.deleteKeepsLocalCopy import com.ninelivesaudio.app.service.download.DownloadOwnerRow import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -733,7 +734,7 @@ class DownloadManager @Inject constructor( downloadItemDao.deleteById(downloadId) dropMeteredOverride(downloadId) if (entity != null && cancelMayTouchFiles(entity.status, stop.wasDownloading, stop.stopConfirmed)) { - removeCancelledPartialFiles(entity.audioBookId) + removeCancelledPartialFiles(entity.audioBookId, startedWriting = entity.downloadedBytes > 0) } stop.wasDownloading } @@ -759,9 +760,9 @@ class DownloadManager @Inject constructor( * gone. See [removeCancelledPartialFiles]. Best effort: a failure here * never fails the cancel. */ - private suspend fun removeCancelledPartialFiles(audioBookId: String) { + private suspend fun removeCancelledPartialFiles(audioBookId: String, startedWriting: Boolean) { try { - when (val decision = removeCancelledPartialFiles(folderReads, ownership, audioBookId)) { + when (val decision = removeCancelledPartialFiles(folderReads, ownership, audioBookId, startedWriting)) { null -> Unit is CancelCleanupDecision.Keep -> android.util.Log.d("DownloadManager", "cancel cleanup kept files: ${decision.reason}") @@ -775,10 +776,14 @@ class DownloadManager @Inject constructor( } } - /** Delete a download's files and DB record. */ - suspend fun deleteDownload(audioBookId: String): Unit = finishInOwnerScope(scope) { + /** + * Delete a download's files and DB record. False when its files could not + * be proven this book's, so they stayed and the book stays downloaded. + */ + suspend fun deleteDownload(audioBookId: String): Boolean = finishInOwnerScope(scope) { // Held through the file deletion like cancel, so no resume or drain // start lands on this book while its files are being judged. + var removed = true val wasDownloading = rowMutex.withLock { ownership.actOnceEngineIsOff( audioBookId = audioBookId, @@ -787,7 +792,7 @@ class DownloadManager @Inject constructor( }, stopEngine = { stopDrainAndAwait() }, ) { stop -> - deleteDownloadedFilesAndRows(audioBookId, downloadItemDao.getByAudioBookId(audioBookId)?.id) + removed = deleteDownloadedFilesAndRows(audioBookId, downloadItemDao.getByAudioBookId(audioBookId)?.id) stop.wasDownloading } } @@ -795,18 +800,20 @@ class DownloadManager @Inject constructor( // Restart the drain for whatever else is queued. enqueueDrain(replace = true) } + removed } - private suspend fun deleteDownloadedFilesAndRows(audioBookId: String, downloadId: String?) { + private suspend fun deleteDownloadedFilesAndRows(audioBookId: String, downloadId: String?): Boolean { // Read the book only now, after any engine on it has stopped, so a // completion that landed during the stop is the state being undone. // Use the actual localPath stored on the audiobook — this matches the path - // set by the engine (basePath/Author - Title), not basePath/audioBookId. + // set by the engine (an "Author - Title [id]" folder, or "Author - Title" for older downloads). val bookEntity = audioBookDao.getById(audioBookId) val localPath = bookEntity?.localPath - if (!localPath.isNullOrEmpty()) { - removeDownloadedFiles(bookEntity.toDomain(), localPath) + if (!localPath.isNullOrEmpty() && !removeDownloadedFiles(bookEntity.toDomain(), localPath)) { + // The files stayed, so the record that reaches them stays too. + return false } if (bookEntity != null) { @@ -818,29 +825,32 @@ class DownloadManager @Inject constructor( downloadItemDao.deleteById(downloadId) dropMeteredOverride(downloadId) } + return true } /** * Delete [book]'s downloaded files from [localPath] and nothing else (#49). * Runs inside [DownloadOwnership.actOnceEngineIsOff]. See - * [removeDownloadedBookFiles]. Best effort: a failure here leaves files - * behind but never fails the delete. + * [removeDownloadedBookFiles]. False when files stayed because they could + * not be proven this book's (see [deleteKeepsLocalCopy]), or the check failed. */ - private suspend fun removeDownloadedFiles(book: AudioBook, localPath: String) { + private suspend fun removeDownloadedFiles(book: AudioBook, localPath: String): Boolean = try { - when (val decision = removeDownloadedBookFiles(folderReads, ownership, book, localPath)) { + val decision = removeDownloadedBookFiles(folderReads, ownership, book, localPath) + when (decision) { null -> Unit is CancelCleanupDecision.Keep -> android.util.Log.d("DownloadManager", "delete kept files: ${decision.reason}") is CancelCleanupDecision.Delete -> android.util.Log.d("DownloadManager", "delete removed ${decision.files.size} files") } + !deleteKeepsLocalCopy(decision) } catch (cancellation: kotlinx.coroutines.CancellationException) { throw cancellation } catch (e: Exception) { android.util.Log.w("DownloadManager", "delete left files: ${e.message}") + false } - } /** Room and the engine's path rules, as delete and cancel read them. */ private val folderReads = object : FolderReads { @@ -851,8 +861,9 @@ class DownloadManager @Inject constructor( audioBookDao.getFilePathBookIdsExcept(audioBookId) override suspend fun localPathsExcept(audioBookId: String) = audioBookDao.getLocalPathsExcept(audioBookId) override suspend fun downloadRows() = - downloadItemDao.getAll().map { DownloadOwnerRow(it.audioBookId, it.status) } - override fun plannedLocation(book: AudioBook) = engine.downloadLocationFor(book) + downloadItemDao.getAll().map { DownloadOwnerRow(it.audioBookId, it.status, it.downloadedBytes) } + override fun plannedLocation(book: AudioBook, startedWriting: Boolean) = + engine.downloadLocationFor(book, startedWriting) override fun storedLocation(localPath: String) = engine.storedDownloadLocation(localPath) } diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt index a7a3e25..83230b6 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt @@ -1,5 +1,6 @@ package com.ninelivesaudio.app.service.download +import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.DownloadStatus import java.io.File import java.io.IOException @@ -47,6 +48,24 @@ internal fun resolveDownloadRoot(configuredPath: String, defaultRoot: File): Pai return defaultRoot to true } +/** + * The folder [book]'s download writes to under [root] (#49). + * + * New downloads get their own "Author - Title [id]" folder. A download that + * started before that naming ([startedWriting]: its row has bytes on record) + * and has not written into its own folder keeps going in its old shared + * "Author - Title" folder, so its finished tracks are not stranded. The engine + * creates the folder it picks before writing a byte, so once a download has + * started here its own folder exists and wins from then on. Delete and cancel + * use this same rule to find the folder another download may be writing. + */ +internal fun bookDownloadFolder(root: File, book: AudioBook, startedWriting: Boolean): File { + val own = File(root, itemDownloadFolderName(book.author, book.title, book.id)) + if (!startedWriting || own.exists()) return own + val legacy = File(root, downloadFolderName(book.author, book.title, book.id)) + return if (legacy.isDirectory) legacy else own +} + /** System dirs a configured download path may never point into. */ internal val FORBIDDEN_DOWNLOAD_ROOTS = listOf("/system", "/data/data", "/data/user", "/proc", "/dev") @@ -93,6 +112,12 @@ internal enum class CancelKeepReason { /** A path could not be resolved, so overlap cannot be ruled out. */ UNRESOLVABLE, + + /** + * A delete in a user-picked root has no track list to name this book's + * files by, so none of them can be told from the user's own. + */ + NO_TRACK_LIST, } internal sealed interface CancelCleanupDecision { @@ -271,7 +296,6 @@ internal fun deleteLocationFor(storedPath: String, configuredPath: String, defau * remove it. Uses the same rules as cancel cleanup and never recurses. * * Kept whole when: - * - the root is a user-picked download folder, * - the folder is not a real direct child of the root (a symlink, the root * itself, `..`, or anything that resolves elsewhere), * - the folder does not exist, @@ -287,6 +311,11 @@ internal fun deleteLocationFor(storedPath: String, configuredPath: String, defau * nobody shares it and it holds only plain files, every file goes and then the * folder. A subfolder or link inside is never followed, so with one present * only this book's own files and its cover go. + * + * A user-picked root may hold the user's own files, so there only this book's + * own named files, their `.part` leftovers and its cover go, never anything + * else, and the folder only once that leaves it empty. The root itself is + * never touched. */ internal fun deleteDownloadFiles( location: DownloadLocation, @@ -299,14 +328,21 @@ internal fun deleteDownloadFiles( return decision } +/** + * Whether a delete has to leave the book marked downloaded: its files stayed + * because they could not be proven this book's, and dropping the record would + * strand them out of reach. A missing folder, or a path the engine never wrote + * (null), leaves nothing behind. + */ +internal fun deleteKeepsLocalCopy(decision: CancelCleanupDecision?): Boolean = + decision is CancelCleanupDecision.Keep && decision.reason != CancelKeepReason.NO_FOLDER + private fun decideDownloadDelete( location: DownloadLocation, ownFileNames: List, sharers: List, otherDownloadFolders: List, ): CancelCleanupDecision { - if (!location.rootIsAppOwned) return CancelCleanupDecision.Keep(CancelKeepReason.USER_FOLDER) - val folder = when (val contained = containedFolder(location)) { is Containment.Inside -> contained.folder is Containment.Refused -> return CancelCleanupDecision.Keep(contained.reason) @@ -338,9 +374,13 @@ private fun decideDownloadDelete( ?: return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) val plainFiles = children.filter { Files.isRegularFile(it, LinkOption.NOFOLLOW_LINKS) }.map(Path::toFile) - if (sameFolder.isEmpty() && plainFiles.size == children.size) { + if (location.rootIsAppOwned && sameFolder.isEmpty() && plainFiles.size == children.size) { return CancelCleanupDecision.Delete(folder, plainFiles) } + // In a user-picked root only named files go, so with no names nothing is ours to take. + if (!location.rootIsAppOwned && ownFileNames.isEmpty()) { + return CancelCleanupDecision.Keep(CancelKeepReason.NO_TRACK_LIST) + } val claimedByOthers = sameFolder.flatMapTo(HashSet()) { it.fileNames.orEmpty() } val own = ownFileNames.filterNot { it in claimedByOthers } @@ -352,7 +392,12 @@ private fun decideDownloadDelete( } /** One download row, as the ownership check needs it. */ -internal data class DownloadOwnerRow(val audioBookId: String, val status: Int) +internal data class DownloadOwnerRow( + val audioBookId: String, + val status: Int, + /** Bytes on record. Above zero, the download has started writing (see [bookDownloadFolder]). */ + val downloadedBytes: Long = 0, +) /** * Books other than [excludeBookId] that may still be writing to their folder, diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt index 9f2b141..d4bbdbe 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt @@ -66,7 +66,9 @@ class DownloadEngine @Inject constructor( audioBook: AudioBook, onProgress: suspend (downloadId: String, downloaded: Long, total: Long) -> Unit, ): DownloadItem? { - val downloadDir = getDownloadPath(audioBook) + // The row as it is now, not as the drain read it before waiting its turn. + val bytesOnRecord = downloadItemDao.getById(item.id)?.downloadedBytes ?: item.downloadedBytes + val downloadDir = getDownloadPath(audioBook, startedWriting = bytesOnRecord > 0) val claimed = ownership.claim( downloadId = item.id, audioBookId = audioBook.id, @@ -329,25 +331,22 @@ class DownloadEngine @Inject constructor( // ─── File Paths ────────────────────────────────────────────────────────── - /** Get download directory for an audiobook. */ - private fun getDownloadPath(audioBook: AudioBook): File { - val basePath = getBasePath() - val folderName = downloadFolderName(audioBook.author, audioBook.title, audioBook.id) - return File(basePath, folderName) - } + /** Get download directory for an audiobook. See [bookDownloadFolder]. */ + private fun getDownloadPath(audioBook: AudioBook, startedWriting: Boolean): File = + bookDownloadFolder(getBasePath(), audioBook, startedWriting) /** * Where [audioBook]'s download folder is under the current settings, for - * cancel cleanup. Resolves exactly like [getDownloadPath] but creates + * delete and cancel. Resolves exactly like [getDownloadPath] but creates * nothing. */ - internal fun downloadLocationFor(audioBook: AudioBook): DownloadLocation { + internal fun downloadLocationFor(audioBook: AudioBook, startedWriting: Boolean): DownloadLocation { val (root, appOwned) = resolveDownloadRoot( configuredPath = settingsManager.currentSettings.downloadPath, defaultRoot = defaultBasePath(), ) - val folderName = downloadFolderName(audioBook.author, audioBook.title, audioBook.id) - return DownloadLocation(root = root, folder = File(root, folderName), rootIsAppOwned = appOwned) + val folder = bookDownloadFolder(root, audioBook, startedWriting) + return DownloadLocation(root = root, folder = folder, rootIsAppOwned = appOwned) } /** diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadOwnership.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadOwnership.kt index 0bc94a2..b2d3de9 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadOwnership.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadOwnership.kt @@ -137,8 +137,11 @@ internal interface FolderReads { /** Every other book's stored localPath. */ suspend fun localPathsExcept(audioBookId: String): List suspend fun downloadRows(): List - /** Where [book] would download to under the current settings. */ - fun plannedLocation(book: AudioBook): DownloadLocation + /** + * Where [book] downloads to under the current settings. [startedWriting] + * is whether its row has bytes on record, see [bookDownloadFolder]. + */ + fun plannedLocation(book: AudioBook, startedWriting: Boolean): DownloadLocation /** Where a stored localPath sits, or null when the engine never wrote it. */ fun storedLocation(localPath: String): DownloadLocation? } @@ -148,7 +151,8 @@ internal interface FolderReads { * Call it inside [DownloadOwnership.actOnceEngineIsOff], so what it reads * cannot change before the files go. * - * Two editions with the same author and title share one folder, so the other + * Two editions with the same author and title shared one folder before each + * download got its own (and those folders are still around), so the other * books stored there come along with their track lists, and only this book's * own files go. A folder another download may still be writing keeps * everything: any unfinished row's folder (see [unfinishedDownloadOwners]) and @@ -175,17 +179,19 @@ internal suspend fun removeDownloadedBookFiles( val withLocalCopy = candidates.values .filter { it.isDownloaded && !it.localPath.isNullOrEmpty() } .mapTo(HashSet()) { it.id } + val started = rows.filter { it.downloadedBytes > 0 }.mapTo(HashSet()) { it.audioBookId } val otherFolders = unfinishedDownloadOwners(rows, withLocalCopy, book.id) // No book row means nothing the engine could be writing for it. .mapNotNull { candidates[it] } - .map { reads.plannedLocation(it).folder } + + .map { reads.plannedLocation(it, it.id in started).folder } + listOfNotNull(claim?.folder) return deleteDownloadFiles(location, downloadedFileNames(book.audioFiles), sharers, otherFolders) } /** * Delete the partial folder a cancelled download of [audioBookId] left behind - * (#51), after its row is gone. Call it inside + * (#51), after its row is gone. [startedWriting] is whether that row had bytes + * on record, which picks the folder (see [bookDownloadFolder]). Call it inside * [DownloadOwnership.actOnceEngineIsOff], so what it reads cannot change before * the files go. The rules live in [decideCancelCleanup] and keep the files on * any doubt, including when another book or download points at the same @@ -195,16 +201,19 @@ internal suspend fun removeCancelledPartialFiles( reads: FolderReads, ownership: DownloadOwnership, audioBookId: String, + startedWriting: Boolean, ): CancelCleanupDecision? { val claim = ownership.engineClaim // The engine still on this book means its stop never confirmed. if (claim?.audioBookId == audioBookId) return CancelCleanupDecision.Keep(CancelKeepReason.SHARED) val book = reads.book(audioBookId) ?: return null - val location = reads.plannedLocation(book) + val location = reads.plannedLocation(book, startedWriting) val otherBookPaths = reads.localPathsExcept(audioBookId) // Every download row still around, mapped to the folder it would write to. - val rowBookIds = reads.downloadRows().map { it.audioBookId }.distinct() - val otherFolders = reads.books(rowBookIds).map { reads.plannedLocation(it).folder } + listOfNotNull(claim?.folder) + val rows = reads.downloadRows() + val started = rows.filter { it.downloadedBytes > 0 }.mapTo(HashSet()) { it.audioBookId } + val otherFolders = reads.books(rows.map { it.audioBookId }.distinct()) + .map { reads.plannedLocation(it, it.id in started).folder } + listOfNotNull(claim?.folder) val bookIsDownloaded = book.isDownloaded || !book.localPath.isNullOrEmpty() val decision = decideCancelCleanup(location, bookIsDownloaded, otherBookPaths, otherFolders) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadPolicies.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadPolicies.kt index 8ea5ba9..68604cb 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadPolicies.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadPolicies.kt @@ -30,14 +30,66 @@ internal fun sanitizeDownloadFileName(name: String): String = .take(MAX_FILE_NAME_LENGTH) /** - * Folder name for a downloaded book: "Author - Title" when a real author is - * known, otherwise just the title, falling back to the book id when both are - * blank. Sanitized for the filesystem. + * The folder name downloads used before #49: "Author - Title" when a real + * author is known, otherwise just the title, falling back to the book id when + * both are blank. Sanitized for the filesystem. Two editions with the same + * author and title got the same folder, so only a download that started under + * this name still uses it (see [bookDownloadFolder]). */ -internal fun downloadFolderName(author: String, title: String, fallbackId: String): String { +internal fun downloadFolderName(author: String, title: String, fallbackId: String): String = + sanitizeDownloadFileName(authorTitle(author, title)).ifBlank { fallbackId } + +private fun authorTitle(author: String, title: String): String { val realAuthor = author.takeIf { it.isNotBlank() && it != "Unknown Author" } - val raw = if (realAuthor != null) "$realAuthor - $title" else title - return sanitizeDownloadFileName(raw).ifBlank { fallbackId } + return if (realAuthor != null) "$realAuthor - $title" else title +} + +/** Characters of the item id a download folder's name ends with. */ +private const val SHORT_ITEM_ID_LENGTH = 8 + +/** UTF-8 bytes a download folder's name may use. Android filesystems cap a name at 255. */ +internal const val MAX_FOLDER_NAME_BYTES = 200 + +/** + * The tail of an ABS item id, letters and digits only. ABS ids end in random + * characters (a UUID, or `li_` plus random letters), so eight of them tell two + * editions of one title apart. An id with no letters or digits uses its hash. + */ +internal fun shortItemId(itemId: String): String = + itemId.filter { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' } + .takeLast(SHORT_ITEM_ID_LENGTH) + .ifEmpty { Integer.toHexString(itemId.hashCode()) } + +/** + * Folder name for a new download: "Author - Title [id]". The title comes + * first so other apps show it readably, and the item id keeps two editions out + * of each other's folder (#49). The title is cut so the whole name fits + * [MAX_FOLDER_NAME_BYTES], and the id always survives. + */ +internal fun itemDownloadFolderName(author: String, title: String, itemId: String): String { + val suffix = "[${shortItemId(itemId)}]" + val room = MAX_FOLDER_NAME_BYTES - suffix.length - 1 + val base = takeUtf8Bytes(sanitizeDownloadFileName(authorTitle(author, title)), room).trimEnd() + return if (base.isEmpty()) suffix else "$base $suffix" +} + +/** The longest start of [text] that fits [maxBytes] of UTF-8, never splitting a character. */ +private fun takeUtf8Bytes(text: String, maxBytes: Int): String { + var bytes = 0 + var end = 0 + while (end < text.length) { + val codePoint = text.codePointAt(end) + val size = when { + codePoint < 0x80 -> 1 + codePoint < 0x800 -> 2 + codePoint < 0x10000 -> 3 + else -> 4 + } + if (bytes + size > maxBytes) break + bytes += size + end += Character.charCount(codePoint) + } + return text.substring(0, end) } /** diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/bookdetail/BookDetailViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/bookdetail/BookDetailViewModel.kt index c2f3ead..aeea25b 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/bookdetail/BookDetailViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/bookdetail/BookDetailViewModel.kt @@ -24,6 +24,7 @@ import com.ninelivesaudio.app.service.remoteMediaAccessDecision import com.ninelivesaudio.app.service.SettingsManager import com.ninelivesaudio.app.service.download.WifiOnlyCopy import com.ninelivesaudio.app.service.local.LocalFolderAccess +import com.ninelivesaudio.app.ui.downloads.DELETE_REFUSED_NOTICE import com.ninelivesaudio.app.service.local.reconcileLocalBookAccess import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* @@ -473,7 +474,10 @@ class BookDetailViewModel @Inject constructor( fun deleteDownload() { viewModelScope.launch { - downloadManager.deleteDownload(bookId) + if (!downloadManager.deleteDownload(bookId)) { + _uiState.update { it.copy(downloadNotice = DELETE_REFUSED_NOTICE) } + return@launch + } _uiState.update { it.copy( isDownloaded = false, diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsViewModel.kt index a7ddbeb..424c9cc 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsViewModel.kt @@ -211,7 +211,9 @@ class DownloadsViewModel @Inject constructor( fun deleteDownload(audioBookId: String) { viewModelScope.launch { - downloadManager.deleteDownload(audioBookId) + if (!downloadManager.deleteDownload(audioBookId)) { + _uiState.update { it.copy(notice = DELETE_REFUSED_NOTICE) } + } } } @@ -223,6 +225,11 @@ class DownloadsViewModel @Inject constructor( } } +/** What a delete says when it kept the files because they might not be only this book's. */ +internal const val DELETE_REFUSED_NOTICE = + "Couldn't safely remove this book's files, so it's still downloaded. " + + "Another download may share its folder, or the download folder changed since." + /** * A Retry the free slot refuses has to say so. It used to requeue anyway and * sit in Queued forever with no word. diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt index 17778b4..dba76da 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt @@ -170,15 +170,80 @@ class DownloadDeleteTest { // ─── User-picked folder ────────────────────────────────────────────────── @Test - fun `a user-picked root keeps every file`() { + fun `a user-picked root loses this book's own files and its emptied folder`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3", "02.mp3.part", "cover.jpg") + + delete(root, folder, listOf("01.mp3", "02.mp3"), appOwned = false) + + assertFalse("the book's files stayed in a user-picked root", folder.exists()) + assertTrue(root.exists()) + } + + @Test + fun `a user-picked root keeps anything the book does not name`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "my notes.txt", "bonus.mp3") + File(root, "loose.mp3").writeBytes(byteArrayOf(9)) + + delete(root, folder, listOf("01.mp3"), appOwned = false) + + assertFalse(File(folder, "01.mp3").exists()) + assertTrue(File(folder, "my notes.txt").exists()) + assertTrue(File(folder, "bonus.mp3").exists()) + assertTrue(File(root, "loose.mp3").exists()) + } + + @Test + fun `a user-picked root keeps the files another edition shares`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3", "cover.jpg") + val other = FolderSharer(folder.absolutePath, listOf("02.mp3")) + + delete(root, folder, listOf("01.mp3", "02.mp3"), appOwned = false, sharers = listOf(other)) + + assertFalse(File(folder, "01.mp3").exists()) + assertTrue(File(folder, "02.mp3").exists()) + assertTrue(File(folder, "cover.jpg").exists()) + } + + @Test + fun `a user-picked root with no track list keeps everything`() { val root = root() val folder = bookFolder(root, "01.mp3", "cover.jpg") - val decision = delete(root, folder, listOf("01.mp3"), appOwned = false) + val decision = delete(root, folder, emptyList(), appOwned = false) - assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.USER_FOLDER), decision) + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.NO_TRACK_LIST), decision) assertTrue(File(folder, "01.mp3").exists()) - assertTrue(File(folder, "cover.jpg").exists()) + assertTrue(deleteKeepsLocalCopy(decision)) + } + + @Test + fun `a user-picked root itself is never deleted`() { + val root = root() + File(root, "01.mp3").writeBytes(byteArrayOf(6)) + + val decision = delete(root, root, listOf("01.mp3"), appOwned = false) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decision) + assertTrue(File(root, "01.mp3").exists()) + } + + // ─── What the record does after a delete ───────────────────────────────── + + @Test + fun `files kept for any doubt keep the book marked downloaded`() { + assertTrue(deleteKeepsLocalCopy(CancelCleanupDecision.Keep(CancelKeepReason.SHARED))) + assertTrue(deleteKeepsLocalCopy(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT))) + assertTrue(deleteKeepsLocalCopy(CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE))) + } + + @Test + fun `a delete that went through or found nothing clears the record`() { + assertFalse(deleteKeepsLocalCopy(CancelCleanupDecision.Delete(File("x"), emptyList()))) + assertFalse(deleteKeepsLocalCopy(CancelCleanupDecision.Keep(CancelKeepReason.NO_FOLDER))) + assertFalse(deleteKeepsLocalCopy(null)) } // ─── Root containment ──────────────────────────────────────────────────── diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadOwnershipTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadOwnershipTest.kt index 67d38e4..ae6ea45 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadOwnershipTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadOwnershipTest.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.withTimeout import kotlinx.coroutines.yield import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -33,7 +34,11 @@ class DownloadOwnershipTest { private fun track(name: String, index: Int = 0) = AudioFile(id = name, ino = name, index = index, filename = name) - /** Room and the path rules, in memory. Every book folder is "Author - Title" under [root]. */ + /** + * Room and the path rules, in memory, with the engine's real folder rule. + * The races here play out in shared "Author - Title" folders left by + * downloads that started before each book got its own folder (#49). + */ private class FakeReads(val root: File) : FolderReads { val books = mutableMapOf() val rows = mutableMapOf() @@ -50,9 +55,10 @@ class DownloadOwnershipTest { } override suspend fun localPathsExcept(audioBookId: String) = books.values.filter { it.id != audioBookId }.mapNotNull { it.localPath?.takeIf(String::isNotEmpty) } - override suspend fun downloadRows() = rows.values.map { DownloadOwnerRow(it.audioBookId, it.status.ordinal) } - override fun plannedLocation(book: AudioBook) = - DownloadLocation(root, File(root, downloadFolderName(book.author, book.title, book.id)), true) + override suspend fun downloadRows() = + rows.values.map { DownloadOwnerRow(it.audioBookId, it.status.ordinal, it.downloadedBytes) } + override fun plannedLocation(book: AudioBook, startedWriting: Boolean) = + DownloadLocation(root, bookDownloadFolder(root, book, startedWriting), true) override fun storedLocation(localPath: String) = deleteLocationFor(localPath, "", root) } @@ -135,7 +141,7 @@ class DownloadOwnershipTest { ownership.noEngineOn("A") { reads.rows.remove("dA") - removeCancelledPartialFiles(reads, ownership, "A") + removeCancelledPartialFiles(reads, ownership, "A", startedWriting = true) } assertTrue("B's in-progress track went with A's cancel", File(shared, "b-01.mp3.part").exists()) @@ -247,7 +253,7 @@ class DownloadOwnershipTest { // A read stuck on a stalled server: the stop gives up, the engine stays. ownership.actOnceEngineIsOff("A", rowIsDownloading = { true }, stopEngine = { false }) { reads.rows.remove("dA") - removeCancelledPartialFiles(reads, ownership, "A") + removeCancelledPartialFiles(reads, ownership, "A", startedWriting = true) } assertTrue(File(dir, "02.mp3.part").exists()) @@ -283,7 +289,7 @@ class DownloadOwnershipTest { ownership.noEngineOn("A") { reads.rows.remove("dA") - removeCancelledPartialFiles(reads, ownership, "A") + removeCancelledPartialFiles(reads, ownership, "A", startedWriting = true) } assertFalse(dir.exists()) @@ -348,4 +354,146 @@ class DownloadOwnershipTest { assertEquals(DownloadStatus.Paused, reads.rows.getValue("dP").status) assertNull(ownership.engineClaim) } + + // ─── Each download in its own folder (#49) ─────────────────────────────── + + private val editionA = AudioBook(id = "li_editionaaa1", title = "Moby-Dick", author = "Herman Melville") + private val editionB = AudioBook(id = "li_editionbbb2", title = "Moby-Dick", author = "Herman Melville") + private val legacyName = "Herman Melville - Moby-Dick" + + @Test + fun `a new download never lands in another edition's cancelled partial folder`() { + val root = tempFolder.newFolder("Audiobookshelf") + // A cancelled partial of edition A, left in the old shared folder. + folder(root, legacyName, "01.mp3", "02.mp3.part") + + val target = bookDownloadFolder(root, editionB, startedWriting = false) + + assertNotEquals(File(root, legacyName), target) + assertFalse("B would reuse A's partial track", File(target, "01.mp3").exists()) + } + + @Test + fun `two editions download side by side into separate folders`() { + val root = tempFolder.newFolder("Audiobookshelf") + val a = bookDownloadFolder(root, editionA, startedWriting = false) + val b = bookDownloadFolder(root, editionB, startedWriting = false) + + assertNotEquals(a, b) + assertEquals(root, a.parentFile) + assertEquals(root, b.parentFile) + } + + @Test + fun `a download started before its own folder existed resumes in its old folder`() { + val root = tempFolder.newFolder("Audiobookshelf") + val old = folder(root, legacyName, "01.mp3") + + assertEquals(old, bookDownloadFolder(root, editionA, startedWriting = true)) + } + + @Test + fun `a started download that already wrote into its own folder stays there`() { + val root = tempFolder.newFolder("Audiobookshelf") + folder(root, legacyName, "01.mp3") + val own = folder(root, itemDownloadFolderName(editionA.author, editionA.title, editionA.id), "01.mp3") + + assertEquals(own, bookDownloadFolder(root, editionA, startedWriting = true)) + } + + @Test + fun `a started download with no old folder gets its own`() { + val root = tempFolder.newFolder("Audiobookshelf") + val target = bookDownloadFolder(root, editionA, startedWriting = true) + assertEquals(itemDownloadFolderName(editionA.author, editionA.title, editionA.id), target.name) + } + + @Test + fun `deleting one edition while the other downloads removes all of the first one's files`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + val aDir = bookDownloadFolder(root, editionA, startedWriting = false) + folder(root, aDir.name, "01.mp3", "cover.jpg") + val a = editionA.copy(isDownloaded = true, localPath = aDir.absolutePath, audioFiles = listOf(track("01.mp3"))) + reads.books[a.id] = a + reads.books[editionB.id] = editionB + reads.rows["dB"] = DownloadItem(id = "dB", audioBookId = editionB.id, status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + val bDir = bookDownloadFolder(root, editionB, startedWriting = false) + assertNotNull(ownership.claimFor(reads, "dB", bDir)) + folder(root, bDir.name, "01.mp3.part") + + ownership.noEngineOn(a.id) { removeDownloadedBookFiles(reads, ownership, a, aDir.absolutePath) } + + assertFalse("A's files were left behind", aDir.exists()) + assertTrue(File(bDir, "01.mp3.part").exists()) + } + + @Test + fun `deleting an old shared-folder download while a new edition is queued removes it`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + val old = folder(root, legacyName, "01.mp3", "cover.jpg") + val a = editionA.copy(isDownloaded = true, localPath = old.absolutePath, audioFiles = listOf(track("01.mp3"))) + reads.books[a.id] = a + reads.books[editionB.id] = editionB + reads.rows["dB"] = DownloadItem(id = "dB", audioBookId = editionB.id, status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + + ownership.noEngineOn(a.id) { removeDownloadedBookFiles(reads, ownership, a, old.absolutePath) } + + assertFalse("A's old folder was kept for a download that will never write there", old.exists()) + } + + @Test + fun `an old paused download still keeps its shared folder safe from the other edition's delete`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + val old = folder(root, legacyName, "a1.mp3", "b1.mp3", "b2.mp3.part") + val a = editionA.copy(isDownloaded = true, localPath = old.absolutePath, audioFiles = listOf(track("a1.mp3"))) + reads.books[a.id] = a + reads.books[editionB.id] = editionB + reads.rows["dB"] = DownloadItem( + id = "dB", audioBookId = editionB.id, status = DownloadStatus.Paused, downloadedBytes = 3, + ) + val ownership = DownloadOwnership() + + ownership.noEngineOn(a.id) { removeDownloadedBookFiles(reads, ownership, a, old.absolutePath) } + + assertTrue("B's finished track went with A's delete", File(old, "b1.mp3").exists()) + assertTrue(File(old, "b2.mp3.part").exists()) + } + + @Test + fun `cancelling a new download cleans its own folder and leaves the old shared one alone`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + // An old cancelled partial nobody tracks any more, from before the upgrade. + val old = folder(root, legacyName, "01.mp3") + val own = folder(root, itemDownloadFolderName(editionB.author, editionB.title, editionB.id), "01.mp3", "02.mp3.part") + reads.books[editionB.id] = editionB + val ownership = DownloadOwnership() + + ownership.noEngineOn(editionB.id) { + removeCancelledPartialFiles(reads, ownership, editionB.id, startedWriting = true) + } + + assertFalse(own.exists()) + assertTrue(File(old, "01.mp3").exists()) + } + + @Test + fun `cancelling an old paused download cleans its old folder`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + val old = folder(root, legacyName, "01.mp3", "02.mp3.part") + reads.books[editionA.id] = editionA + val ownership = DownloadOwnership() + + ownership.noEngineOn(editionA.id) { + removeCancelledPartialFiles(reads, ownership, editionA.id, startedWriting = true) + } + + assertFalse(old.exists()) + } } diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadPoliciesTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadPoliciesTest.kt index 8c26f70..1350d33 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadPoliciesTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadPoliciesTest.kt @@ -60,6 +60,71 @@ class DownloadPoliciesTest { assertEquals("A_B - C_D", downloadFolderName("A/B", "C:D", "id1")) } + // ─── itemDownloadFolderName (#49) ───────────────────────────────────── + + @Test + fun `new folder name puts the title first and the item id in brackets at the end`() { + assertEquals( + "Jane Doe - My Book [b7c1d2e3]", + itemDownloadFolderName("Jane Doe", "My Book", "5f0c2a9e-3b1d-4c8e-9a7f-1e2db7c1d2e3"), + ) + } + + @Test + fun `two editions of one title get different folders`() { + val first = itemDownloadFolderName("Herman Melville", "Moby-Dick", "li_8gch9ve09orgn4fdz8") + val second = itemDownloadFolderName("Herman Melville", "Moby-Dick", "li_2kq0x7mbn1aa3plw4c") + assertTrue(first != second) + assertTrue(first != downloadFolderName("Herman Melville", "Moby-Dick", "li_8gch9ve09orgn4fdz8")) + } + + @Test + fun `new folder name is stable for the same item`() { + assertEquals( + itemDownloadFolderName("A", "B", "li_8gch9ve09orgn4fdz8"), + itemDownloadFolderName("A", "B", "li_8gch9ve09orgn4fdz8"), + ) + } + + @Test + fun `short id keeps only letters and digits from the id`() { + assertEquals("abc12345", shortItemId("x/y:z*abc-123?45")) + assertEquals("ab", shortItemId("a/b")) + } + + @Test + fun `short id falls back to a hash when the id has no letters or digits`() { + assertEquals(Integer.toHexString("--".hashCode()), shortItemId("--")) + assertEquals("[${shortItemId("")}]", itemDownloadFolderName("", "", "")) + } + + @Test + fun `new folder name sanitizes odd characters`() { + val name = itemDownloadFolderName("A/B", "C:D*E?\"F|H\\I", "li_8gch9ve09orgn4fdz8") + assertEquals("A_B - C_D_E__F_G__H_I [rgn4fdz8]", name) + } + + @Test + fun `new folder name without author or title is just the id`() { + assertEquals("[rgn4fdz8]", itemDownloadFolderName("Unknown Author", " ", "li_8gch9ve09orgn4fdz8")) + } + + @Test + fun `a long title is cut to fit and the id survives`() { + val name = itemDownloadFolderName("Author", "x".repeat(400), "li_8gch9ve09orgn4fdz8") + assertTrue(name.endsWith(" [rgn4fdz8]")) + assertTrue(name.toByteArray(Charsets.UTF_8).size <= MAX_FOLDER_NAME_BYTES) + } + + @Test + fun `a long multibyte title fits the byte limit without splitting a character`() { + // Three bytes each in UTF-8, plus one emoji (a surrogate pair, four bytes) at every step. + val name = itemDownloadFolderName("作者", "長い題名📚".repeat(60), "li_8gch9ve09orgn4fdz8") + assertTrue(name.endsWith(" [rgn4fdz8]")) + assertTrue(name.toByteArray(Charsets.UTF_8).size <= MAX_FOLDER_NAME_BYTES) + assertEquals(name, String(name.toByteArray(Charsets.UTF_8), Charsets.UTF_8)) + } + // ─── estimateTotalBytes ─────────────────────────────────────────────── @Test From 9d75199dded0861f95791c832583048e40d5eb7e Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 13:50:01 -0400 Subject: [PATCH 20/23] Series and authors whose names differ only in capitals stay separate groups The duplicate-row fix merged group names that differ only in capitals, which joined distinct series like Saga and SAGA (interleaving their reading order) and named the merged group by whichever spelling sorted first, so a collapsed group could reopen on a sort change. Keys are now the name with runs of spaces collapsed, nothing else. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/library/LibraryViewModel.kt | 10 +++--- .../app/ui/library/LibraryDuplicateRowTest.kt | 34 +++++++++++-------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt index dacc38d..fa8ab17 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt @@ -1431,17 +1431,17 @@ internal fun buildGroupedSections( if (viewMode == ViewMode.ALL) return emptyList() // Genre view uses multi-placement: a book appears in every genre group it belongs to. - // Names that differ only in capitals or spacing ("John Smith", "john smith") - // are one group, named by the first spelling seen. So no two groups share - // a key, and a book is in each group at most once. + // A group's key is its name with runs of spaces made one ("John Smith" is + // "John Smith"). Capitals stay distinct, so "Saga" and "SAGA" remain two + // series, and the key never depends on which book sorts first. A book is in + // each group at most once. val grouped = linkedMapOf>() - val keyByFoldedName = HashMap() val bookKeys = LinkedHashSet() books.forEach { book -> bookKeys.clear() groupingKeysForBook(book, viewMode).forEach { name -> val spelling = collapseSpaces(name) - if (spelling.isNotEmpty()) bookKeys += keyByFoldedName.getOrPut(spelling.lowercase()) { spelling } + if (spelling.isNotEmpty()) bookKeys += spelling } bookKeys.forEach { key -> grouped.getOrPut(key) { mutableListOf() }.add(book) } } diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryDuplicateRowTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryDuplicateRowTest.kt index dc3b8a2..95e7b53 100644 --- a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryDuplicateRowTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryDuplicateRowTest.kt @@ -39,32 +39,36 @@ class LibraryDuplicateRowTest { fun `an author line that repeats a name lists the book once`() { val books = listOf( book("a", "A", "John Smith, John Smith"), - book("b", "B", "John Smith, john smith"), + book("b", "B", "John Smith, John Smith"), book("c", "C", " John Smith , JOHN SMITH"), ) val sections = buildGroupedSections(books, ViewMode.AUTHOR, SortMode.TITLE_AZ) - assertEquals(listOf("John Smith"), sections.map { it.key }) - assertEquals(listOf("a", "b", "c"), sections.single().books.map { it.id }) - assertNoRepeatedRows(flattenGroupedItems(sections, mapOf("John Smith" to true))) + assertEquals(setOf("John Smith", "JOHN SMITH"), sections.map { it.key }.toSet()) + assertEquals(listOf("a", "b", "c"), sections.single { it.key == "John Smith" }.books.map { it.id }) + assertEquals(listOf("c"), sections.single { it.key == "JOHN SMITH" }.books.map { it.id }) + assertNoRepeatedRows(flattenGroupedItems(sections, mapOf("John Smith" to true, "JOHN SMITH" to true))) } @Test - fun `names that differ only in capitals or spacing are one group`() { + fun `spacing variants are one group and capitals stay apart`() { val books = listOf( book("a", "A", "Ursula K. Le Guin"), - book("b", "B", "ursula k. le guin"), - book("c", "C", "Ursula K. Le Guin, Neil Gaiman"), - book("d", "D", "NEIL GAIMAN"), + book("b", "B", "Ursula K. Le Guin, Neil Gaiman"), + book("c", "C", "Saga Author", series = "Saga"), + book("d", "D", "Saga Author", series = "SAGA"), ) - val sections = buildGroupedSections(books, ViewMode.AUTHOR, SortMode.TITLE_AZ) + val authors = buildGroupedSections(books, ViewMode.AUTHOR, SortMode.TITLE_AZ) + assertEquals(listOf("a", "b"), authors.single { it.key == "Ursula K. Le Guin" }.books.map { it.id }) + assertEquals(listOf("b"), authors.single { it.key == "Neil Gaiman" }.books.map { it.id }) - // The first spelling seen names the group. - assertEquals(listOf("Neil Gaiman", "Ursula K. Le Guin"), sections.map { it.key }) - assertEquals(listOf("c", "d"), sections[0].books.map { it.id }) - assertEquals(listOf("a", "b", "c"), sections[1].books.map { it.id }) + // Two series whose names differ only in capitals are two series, so + // their reading orders never interleave. + val series = buildGroupedSections(books, ViewMode.SERIES, SortMode.TITLE_AZ) + assertEquals(listOf("c"), series.single { it.key == "Saga" }.books.map { it.id }) + assertEquals(listOf("d"), series.single { it.key == "SAGA" }.books.map { it.id }) } @Test @@ -83,8 +87,8 @@ class LibraryDuplicateRowTest { } } assertEquals( - listOf("Fantasy", "Sci Fi", "Uncategorized Genre"), - buildGroupedSections(books, ViewMode.GENRE, SortMode.TITLE_AZ).map { it.key }, + setOf("Fantasy", "fantasy", "FANTASY", "Sci Fi", "Uncategorized Genre"), + buildGroupedSections(books, ViewMode.GENRE, SortMode.TITLE_AZ).map { it.key }.toSet(), ) } From 3f89c013b146f917daa38f9d2cb0b34fb2275165 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 14:00:09 -0400 Subject: [PATCH 21/23] A delete whose files will not go keeps the book's record When storage turns read only mid-delete, the files stayed on disk but the record was cleared, orphaning the audio. A file still present after the delete now returns Keep(REMOVE_FAILED), so the book stays downloaded and the refusal notice shows. Co-Authored-By: Claude Opus 5.5 --- .../app/service/download/CancelCleanup.kt | 14 ++++++++++++-- .../app/service/download/DownloadDeleteTest.kt | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt index 83230b6..e9c449f 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt @@ -118,6 +118,12 @@ internal enum class CancelKeepReason { * files by, so none of them can be told from the user's own. */ NO_TRACK_LIST, + + /** + * A delete was allowed but one of this book's files would not go (storage + * turned read only, a permission changed), so the record stays with them. + */ + REMOVE_FAILED, } internal sealed interface CancelCleanupDecision { @@ -324,8 +330,12 @@ internal fun deleteDownloadFiles( otherDownloadFolders: List, ): CancelCleanupDecision { val decision = decideDownloadDelete(location, ownFileNames, sharers, otherDownloadFolders) - if (decision is CancelCleanupDecision.Delete) applyCancelCleanup(decision) - return decision + if (decision !is CancelCleanupDecision.Delete) return decision + applyCancelCleanup(decision) + // A file that is still there after the delete keeps the book's record, so + // its audio never sits on disk with nothing pointing at it. + val left = decision.files.any { Files.exists(it.toPath(), LinkOption.NOFOLLOW_LINKS) } + return if (left) CancelCleanupDecision.Keep(CancelKeepReason.REMOVE_FAILED) else decision } /** diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt index dba76da..b7834b5 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt @@ -76,6 +76,23 @@ class DownloadDeleteTest { assertTrue(folder.exists()) } + @Test + fun `a file that will not delete keeps the book's record`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3") + // A read-only folder: its files cannot be unlinked. + folder.setWritable(false) + try { + val decision = delete(root, folder, ownFileNames = listOf("01.mp3", "02.mp3")) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.REMOVE_FAILED), decision) + assertTrue(deleteKeepsLocalCopy(decision)) + assertTrue(File(folder, "01.mp3").exists()) + } finally { + folder.setWritable(true) + } + } + @Test fun `a sharer stored as a file uri with a trailing slash still counts`() { val root = root() From 46023b7b3d7c23ee89c5507169b3aa851a3e8053 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 14:59:51 -0400 Subject: [PATCH 22/23] Settings shows a server library's new name after a rename (#81) The earlier #81 change fixed the Library screen's reload check, but the visible selector lives in Settings, which loaded its list only at start and on connect. Settings now follows the cached server libraries and refreshes the names of the ones it shows. Which libraries appear and which is selected still come from loadLibraries(). Co-Authored-By: Claude Opus 5.5 --- .../app/ui/settings/SettingsViewModel.kt | 29 ++++++++++++++++ .../ui/settings/SettingsLibraryRenameTest.kt | 33 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/settings/SettingsLibraryRenameTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt index cbbec90..46151e1 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt @@ -357,6 +357,25 @@ class SettingsViewModel @Inject constructor( } } + // A server library renamed on the next sync shows its new name here + // without a restart (#81). Only names of libraries already shown are + // refreshed: which libraries appear and which is selected stay with + // loadLibraries(). + viewModelScope.launch { + libraryRepository.observeAudiobookshelf().collect { cached -> + _uiState.update { state -> + val libraries = withCachedLibraryNames(state.libraries, cached) + if (libraries == state.libraries) return@update state + state.copy( + libraries = libraries, + selectedLibrary = state.selectedLibrary?.let { selected -> + libraries.firstOrNull { it.id == selected.id } ?: selected + }, + ) + } + } + } + // Observe local libraries viewModelScope.launch { libraryRepository.observeLocalLibraries().collect { locals -> @@ -1549,6 +1568,16 @@ class SettingsViewModel @Inject constructor( * selector is still useful and requires no network). INVALID means the token was * rejected and the user was logged out, so there is nothing to load. */ +/** [shown] with each library's name taken from [cached] where the ids match. */ +internal fun withCachedLibraryNames(shown: List, cached: List): List { + if (shown.isEmpty() || cached.isEmpty()) return shown + val names = cached.associate { it.id to it.name } + return shown.map { library -> + val name = names[library.id] + if (name == null || name == library.name) library else library.copy(name = name) + } +} + internal fun shouldLoadCachedLibrariesAfterValidation(result: TokenValidationResult): Boolean = result != TokenValidationResult.INVALID diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/settings/SettingsLibraryRenameTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/settings/SettingsLibraryRenameTest.kt new file mode 100644 index 0000000..9c4b025 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/settings/SettingsLibraryRenameTest.kt @@ -0,0 +1,33 @@ +package com.ninelivesaudio.app.ui.settings + +import com.ninelivesaudio.app.domain.model.Library +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class SettingsLibraryRenameTest { + + @Test + fun `a library renamed in the cache shows its new name`() { + val shown = listOf(Library(id = "a", name = "Mock Shelf"), Library(id = "b", name = "Podcasts")) + val cached = listOf(Library(id = "a", name = "Renamed Shelf"), Library(id = "b", name = "Podcasts")) + + assertEquals(listOf("Renamed Shelf", "Podcasts"), withCachedLibraryNames(shown, cached).map { it.name }) + } + + @Test + fun `only names change, never which libraries are shown`() { + val shown = listOf(Library(id = "a", name = "A")) + val cached = listOf(Library(id = "a", name = "A2"), Library(id = "new", name = "New")) + + assertEquals(listOf("a"), withCachedLibraryNames(shown, cached).map { it.id }) + assertEquals(shown, withCachedLibraryNames(shown, emptyList())) + } + + @Test + fun `an unchanged cache hands back the same list`() { + val shown = listOf(Library(id = "a", name = "A")) + + assertSame(shown.single(), withCachedLibraryNames(shown, listOf(Library(id = "a", name = "A"))).single()) + } +} From f341cd9f7f8a3277b5355e25582c3104cec62bd8 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 15:04:19 -0400 Subject: [PATCH 23/23] A library load that finishes after a rename keeps the new name (#81) Settings' loadLibraries() could publish the cached snapshot it read before a concurrent sync renamed the library, undoing the name the collector had just applied. The collector now records the latest cached libraries before updating state, and loadLibraries() applies those names inside its own state update. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/settings/SettingsViewModel.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt index 46151e1..af12f32 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt @@ -304,6 +304,10 @@ class SettingsViewModel @Inject constructor( data class PendingSweep(val type: SweepType, val count: Int) private val _uiState = MutableStateFlow(UiState()) + + /** The server libraries Room last emitted, for names only (#81). */ + @Volatile + private var latestCachedLibraries: List = emptyList() val uiState: StateFlow = _uiState.asStateFlow() private val authUiGeneration = AuthUiGenerationCoordinator() private val authUiOperationMutex = Mutex() @@ -363,6 +367,9 @@ class SettingsViewModel @Inject constructor( // loadLibraries(). viewModelScope.launch { libraryRepository.observeAudiobookshelf().collect { cached -> + // Set before the update so a loadLibraries() that publishes + // after this point still carries these names. + latestCachedLibraries = cached _uiState.update { state -> val libraries = withCachedLibraryNames(state.libraries, cached) if (libraries == state.libraries) return@update state @@ -1532,9 +1539,12 @@ class SettingsViewModel @Inject constructor( } _uiState.update { + // A rename the collector saw while this load ran wins over the + // older snapshot this load started from (#81). + val named = withCachedLibraryNames(libs, latestCachedLibraries) it.copy( - libraries = libs, - selectedLibrary = selected, + libraries = named, + selectedLibrary = selected?.let { s -> named.firstOrNull { l -> l.id == s.id } ?: s }, ) } } catch (_: Exception) {