From 2092431852776e95f3348ca198d19eef10281bd2 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 19:43:56 -0400 Subject: [PATCH 01/69] The Library keeps its shelf across tabs and shows saved books first A server with thousands of books took minutes to show the Library, and again on every return from another tab. The tabs now save and restore their state, a return only reloads when the server, account, mode, or library changed, the saved shelf shows before the network fetch, and the merge, encode, decode, and sort work runs off the main thread. Co-Authored-By: Claude Opus 5.5 --- .../data/repository/AudioBookRepository.kt | 11 +++- .../app/ui/library/LibraryScreen.kt | 1 + .../app/ui/library/LibraryViewModel.kt | 62 ++++++++++++++++++- .../app/ui/navigation/BottomNavBar.kt | 7 ++- .../app/ui/navigation/LeftNavRail.kt | 7 ++- .../app/ui/library/LibraryReturnReloadTest.kt | 58 +++++++++++++++++ 6 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryReturnReloadTest.kt 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 f75ccc5b..90c472ce 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 @@ -20,6 +20,7 @@ import com.ninelivesaudio.app.service.local.LocalBookFingerprint import com.ninelivesaudio.app.service.local.folderNameOfTrackUri import com.ninelivesaudio.app.service.local.matchMovedLocalBooks import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -223,8 +224,14 @@ class AudioBookRepository @Inject constructor( mutex = syncLibraryItemsMutex, libraryId = libraryId, fetchItems = { apiService.getLibraryItems(libraryId) }, - mergeItems = { remote -> mergeSyncedBooks(remote, audioBookDao::getByIds) }, - upsertAll = { books -> audioBookDao.upsertAll(books.map { it.toEntity() }) }, + // Thousands of books are merged and JSON-encoded here, so keep it off + // the main thread the Library calls from. + mergeItems = { remote -> + withContext(Dispatchers.Default) { mergeSyncedBooks(remote, audioBookDao::getByIds) } + }, + upsertAll = { books -> + audioBookDao.upsertAll(withContext(Dispatchers.Default) { books.map { it.toEntity() } }) + }, cachedNonDownloadedIds = audioBookDao::getNonDownloadedServerIdsByLibrary, deleteByIds = audioBookDao::deleteServerBooksByIds, deleteAllServerBooks = { id -> audioBookDao.deleteServerBooksByLibrary(id) }, 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 e897e546..4238fa58 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 @@ -79,6 +79,7 @@ fun LibraryScreen( // (i.e. each time the user navigates to the Library tab). LaunchedEffect(Unit) { viewModel.incrementWhisperEpoch() + viewModel.onScreenEntered() } // Flatten grouped items only when groupedSections or expandedGroups change 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 bbfafdf3..f207bb6c 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 @@ -229,6 +229,33 @@ internal suspend fun updateLibraryLoadStateIfActive(update: () -> Unit) { if (currentCoroutineContext().isActive) update() } +/** + * What a shelf load was started for. The tabs keep the Library alive while + * another tab is open, so a return only reloads when this changed underneath + * it (a server, account, mode, or library switch in Settings). + */ +internal data class ShelfIdentity( + val appMode: AppMode, + val serverUrl: String, + val username: String, + val activeLibraryId: String?, +) + +internal fun AppSettings.shelfIdentity() = + ShelfIdentity(appMode, serverUrl, username, activeLibraryId) + +/** + * A return to the Library reloads when what it shows no longer matches the + * settings, or when it has nothing to show because its last fetch failed, so + * the return doubles as a retry. + * Nothing loaded yet means the first load is still starting and owns the shelf. + */ +internal fun shouldReloadOnLibraryReturn( + loadedFor: ShelfIdentity?, + current: ShelfIdentity, + lastFetchFailed: Boolean, +): Boolean = loadedFor != null && (loadedFor != current || lastFetchFailed) + // ─── ViewModel ─────────────────────────────────────────────────────────── @HiltViewModel @@ -295,6 +322,30 @@ class LibraryViewModel @Inject constructor( _whisperEpoch.update { it + 1 } } + // The settings the shown shelf was loaded for. See [onScreenEntered]. + private var shelfLoadedFor: ShelfIdentity? = null + + /** + * Called by LibraryScreen each time it enters composition. A tab return + * keeps the shelf it already has instead of downloading the library again. + */ + fun onScreenEntered() { + val state = _uiState.value + // Only an empty failed shelf retries on return. One with saved books + // keeps them, and the periodic and reconnect syncs recover it. + val lastFetchFailed = state.filteredBooks.isEmpty() && + (state.selectedLibraryFetchResult == SyncResult.FAILED || state.errorMessage != null) + if (shouldReloadOnLibraryReturn( + loadedFor = shelfLoadedFor, + current = settingsManager.currentSettings.shelfIdentity(), + lastFetchFailed = lastFetchFailed, + ) + ) { + // This can replace a refresh in the lane, so it owns that spinner. + libraryLoadLaunch.launch(viewModelScope) { loadLibrariesOwningRefresh() } + } + } + // Search debounce private var searchJob: Job? = null private val filterPublication = LibraryFilterPublication() @@ -439,6 +490,7 @@ class LibraryViewModel @Inject constructor( // ─── Loading ────────────────────────────────────────────────────────── private suspend fun loadLibraries(keepPendingPick: Boolean = false) { + shelfLoadedFor = settingsManager.currentSettings.shelfIdentity() _uiState.update { it.copy(isLoading = true, errorMessage = null) } localCatalogBaseline.await() @@ -490,6 +542,9 @@ class LibraryViewModel @Inject constructor( updateSettings = settingsManager::updateSettings, ) val selected = selection.library + // The selection this load settled on, so a return does not + // mistake its own write for a change made in Settings. + shelfLoadedFor = settingsManager.currentSettings.shelfIdentity() _uiState.update { it.withLibrarySelection( @@ -536,6 +591,9 @@ class LibraryViewModel @Inject constructor( try { val serverUrlAtStart = settingsManager.currentSettings.serverUrl val selected = _uiState.value.selectedLibrary + // Show the saved shelf before the network. A full fetch of a big + // library takes minutes, and the spinner only covers an empty shelf. + if (selected?.isLocal != true) applyFilter()?.join() // Only hit the network when a remote library is selected AND we have // connectivity. In airplane mode the old code attempted syncLibraryItems // regardless, leaving the switch spinning on a doomed request until the @@ -651,6 +709,7 @@ class LibraryViewModel @Inject constructor( } } if (pendingLibraryPickId == library.id) pendingLibraryPickId = null + shelfLoadedFor = settingsManager.currentSettings.shelfIdentity() // Full resync for the newly selected library loadAudioBooks(library.id) _uiState.update { it.copy(isLoading = false) } @@ -873,7 +932,8 @@ class LibraryViewModel @Inject constructor( return filterPublication.launch( scope = viewModelScope, request = snapshot.request, - load = { buildFilterResult(snapshot) }, + // Decoding and sorting thousands of books stays off the main thread. + load = { withContext(Dispatchers.Default) { buildFilterResult(snapshot) } }, onFailure = { e -> _uiState.update { it.copy(errorMessage = "Failed to load audiobooks: ${e.message}") } }, diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/BottomNavBar.kt b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/BottomNavBar.kt index beb047a8..8e5cb92b 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/BottomNavBar.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/BottomNavBar.kt @@ -96,10 +96,15 @@ fun BottomNavBar( selected = isSelected, onClick = { navController.navigate(item.route) { + // Each tab keeps its screen, scroll, and loaded shelf + // while another tab is open. Without this, leaving the + // Library threw it away and a big library re-downloaded + // on every return. popUpTo(navController.graph.findStartDestination().id) { - inclusive = false + saveState = true } launchSingleTop = true + restoreState = true } }, icon = { diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/LeftNavRail.kt b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/LeftNavRail.kt index 596209e6..0303c837 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/LeftNavRail.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/LeftNavRail.kt @@ -52,10 +52,15 @@ fun LeftNavRail( isSelected = isSelected, onClick = { navController.navigate(item.route) { + // Each tab keeps its screen, scroll, and loaded shelf + // while another tab is open. Without this, leaving the + // Library threw it away and a big library re-downloaded + // on every return. popUpTo(navController.graph.findStartDestination().id) { - inclusive = false + saveState = true } launchSingleTop = true + restoreState = true } } ) diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryReturnReloadTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryReturnReloadTest.kt new file mode 100644 index 00000000..c9b45684 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryReturnReloadTest.kt @@ -0,0 +1,58 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AppSettings +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The tabs keep the Library alive while another tab is open. A server with + * thousands of books took minutes to re-download on every return, so a return + * now keeps the shelf unless what it was loaded for changed. + */ +class LibraryReturnReloadTest { + + private val shown = ShelfIdentity(AppMode.AUDIOBOOKSHELF, "https://abs", "jeff", "books") + + @Test + fun `an unchanged return keeps the shelf`() { + assertFalse(shouldReloadOnLibraryReturn(shown, shown, lastFetchFailed = false)) + } + + @Test + fun `a library picked in Settings reloads`() { + assertTrue(shouldReloadOnLibraryReturn(shown, shown.copy(activeLibraryId = "podcasts"), false)) + } + + @Test + fun `a server, account, or mode switch reloads`() { + assertTrue(shouldReloadOnLibraryReturn(shown, shown.copy(serverUrl = "https://other"), false)) + assertTrue(shouldReloadOnLibraryReturn(shown, shown.copy(username = "guest"), false)) + assertTrue(shouldReloadOnLibraryReturn(shown, shown.copy(appMode = AppMode.LOCAL), false)) + } + + @Test + fun `an empty failed shelf retries on return`() { + assertTrue(shouldReloadOnLibraryReturn(shown, shown, lastFetchFailed = true)) + } + + @Test + fun `the first entry leaves the shelf to the load already starting`() { + assertFalse(shouldReloadOnLibraryReturn(null, shown, lastFetchFailed = true)) + } + + @Test + fun `identity follows the active mode's library`() { + val settings = AppSettings( + appMode = AppMode.LOCAL, + serverUrl = "https://abs", + username = "jeff", + selectedLibraryId = "books", + selectedLocalLibraryId = "folder", + ) + assertEquals("folder", settings.shelfIdentity().activeLibraryId) + assertEquals("books", settings.copy(appMode = AppMode.AUDIOBOOKSHELF).shelfIdentity().activeLibraryId) + } +} From 4ed14871aaf34a5d1847ca445def3dc7abaac6f9 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 20:14:54 -0400 Subject: [PATCH 02/69] A Library return re-reads the saved shelf and retries only an empty one Review round 1: progress played in another tab and a cache cleared in Settings now show on return through a Room re-read with no network, and a shelf filtered to nothing no longer counts as empty for the retry. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/library/LibraryViewModel.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 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 f207bb6c..90731503 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 @@ -327,13 +327,16 @@ class LibraryViewModel @Inject constructor( /** * Called by LibraryScreen each time it enters composition. A tab return - * keeps the shelf it already has instead of downloading the library again. + * keeps the shelf it already has instead of downloading the library again, + * and re-reads it from Room so progress played elsewhere and a cache + * cleared in Settings show up. Room changes outside a sync are not watched. */ fun onScreenEntered() { val state = _uiState.value - // Only an empty failed shelf retries on return. One with saved books - // keeps them, and the periodic and reconnect syncs recover it. - val lastFetchFailed = state.filteredBooks.isEmpty() && + // Only a failed library with nothing saved retries on return. A saved + // shelf, even one filtered to nothing, keeps its books, and the + // periodic and reconnect syncs recover it. + val lastFetchFailed = state.totalBookCount == 0 && (state.selectedLibraryFetchResult == SyncResult.FAILED || state.errorMessage != null) if (shouldReloadOnLibraryReturn( loadedFor = shelfLoadedFor, @@ -343,6 +346,8 @@ class LibraryViewModel @Inject constructor( ) { // This can replace a refresh in the lane, so it owns that spinner. libraryLoadLaunch.launch(viewModelScope) { loadLibrariesOwningRefresh() } + } else if (shelfLoadedFor != null && !state.isLoading) { + applyFilter() } } From 2305c537374da221852e3ebb152201890b6e20b2 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 20:16:16 -0400 Subject: [PATCH 03/69] A cold start shows the saved shelf before the server check The saved library's cached shelf now publishes before the /ping probe and the library list fetch, so a gone server no longer holds first paint behind serialized 5s probes. Closes #53. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/library/LibraryViewModel.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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 90731503..06803736 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 @@ -507,6 +507,10 @@ class LibraryViewModel @Inject constructor( // The item load below reuses this probe so a dead server costs one // 5s wait on a cold start, not two back to back. var serverReachable: Boolean? = null + // Show the saved shelf before the /ping probe and the library list + // fetch. A gone server costs a 5s probe per check, and the gate + // runs them one after another (issue #53). + if (!isLocalMode) showSavedShelfBeforeNetwork(settings) val libs = if (isLocalMode) { libraryRepository.getLocalLibraries() } else { @@ -587,6 +591,27 @@ class LibraryViewModel @Inject constructor( } } + /** + * Publishes the cached shelf of the saved library selection, if it is + * cached. The load that follows still resolves and persists the real + * selection and replaces this shelf. + */ + private suspend fun showSavedShelfBeforeNetwork(settings: AppSettings) { + val cachedLibraries = visibleCachedLibraries( + settings = settings, + cached = libraryRepository.getAudiobookshelf(), + ) + val saved = cachedLibraries.firstOrNull { it.id == settings.activeLibraryId } ?: return + _uiState.update { + it.withLibrarySelection( + libraries = cachedLibraries, + selectedLibrary = saved, + isLocalMode = false, + ) + } + applyFilter()?.join() + } + private suspend fun loadAudioBooks( libraryId: String, persistResult: Boolean = true, From 17260daf16a3a2bf976f4fb21350be038c7dc59e Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 20:54:25 -0400 Subject: [PATCH 04/69] A Library return retries when no library or no saved book came back Review round 2: a failed library list left nothing selected and no selected-library verdict, so a return skipped the reload and showed an empty shelf until the next background sync. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/library/LibraryViewModel.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 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 06803736..bf9abf6f 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 @@ -333,11 +333,17 @@ class LibraryViewModel @Inject constructor( */ fun onScreenEntered() { val state = _uiState.value - // Only a failed library with nothing saved retries on return. A saved - // shelf, even one filtered to nothing, keeps its books, and the + // Only a shelf with nothing saved retries on return: no library came + // back at all, or the selected one failed with no books cached. A + // saved shelf, even one filtered to nothing, keeps its books, and the // periodic and reconnect syncs recover it. - val lastFetchFailed = state.totalBookCount == 0 && - (state.selectedLibraryFetchResult == SyncResult.FAILED || state.errorMessage != null) + val lastFetchFailed = state.selectedLibrary == null || ( + state.totalBookCount == 0 && ( + state.selectedLibraryFetchResult == SyncResult.FAILED || + state.lastSyncResult == SyncResult.FAILED || + state.errorMessage != null + ) + ) if (shouldReloadOnLibraryReturn( loadedFor = shelfLoadedFor, current = settingsManager.currentSettings.shelfIdentity(), From d33024d114149d89931cb9edbe9768ba4d48c169 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 20:48:32 -0400 Subject: [PATCH 05/69] A folder scan finishes even after leaving Settings Add Folder and Rescan ran in the Settings screen's own scope, so leaving Settings by Back or a tab switch cancelled the scan and nothing was imported. The scan and import now run in an app-wide coordinator, and Settings only shows its progress and result. A result that lands while Settings is closed shows the next time Settings opens. The import and reconcile rules are unchanged. Closes #57. Co-Authored-By: Claude Opus 5.5 --- .../app/service/local/LocalScanCoordinator.kt | 107 +++++++++ .../app/service/local/LocalScanImporter.kt | 132 +++++++++++ .../app/ui/settings/SettingsViewModel.kt | 212 ++++++------------ .../service/local/LocalScanCoordinatorTest.kt | 114 ++++++++++ .../app/ui/settings/LocalScanUiStateTest.kt | 83 +++++++ 5 files changed, 500 insertions(+), 148 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/service/local/LocalScanCoordinator.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/service/local/LocalScanImporter.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/local/LocalScanCoordinatorTest.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/settings/LocalScanUiStateTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/service/local/LocalScanCoordinator.kt b/app/src/main/java/com/ninelivesaudio/app/service/local/LocalScanCoordinator.kt new file mode 100644 index 00000000..6e182951 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/service/local/LocalScanCoordinator.kt @@ -0,0 +1,107 @@ +package com.ninelivesaudio.app.service.local + +import com.ninelivesaudio.app.domain.model.Library +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicLong +import javax.inject.Inject +import javax.inject.Singleton + +/** A folder scan the user started from Settings. */ +sealed interface LocalScanRequest { + /** Add Folder: scan a newly picked folder and create its library. */ + data class AddFolder(val folderUri: String) : LocalScanRequest + + /** Rescan: scan an existing library's folder again. */ + data class Rescan(val library: Library) : LocalScanRequest +} + +/** What a finished scan imported into [library]. */ +data class LocalScanImport( + val library: Library, + val scanResult: LocalLibraryScanner.ScanResult, +) + +sealed interface LocalScanState { + data object Idle : LocalScanState + + data class Running(val request: LocalScanRequest) : LocalScanState + + /** + * A scan ended. Exactly one of [imported] and [failure] is set. It stays + * here until Settings shows it and calls [LocalScanCoordinator.acknowledge], + * so a scan that finished while Settings was closed still reports when + * Settings opens again. + */ + data class Finished( + val id: Long, + val request: LocalScanRequest, + val imported: LocalScanImport?, + val failure: String?, + ) : LocalScanState +} + +/** + * Owns Add Folder and Rescan for the whole app (issue #57). They used to run + * in the Settings screen's own scope, so leaving Settings mid-scan cancelled + * the scan and nothing was imported. This scope lives as long as the app, so + * the scan finishes wherever the user goes, and Settings just watches [state]. + * + * One scan at a time. Settings disables its buttons while one runs, and a + * second request that slips through is refused rather than racing the first + * one's reconciliation. + */ +@Singleton +class LocalScanCoordinator internal constructor( + private val scope: CoroutineScope, + private val work: suspend (LocalScanRequest) -> LocalScanImport, +) { + @Inject + constructor(importer: LocalScanImporter) : this( + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + work = importer::run, + ) + + private val _state = MutableStateFlow(LocalScanState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val finishedIds = AtomicLong() + + /** Starts [request]. Returns false if a scan is already running. */ + fun start(request: LocalScanRequest): Boolean { + val running = LocalScanState.Running(request) + while (true) { + val current = _state.value + if (current is LocalScanState.Running) return false + if (_state.compareAndSet(current, running)) break + } + scope.launch { + val finished = try { + val imported = work(request) + LocalScanState.Finished(finishedIds.incrementAndGet(), request, imported, failure = null) + } catch (e: Exception) { + LocalScanState.Finished( + finishedIds.incrementAndGet(), + request, + imported = null, + failure = e.message ?: e.javaClass.simpleName, + ) + } + _state.value = finished + } + return true + } + + /** Settings showed the result of scan [id], so it can go back to Idle. */ + fun acknowledge(id: Long) { + val current = _state.value + if (current is LocalScanState.Finished && current.id == id) { + _state.compareAndSet(current, LocalScanState.Idle) + } + } +} diff --git a/app/src/main/java/com/ninelivesaudio/app/service/local/LocalScanImporter.kt b/app/src/main/java/com/ninelivesaudio/app/service/local/LocalScanImporter.kt new file mode 100644 index 00000000..8bf012aa --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/service/local/LocalScanImporter.kt @@ -0,0 +1,132 @@ +package com.ninelivesaudio.app.service.local + +import android.net.Uri +import com.ninelivesaudio.app.data.repository.AudioBookRepository +import com.ninelivesaudio.app.data.repository.LibraryRepository +import com.ninelivesaudio.app.domain.model.Library +import com.ninelivesaudio.app.service.SettingsManager +import com.ninelivesaudio.app.ui.settings.failEmptyScanWithErrors +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The work behind Add Folder and Rescan: scan the folder, import what it + * found, and reconcile the library against it. [LocalScanCoordinator] runs + * this in an app-wide scope so leaving Settings no longer throws a scan away + * (issue #57). Settings only shows the result. + */ +@Singleton +class LocalScanImporter @Inject constructor( + private val localScanner: LocalLibraryScanner, + private val localMetadataExtractor: LocalMetadataExtractor, + private val libraryRepository: LibraryRepository, + private val audioBookRepository: AudioBookRepository, + private val settingsManager: SettingsManager, +) { + + suspend fun run(request: LocalScanRequest): LocalScanImport = when (request) { + is LocalScanRequest.AddFolder -> addFolder(request.folderUri) + is LocalScanRequest.Rescan -> rescan(request.library) + } + + private suspend fun addFolder(uriString: String): LocalScanImport { + // Derive a display name from the URI path + val uri = Uri.parse(uriString) + val displayName = uri.lastPathSegment + ?.substringAfterLast(':') + ?.substringAfterLast('/') + ?.ifBlank { null } + ?: "Local Library" + + val scanResult = withContext(Dispatchers.IO) { localScanner.scan(uri) } + failEmptyScanWithErrors(scanResult) + + // Create or reuse the local library row after confirming the folder is readable. + val library = libraryRepository.createLocalLibrary(displayName, uriString) + + importAndReconcile(library.id, scanResult) + + // Select this library + settingsManager.updateSettings { + it.copy(selectedLocalLibraryId = library.id) + } + return LocalScanImport(library, scanResult) + } + + private suspend fun rescan(library: Library): LocalScanImport { + val folderUri = requireNotNull(library.folderUri) { "This library has no folder" } + val scanResult = withContext(Dispatchers.IO) { localScanner.scan(Uri.parse(folderUri)) } + failEmptyScanWithErrors(scanResult) + + importAndReconcile(library.id, scanResult) + return LocalScanImport(library, scanResult) + } + + /** + * Import what the scan found, then reconcile the library against it: any + * local book whose folder was not seen in this scan leaves the library + * (issue #20). Without this a rescan only ever adds, so moving a folder one + * level deeper leaves a ghost entry pointing at files that are gone, and + * playing it fails. + * + * Removal is gated on [LocalLibraryScanner.ScanResult.coverageComplete], NOT + * on "the scan reported no warnings". The distinction is the whole safety + * argument: a folder that could not be listed, a revoked permission, or a + * traversal cut short by the depth or folder cap all mean books exist that + * this scan never saw, and absence from a partial inventory is not evidence + * a book is gone. Those scans import and reconcile nothing. A warning that + * is not a coverage gap (a single unreadable file, whose id the scan hands + * back as retained) no longer freezes the whole library forever. + * + * "Leaves the library" means archived, not deleted. A folder can come back: + * the card is out of the library and its Archive tab entry keeps the cover, + * progress, and history, and re-adding the folder restores it in place. The + * Orphaned Books sweep in Settings stays the one path that actually + * deletes rows, and it already cascades progress, sessions, bookmarks, and + * the local_covers file. + */ + private suspend fun importAndReconcile( + libraryId: String, + scanResult: LocalLibraryScanner.ScanResult, + ) { + val books = scanResult.books.map { it.toAudioBook(libraryId) } + val seenIds = scanResult.seenBookIds() + // Captured before the import, so a book that arrived in THIS scan is + // distinguishable from one that was already in the library. + val existingIds = if (scanResult.coverageComplete) { + audioBookRepository.getLiveLocalIds(libraryId) + } else { + emptyList() + } + + audioBookRepository.importLocalBooksCarryingMoves( + libraryId = libraryId, + books = books, + existingIds = existingIds, + seenIds = seenIds, + ) + + if (!scanResult.coverageComplete) return + + archiveMissingBooks(libraryId, seenIds) + } + + /** + * The single archive choke point: before soft-deleting the books missing + * from [scannedIds], copy any still-readable content:// folder cover to + * durable storage. Both archive triggers (whole-folder Remove and the + * missing-books pass after a rescan) route through here, so a book scanned + * under an older build keeps its cover once archived — the folder is still + * accessible at this point (permission not yet released; a rescan only + * archives what a clean scan reported missing). Already-durable and + * unreadable covers are left as-is (best-effort). + */ + suspend fun archiveMissingBooks(libraryId: String, scannedIds: List) { + audioBookRepository.persistFolderCovers(libraryId) { uri, id -> + localMetadataExtractor.persistFolderCover(uri, id) + } + audioBookRepository.removeMissingLocalBooks(libraryId, scannedIds) + } +} 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 f3512cd0..a0ec120d 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 @@ -34,8 +34,10 @@ import com.ninelivesaudio.app.service.SettingsManager import com.ninelivesaudio.app.service.SyncManager import com.ninelivesaudio.app.service.local.LocalLibraryScanner import com.ninelivesaudio.app.service.local.LocalFolderAccess -import com.ninelivesaudio.app.service.local.LocalMetadataExtractor -import com.ninelivesaudio.app.service.local.toAudioBook +import com.ninelivesaudio.app.service.local.LocalScanCoordinator +import com.ninelivesaudio.app.service.local.LocalScanImporter +import com.ninelivesaudio.app.service.local.LocalScanRequest +import com.ninelivesaudio.app.service.local.LocalScanState import com.ninelivesaudio.app.settings.unhinged.UnhingedSettingsRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -192,8 +194,8 @@ class SettingsViewModel @Inject constructor( private val unhingedRepository: UnhingedSettingsRepository, private val syncManager: SyncManager, private val playbackManager: PlaybackManager, - private val localScanner: LocalLibraryScanner, - private val localMetadataExtractor: LocalMetadataExtractor, + private val localScanImporter: LocalScanImporter, + private val localScanCoordinator: LocalScanCoordinator, private val localFolderAccess: LocalFolderAccess, ) : ViewModel() { @@ -366,6 +368,15 @@ class SettingsViewModel @Inject constructor( } } + // Follow the app-wide folder scan. A result that landed while + // Settings was closed shows once here, then is acknowledged. + viewModelScope.launch { + localScanCoordinator.state.collect { scan -> + _uiState.update { it.withLocalScan(scan) } + if (scan is LocalScanState.Finished) localScanCoordinator.acknowledge(scan.id) + } + } + // Load settings on init viewModelScope.launch { initialize() @@ -537,86 +548,14 @@ class SettingsViewModel @Inject constructor( * before passing the URI string here. */ fun onLocalFolderPicked(uriString: String) { - viewModelScope.launch { - _uiState.update { it.copy(isScanning = true, lastScanMessage = null, errorMessage = null) } - try { - // Derive a display name from the URI path - val uri = Uri.parse(uriString) - val displayName = uri.lastPathSegment - ?.substringAfterLast(':') - ?.substringAfterLast('/') - ?.ifBlank { null } - ?: "Local Library" - - val scanResult = withContext(Dispatchers.IO) { - localScanner.scan(uri) - } - failEmptyScanWithErrors(scanResult) - - // Create or reuse the local library row after confirming the folder is readable. - val library = libraryRepository.createLocalLibrary(displayName, uriString) - - importAndReconcile(library.id, scanResult) - - // Select this library - settingsManager.updateSettings { - it.copy(selectedLocalLibraryId = library.id) - } - - val outcome = buildScanOutcome(scanResult, countedAs = "imported") - - _uiState.update { - it.copy( - isScanning = false, - lastScanMessage = outcome.lastScanMessage, - selectedLocalLibrary = library, - inaccessibleLocalLibraryIds = it.inaccessibleLocalLibraryIds - library.id, - successMessage = outcome.successMessage, - errorMessage = outcome.errorMessage, - ) - } - } catch (e: Exception) { - _uiState.update { - it.copy( - isScanning = false, - errorMessage = "Scan failed: ${e.message}", - ) - } - } - } + // The scan runs app-wide so leaving Settings cannot cancel it (#57). + // The state collector in init shows its progress and result. + localScanCoordinator.start(LocalScanRequest.AddFolder(uriString)) } fun rescanLocalLibrary(library: Library) { - val folderUri = library.folderUri ?: return - viewModelScope.launch { - _uiState.update { it.copy(isScanning = true, lastScanMessage = null, errorMessage = null) } - try { - val scanResult = withContext(Dispatchers.IO) { - localScanner.scan(Uri.parse(folderUri)) - } - failEmptyScanWithErrors(scanResult) - - importAndReconcile(library.id, scanResult) - - val outcome = buildScanOutcome(scanResult, countedAs = "found") { "Rescan complete: $it" } - - _uiState.update { - it.copy( - isScanning = false, - lastScanMessage = outcome.lastScanMessage, - successMessage = outcome.successMessage, - errorMessage = outcome.errorMessage, - ) - } - } catch (e: Exception) { - _uiState.update { - it.copy( - isScanning = false, - errorMessage = "Rescan failed: ${e.message}", - ) - } - } - } + if (library.folderUri == null) return + localScanCoordinator.start(LocalScanRequest.Rescan(library)) } fun removeLocalLibrary(library: Library) { @@ -630,7 +569,7 @@ class SettingsViewModel @Inject constructor( // re-adding the same folder re-imports and clears ArchivedAt // (restore). "Delete forever" / the archive sweep is the path // to actually remove them. - archiveMissingBooks(library.id, scannedIds = emptyList()) + localScanImporter.archiveMissingBooks(library.id, scannedIds = emptyList()) releaseSafPermission(library.folderUri) moveSelectionOffLibrary(library) @@ -786,72 +725,6 @@ class SettingsViewModel @Inject constructor( return localLibraries.firstOrNull { it.id == savedLocalId } ?: localLibraries.firstOrNull() } - /** - * Import what the scan found, then reconcile the library against it: any - * local book whose folder was not seen in this scan leaves the library - * (issue #20). Without this a rescan only ever adds, so moving a folder one - * level deeper leaves a ghost entry pointing at files that are gone, and - * playing it fails. - * - * Removal is gated on [LocalLibraryScanner.ScanResult.coverageComplete], NOT - * on "the scan reported no warnings". The distinction is the whole safety - * argument: a folder that could not be listed, a revoked permission, or a - * traversal cut short by the depth or folder cap all mean books exist that - * this scan never saw, and absence from a partial inventory is not evidence - * a book is gone. Those scans import and reconcile nothing. A warning that - * is not a coverage gap (a single unreadable file, whose id the scan hands - * back as retained) no longer freezes the whole library forever. - * - * "Leaves the library" means archived, not deleted. A folder can come back: - * the card is out of the library and its Archive tab entry keeps the cover, - * progress, and history, and re-adding the folder restores it in place. The - * Orphaned Books sweep in this screen stays the one path that actually - * deletes rows, and it already cascades progress, sessions, bookmarks, and - * the local_covers file. - */ - private suspend fun importAndReconcile( - libraryId: String, - scanResult: LocalLibraryScanner.ScanResult, - ) { - val books = scanResult.books.map { it.toAudioBook(libraryId) } - val seenIds = scanResult.seenBookIds() - // Captured before the import, so a book that arrived in THIS scan is - // distinguishable from one that was already in the library. - val existingIds = if (scanResult.coverageComplete) { - audioBookRepository.getLiveLocalIds(libraryId) - } else { - emptyList() - } - - audioBookRepository.importLocalBooksCarryingMoves( - libraryId = libraryId, - books = books, - existingIds = existingIds, - seenIds = seenIds, - ) - - if (!scanResult.coverageComplete) return - - archiveMissingBooks(libraryId, seenIds) - } - - /** - * The single archive choke point: before soft-deleting the books missing - * from [scannedIds], copy any still-readable content:// folder cover to - * durable storage. Both archive triggers (whole-folder Remove and the - * missing-books pass after a rescan) route through here, so a book scanned - * under an older build keeps its cover once archived — the folder is still - * accessible at this point (permission not yet released; a rescan only - * archives what a clean scan reported missing). Already-durable and - * unreadable covers are left as-is (best-effort). - */ - private suspend fun archiveMissingBooks(libraryId: String, scannedIds: List) { - audioBookRepository.persistFolderCovers(libraryId) { uri, id -> - localMetadataExtractor.persistFolderCover(uri, id) - } - audioBookRepository.removeMissingLocalBooks(libraryId, scannedIds) - } - // ─── User Actions ───────────────────────────────────────────────────── fun onServerUrlChanged(value: String) { @@ -1982,6 +1855,49 @@ internal fun buildScanOutcome( ) } +/** + * How the app-wide folder scan shows in Settings (#57). Running clears the + * last result and shows "Scanning folder...". Finished shows the same + * summary and banners the scan showed when it ran inside this screen, and + * Add Folder also selects the library it just imported. + */ +internal fun SettingsViewModel.UiState.withLocalScan( + scan: LocalScanState, +): SettingsViewModel.UiState = when (scan) { + LocalScanState.Idle -> copy(isScanning = false) + is LocalScanState.Running -> copy(isScanning = true, lastScanMessage = null, errorMessage = null) + is LocalScanState.Finished -> { + val rescan = scan.request is LocalScanRequest.Rescan + val imported = scan.imported + if (imported == null) { + copy( + isScanning = false, + errorMessage = (if (rescan) "Rescan failed: " else "Scan failed: ") + scan.failure, + ) + } else { + val outcome = if (rescan) { + buildScanOutcome(imported.scanResult, countedAs = "found") { "Rescan complete: $it" } + } else { + buildScanOutcome(imported.scanResult, countedAs = "imported") + } + val shown = copy( + isScanning = false, + lastScanMessage = outcome.lastScanMessage, + successMessage = outcome.successMessage, + errorMessage = outcome.errorMessage, + ) + if (rescan) { + shown + } else { + shown.copy( + selectedLocalLibrary = imported.library, + inaccessibleLocalLibraryIds = inaccessibleLocalLibraryIds - imported.library.id, + ) + } + } + } +} + private fun withArchiveHint(message: String, archiveFileCount: Int): String { val hint = when (archiveFileCount) { 0 -> return message diff --git a/app/src/test/java/com/ninelivesaudio/app/service/local/LocalScanCoordinatorTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/local/LocalScanCoordinatorTest.kt new file mode 100644 index 00000000..6abb3322 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/local/LocalScanCoordinatorTest.kt @@ -0,0 +1,114 @@ +package com.ninelivesaudio.app.service.local + +import com.ninelivesaudio.app.domain.model.Library +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Leaving Settings mid-scan used to cancel the scan, so nothing imported + * (#57). The coordinator runs the scan in its own app-wide scope and keeps + * the result until Settings shows it. + */ +class LocalScanCoordinatorTest { + + private val library = Library(id = "folder", name = "Audiobooks", isLocal = true, folderUri = "content://tree/a") + private val scanResult = LocalLibraryScanner.ScanResult( + books = emptyList(), + skippedCount = 0, + errorMessages = emptyList(), + foldersScanned = 1, + ) + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private fun coordinator(work: suspend (LocalScanRequest) -> LocalScanImport) = + LocalScanCoordinator(appScope, work) + + private suspend fun LocalScanCoordinator.awaitFinished(): LocalScanState.Finished = + withTimeout(5_000) { state.first { it is LocalScanState.Finished } as LocalScanState.Finished } + + @Test + fun `a scan started from a screen finishes after that screen is gone`() = runBlocking { + val release = CompletableDeferred() + var imported = false + val coordinator = coordinator { + release.await() + imported = true + LocalScanImport(library, scanResult) + } + + // Settings' own scope starts the scan, then is cleared as the user leaves. + val screenScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + screenScope.launch { coordinator.start(LocalScanRequest.Rescan(library)) }.join() + screenScope.cancel() + release.complete(Unit) + + val finished = coordinator.awaitFinished() + assertTrue(imported) + assertEquals(library, finished.imported?.library) + assertNull(finished.failure) + } + + @Test + fun `the scan shows as running until it ends`() = runBlocking { + val release = CompletableDeferred() + val coordinator = coordinator { release.await(); LocalScanImport(library, scanResult) } + val request = LocalScanRequest.AddFolder("content://tree/a") + + assertTrue(coordinator.start(request)) + assertEquals(LocalScanState.Running(request), coordinator.state.value) + + release.complete(Unit) + assertEquals(request, coordinator.awaitFinished().request) + } + + @Test + fun `a second scan while one runs is refused`() = runBlocking { + val release = CompletableDeferred() + var runs = 0 + val coordinator = coordinator { runs++; release.await(); LocalScanImport(library, scanResult) } + + assertTrue(coordinator.start(LocalScanRequest.Rescan(library))) + assertFalse(coordinator.start(LocalScanRequest.AddFolder("content://tree/b"))) + + release.complete(Unit) + coordinator.awaitFinished() + assertEquals(1, runs) + assertTrue(coordinator.start(LocalScanRequest.Rescan(library))) + } + + @Test + fun `a failed scan reports why`() = runBlocking { + val coordinator = coordinator { throw IllegalStateException("Permission revoked") } + + coordinator.start(LocalScanRequest.Rescan(library)) + + val finished = coordinator.awaitFinished() + assertNull(finished.imported) + assertEquals("Permission revoked", finished.failure) + } + + @Test + fun `a result stays until Settings acknowledges it`() = runBlocking { + val coordinator = coordinator { LocalScanImport(library, scanResult) } + coordinator.start(LocalScanRequest.Rescan(library)) + val finished = coordinator.awaitFinished() + + coordinator.acknowledge(finished.id + 1) + assertEquals(finished, coordinator.state.value) + + coordinator.acknowledge(finished.id) + assertEquals(LocalScanState.Idle, coordinator.state.value) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/settings/LocalScanUiStateTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/settings/LocalScanUiStateTest.kt new file mode 100644 index 00000000..77d3ded3 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/settings/LocalScanUiStateTest.kt @@ -0,0 +1,83 @@ +package com.ninelivesaudio.app.ui.settings + +import com.ninelivesaudio.app.domain.model.Library +import com.ninelivesaudio.app.service.local.LocalLibraryScanner +import com.ninelivesaudio.app.service.local.LocalScanImport +import com.ninelivesaudio.app.service.local.LocalScanRequest +import com.ninelivesaudio.app.service.local.LocalScanState +import com.ninelivesaudio.app.service.local.ScannedLocalBook +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The folder scan runs app-wide now (#57), and Settings only shows it. These + * pin that Settings shows the same thing it did when the scan ran inside it. + */ +class LocalScanUiStateTest { + + private val library = Library(id = "folder", name = "Audiobooks", isLocal = true, folderUri = "content://tree/a") + private val tenBooks = LocalLibraryScanner.ScanResult( + books = (1..10).map { ScannedLocalBook(id = "b$it", title = "b$it", author = "A", tracks = emptyList()) }, + skippedCount = 0, + errorMessages = emptyList(), + foldersScanned = 10, + ) + + @Test + fun `a running scan clears the last result`() { + val before = SettingsViewModel.UiState(lastScanMessage = "9 books found", errorMessage = "old") + + val shown = before.withLocalScan(LocalScanState.Running(LocalScanRequest.Rescan(library))) + + assertTrue(shown.isScanning) + assertNull(shown.lastScanMessage) + assertNull(shown.errorMessage) + } + + @Test + fun `a finished rescan shows its count`() { + val finished = LocalScanState.Finished( + id = 1, + request = LocalScanRequest.Rescan(library), + imported = LocalScanImport(library, tenBooks), + failure = null, + ) + + val shown = SettingsViewModel.UiState(isScanning = true).withLocalScan(finished) + + assertFalse(shown.isScanning) + assertEquals("10 books found", shown.lastScanMessage) + assertEquals("Rescan complete: 10 books found", shown.successMessage) + } + + @Test + fun `a finished Add Folder selects the new library`() { + val finished = LocalScanState.Finished( + id = 1, + request = LocalScanRequest.AddFolder("content://tree/a"), + imported = LocalScanImport(library, tenBooks), + failure = null, + ) + + val shown = SettingsViewModel.UiState(isScanning = true, inaccessibleLocalLibraryIds = setOf("folder")) + .withLocalScan(finished) + + assertEquals(library, shown.selectedLocalLibrary) + assertTrue(shown.inaccessibleLocalLibraryIds.isEmpty()) + assertEquals("10 books imported", shown.successMessage) + } + + @Test + fun `a failed scan says which action failed`() { + fun failed(request: LocalScanRequest) = + LocalScanState.Finished(id = 1, request = request, imported = null, failure = "No access") + + val start = SettingsViewModel.UiState(isScanning = true) + assertEquals("Rescan failed: No access", start.withLocalScan(failed(LocalScanRequest.Rescan(library))).errorMessage) + assertEquals("Scan failed: No access", start.withLocalScan(failed(LocalScanRequest.AddFolder("u"))).errorMessage) + assertFalse(start.withLocalScan(failed(LocalScanRequest.Rescan(library))).isScanning) + } +} From 121e495c006668798342eb2143cc5653f62fc177 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 20:48:32 -0400 Subject: [PATCH 06/69] First-run tab taps replace the last tab instead of piling up The tabs popped to the graph's start destination, which is Welcome on first run and is gone once onboarding finishes, so every tab tap pushed a new screen. Onboarding now leaves Home under the screen it lands on, and the tabs, mini player, and book detail pop to Home, which is on the stack on every launch. Back goes Home and then out of the app, the same as a normal launch. Closes #58. Co-Authored-By: Claude Opus 5.5 --- .../com/ninelivesaudio/app/MainActivity.kt | 10 +--- .../app/ui/navigation/BottomNavBar.kt | 13 +---- .../app/ui/navigation/LeftNavRail.kt | 13 +---- .../app/ui/navigation/NineLivesNavHost.kt | 8 +--- .../app/ui/navigation/TabNavigation.kt | 47 +++++++++++++++++++ .../app/ui/navigation/TabNavigationTest.kt | 37 +++++++++++++++ 6 files changed, 90 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/ui/navigation/TabNavigation.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/navigation/TabNavigationTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt b/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt index 727388f1..a3070003 100644 --- a/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt +++ b/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope -import androidx.navigation.NavGraph.Companion.findStartDestination +import com.ninelivesaudio.app.ui.navigation.navigateToTab import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.lifecycle.Lifecycle @@ -250,13 +250,7 @@ class MainActivity : ComponentActivity() { MiniPlayer( playbackManager = playbackManager, onNavigateToPlayer = { - navController.navigate(Routes.PLAYER) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } + navController.navigateToTab(Routes.PLAYER) } ) } diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/BottomNavBar.kt b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/BottomNavBar.kt index 8e5cb92b..cebc839e 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/BottomNavBar.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/BottomNavBar.kt @@ -22,7 +22,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.vector.ImageVector import androidx.navigation.NavController import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.currentBackStackEntryAsState import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.ui.theme.NineLivesTheme @@ -95,17 +94,7 @@ fun BottomNavBar( NavigationBarItem( selected = isSelected, onClick = { - navController.navigate(item.route) { - // Each tab keeps its screen, scroll, and loaded shelf - // while another tab is open. Without this, leaving the - // Library threw it away and a big library re-downloaded - // on every return. - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } + navController.navigateToTab(item.route) }, icon = { Icon( diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/LeftNavRail.kt b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/LeftNavRail.kt index 0303c837..4438eba2 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/LeftNavRail.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/LeftNavRail.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.navigation.NavController import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.currentBackStackEntryAsState import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.ui.theme.NineLivesTheme @@ -51,17 +50,7 @@ fun LeftNavRail( item = item, isSelected = isSelected, onClick = { - navController.navigate(item.route) { - // Each tab keeps its screen, scroll, and loaded shelf - // while another tab is open. Without this, leaving the - // Library threw it away and a big library re-downloaded - // on every return. - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } + navController.navigateToTab(item.route) } ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/NineLivesNavHost.kt b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/NineLivesNavHost.kt index 1992579d..4ffcd350 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/NineLivesNavHost.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/NineLivesNavHost.kt @@ -3,7 +3,6 @@ package com.ninelivesaudio.app.ui.navigation import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -61,10 +60,7 @@ fun NineLivesNavHost( val route = when (destination) { OnboardingDestination.SETTINGS -> Routes.SETTINGS } - navController.navigate(route) { - popUpTo(Routes.WELCOME) { inclusive = true } - launchSingleTop = true - } + navController.finishOnboarding(route) } WelcomeScreen( onChooseLocal = { @@ -211,7 +207,7 @@ fun NineLivesNavHost( }, onNavigateToPlayer = { navController.navigate(Routes.PLAYER) { - popUpTo(navController.graph.findStartDestination().id) { + popUpTo(TAB_ROOT_ROUTE) { inclusive = false } launchSingleTop = true diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/TabNavigation.kt b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/TabNavigation.kt new file mode 100644 index 00000000..83faec60 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/TabNavigation.kt @@ -0,0 +1,47 @@ +package com.ninelivesaudio.app.ui.navigation + +import androidx.navigation.NavController + +/** + * The screen every tab sits on top of, on every launch. The tabs used to pop + * to the graph's start destination, but on first run that is Welcome, which + * onboarding removes from the back stack. Navigation ignores a popUpTo whose + * target is not on the stack, so every tab tap pushed a new screen (#58). + * Home is always on the stack (onboarding puts it there), so popping to it + * keeps one entry per tab under Home on first run and every launch after. + */ +const val TAB_ROOT_ROUTE = Routes.HOME + +/** + * The back stack onboarding leaves behind when it lands on [target]: Home + * first, so Back from the landing tab goes Home and then out of the app, the + * same as a normal launch. + */ +fun postOnboardingRoutes(target: String): List = + if (target == TAB_ROOT_ROUTE) listOf(TAB_ROOT_ROUTE) else listOf(TAB_ROOT_ROUTE, target) + +/** + * Switch to the tab at [route]. Each tab keeps its screen, scroll, and loaded + * shelf while another tab is open. Without saveState and restoreState, + * leaving the Library threw it away and a big library re-downloaded on every + * return. + */ +fun NavController.navigateToTab(route: String) { + navigate(route) { + popUpTo(TAB_ROOT_ROUTE) { + saveState = true + } + launchSingleTop = true + restoreState = true + } +} + +/** Leave Welcome for [target] with the stack a normal launch would have. */ +fun NavController.finishOnboarding(target: String) { + val routes = postOnboardingRoutes(target) + navigate(routes.first()) { + popUpTo(Routes.WELCOME) { inclusive = true } + launchSingleTop = true + } + routes.drop(1).forEach { navigateToTab(it) } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/navigation/TabNavigationTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/navigation/TabNavigationTest.kt new file mode 100644 index 00000000..8118be30 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/navigation/TabNavigationTest.kt @@ -0,0 +1,37 @@ +package com.ninelivesaudio.app.ui.navigation + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The tabs pop to a route that has to be on the back stack, or Navigation + * ignores the pop and every tap pushes a new screen. On first run the graph's + * start destination is Welcome, which onboarding removes, so tab taps piled + * up (#58). The tab root is Home on every launch, and onboarding leaves Home + * under whatever screen it lands on. + */ +class TabNavigationTest { + + @Test + fun `the tab root is where a normal launch starts`() { + assertEquals(startDestinationFor(onboardingComplete = true), TAB_ROOT_ROUTE) + } + + @Test + fun `onboarding leaves Home under the screen it lands on`() { + assertEquals(listOf(Routes.HOME, Routes.SETTINGS), postOnboardingRoutes(Routes.SETTINGS)) + } + + @Test + fun `onboarding that lands on Home leaves just Home`() { + assertEquals(listOf(Routes.HOME), postOnboardingRoutes(Routes.HOME)) + } + + @Test + fun `the first-run stack never keeps Welcome`() { + listOf(Routes.HOME, Routes.LIBRARY, Routes.SETTINGS).forEach { target -> + assertEquals(false, Routes.WELCOME in postOnboardingRoutes(target)) + assertEquals(TAB_ROOT_ROUTE, postOnboardingRoutes(target).first()) + } + } +} From 545cfd2ec9f3ff6dbee2f93fca37e06c79a13130 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 20:56:48 -0400 Subject: [PATCH 07/69] Settings follows a library picked on another tab The tabs now keep Settings alive while another tab is open, and Settings read the saved library selection only once when it opened. A library picked on the Library tab meanwhile did not show on return, and the local "All Books" and "All Deleted Books" cleanup actions still targeted the old library. Settings now follows the saved selection for both the server and local pickers. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/settings/SettingsViewModel.kt | 26 +++++++++ .../ui/settings/SettingsSavedSelectionTest.kt | 57 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/settings/SettingsSavedSelectionTest.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 a0ec120d..384d7a9f 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 @@ -23,6 +23,7 @@ import com.ninelivesaudio.app.data.remote.TokenValidationResult import com.ninelivesaudio.app.data.repository.AudioBookRepository import com.ninelivesaudio.app.data.repository.LibraryRepository import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AppSettings import com.ninelivesaudio.app.domain.model.LastSyncRecord import com.ninelivesaudio.app.domain.model.Library import com.ninelivesaudio.app.domain.model.SyncResult @@ -368,6 +369,16 @@ class SettingsViewModel @Inject constructor( } } + // Follow the saved library selection. The tabs keep this screen alive + // while another tab is open, and the Library tab can pick a different + // library meanwhile. Without this, Settings kept showing the old one + // and the local cleanup actions targeted it. + viewModelScope.launch { + settingsManager.settings.collect { settings -> + _uiState.update { it.withSavedSelections(settings) } + } + } + // Follow the app-wide folder scan. A result that landed while // Settings was closed shows once here, then is acknowledged. viewModelScope.launch { @@ -1855,6 +1866,21 @@ internal fun buildScanOutcome( ) } +/** + * Points both library selectors at the libraries [settings] has saved, so a + * pick made on another tab shows here and the local cleanup actions target + * it. A saved id this screen does not know yet (a list still loading, or a + * selection just cleared) leaves the current pick alone, and the list + * observers resolve it when they next emit. + */ +internal fun SettingsViewModel.UiState.withSavedSelections( + settings: AppSettings, +): SettingsViewModel.UiState = copy( + selectedLibrary = libraries.firstOrNull { it.id == settings.selectedLibraryId } ?: selectedLibrary, + selectedLocalLibrary = localLibraries.firstOrNull { it.id == settings.selectedLocalLibraryId } + ?: selectedLocalLibrary, +) + /** * How the app-wide folder scan shows in Settings (#57). Running clears the * last result and shows "Scanning folder...". Finished shows the same diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/settings/SettingsSavedSelectionTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/settings/SettingsSavedSelectionTest.kt new file mode 100644 index 00000000..1ffbd097 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/settings/SettingsSavedSelectionTest.kt @@ -0,0 +1,57 @@ +package com.ninelivesaudio.app.ui.settings + +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AppSettings +import com.ninelivesaudio.app.domain.model.Library +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The tabs keep Settings alive while another tab is open. A library picked on + * the Library tab meanwhile has to show in Settings on return, or the local + * cleanup actions ("All Books", "All Deleted Books") target the old library. + */ +class SettingsSavedSelectionTest { + + private val folderA = Library(id = "a", name = "A", isLocal = true, folderUri = "content://a") + private val folderB = Library(id = "b", name = "B", isLocal = true, folderUri = "content://b") + private val books = Library(id = "books", name = "Books") + private val podcasts = Library(id = "podcasts", name = "Podcasts") + + private val shown = SettingsViewModel.UiState( + localLibraries = listOf(folderA, folderB), + selectedLocalLibrary = folderA, + libraries = listOf(books, podcasts), + selectedLibrary = books, + ) + + @Test + fun `a local folder picked on another tab shows here`() { + val settings = AppSettings(appMode = AppMode.LOCAL, selectedLocalLibraryId = "b", selectedLibraryId = "books") + + assertEquals(folderB, shown.withSavedSelections(settings).selectedLocalLibrary) + } + + @Test + fun `a server library picked on another tab shows here`() { + val settings = AppSettings(appMode = AppMode.AUDIOBOOKSHELF, selectedLibraryId = "podcasts", selectedLocalLibraryId = "a") + + assertEquals(podcasts, shown.withSavedSelections(settings).selectedLibrary) + } + + @Test + fun `an unchanged selection stays put`() { + val settings = AppSettings(selectedLocalLibraryId = "a", selectedLibraryId = "books") + + assertEquals(shown, shown.withSavedSelections(settings)) + } + + @Test + fun `an id this screen does not know yet leaves the pick alone`() { + val settings = AppSettings(selectedLocalLibraryId = "new-folder", selectedLibraryId = null) + + val after = shown.withSavedSelections(settings) + assertEquals(folderA, after.selectedLocalLibrary) + assertEquals(books, after.selectedLibrary) + } +} From 992bb410a803c642c695ae5530573b48da019c06 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 20:52:05 -0400 Subject: [PATCH 08/69] Cancelling a download clears its partial folder when nothing else claims it A cancelled download used to leave its partial audio on disk for good. Once the engine is confirmed stopped (or the row was Paused or Failed), cancel now removes the folder it was writing to, but only when that folder is a plain direct child of the download root, holds nothing but plain files, and no other book row or download points at it (#49). A user-chosen download folder only loses .part files. Completed downloads and Queued rows keep everything. Closes #51. Co-Authored-By: Claude Opus 5.5 --- .../app/data/local/dao/AudioBookDao.kt | 4 + .../app/service/DownloadManager.kt | 58 +++- .../app/service/download/CancelCleanup.kt | 237 ++++++++++++++ .../app/service/download/DownloadEngine.kt | 39 ++- .../app/service/download/CancelCleanupTest.kt | 293 ++++++++++++++++++ 5 files changed, 611 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/download/CancelCleanupTest.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 2ca43ad0..895554ee 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 @@ -43,6 +43,10 @@ interface AudioBookDao { @Query("SELECT * FROM AudioBooks WHERE Id IN (:ids)") suspend fun getByIds(ids: List): List + /** Every stored localPath except [excludeId]'s, for the shared-folder check on cancel. */ + @Query("SELECT LocalPath FROM AudioBooks WHERE Id != :excludeId AND LocalPath IS NOT NULL AND LocalPath != ''") + suspend fun getLocalPathsExcept(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 74a3d1b6..0fe212f6 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt @@ -13,7 +13,11 @@ import com.ninelivesaudio.app.data.local.dao.DownloadItemDao import com.ninelivesaudio.app.domain.model.AudioBook 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.cancelMayTouchFiles +import com.ninelivesaudio.app.service.download.decideCancelCleanup import com.ninelivesaudio.app.service.download.DownloadEngine import com.ninelivesaudio.app.service.download.DownloadNotifications import com.ninelivesaudio.app.service.download.DownloadQueueWorker @@ -423,12 +427,13 @@ class DownloadManager @Inject constructor( * gives up and proceeds, which is the same risk profile as before this * function existed. */ - private suspend fun stopDrainAndAwait() { + private suspend fun stopDrainAndAwait(): Boolean { workManager.cancelUniqueWork(DOWNLOAD_WORK_NAME) - awaitDrainStopped() + return awaitDrainStopped() } - private suspend fun awaitDrainStopped() { + /** True once the drain is confirmed stopped, false when the ceiling ran out first. */ + private suspend fun awaitDrainStopped(): Boolean { val deadline = System.currentTimeMillis() + WORKER_STOP_TIMEOUT_MS while (System.currentTimeMillis() < deadline) { // The worker's own flag is the authority. WorkInfo is consulted only @@ -439,10 +444,11 @@ class DownloadManager @Inject constructor( workManager.getWorkInfosForUniqueWork(DOWNLOAD_WORK_NAME).await() }.getOrNull()?.any { !it.state.isFinished } ?: false - if (!stillScheduled) return + if (!stillScheduled) return true } delay(WORKER_STOP_POLL_MS) } + return false } /** @@ -519,8 +525,13 @@ class DownloadManager @Inject constructor( suspend fun cancelDownload(downloadId: String): Unit = finishInOwnerScope(scope) { val entity = downloadItemDao.getById(downloadId) val wasDownloading = entity?.status == DownloadStatus.Downloading.ordinal - writeAfterEngineStops(wasDownloading, { stopDrainAndAwait() }) { + var stopConfirmed = false + writeAfterEngineStops(wasDownloading, { stopConfirmed = stopDrainAndAwait() }) { downloadItemDao.deleteById(downloadId) + // Still before any drain restart below, so nothing is writing here. + if (entity != null && cancelMayTouchFiles(entity.status, wasDownloading, stopConfirmed)) { + removeCancelledPartialFiles(entity.audioBookId) + } } if (downloadItemDao.getDownloadable().isEmpty()) { @@ -534,6 +545,43 @@ class DownloadManager @Inject constructor( } } + /** + * 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. The rules live in [decideCancelCleanup] and keep the + * files on any doubt, including when another book or download points at the + * same folder (#49). 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. + val otherRows = downloadItemDao.getAll() + val otherBooks = audioBookDao.getByIds(otherRows.map { it.audioBookId }.distinct()) + val otherFolders = otherBooks.map { engine.downloadLocationFor(it.toDomain()).folder } + val bookIsDownloaded = bookEntity.isDownloaded == 1 || !bookEntity.localPath.isNullOrEmpty() + + withContext(Dispatchers.IO) { + when (val decision = decideCancelCleanup(location, bookIsDownloaded, otherBookPaths, otherFolders)) { + is CancelCleanupDecision.Keep -> + android.util.Log.d("DownloadManager", "cancel cleanup kept files: ${decision.reason}") + is CancelCleanupDecision.Delete -> { + val count = applyCancelCleanup(decision) + android.util.Log.d("DownloadManager", "cancel cleanup removed $count files") + } + } + } + } catch (cancellation: kotlinx.coroutines.CancellationException) { + throw cancellation + } catch (e: Exception) { + android.util.Log.w("DownloadManager", "cancel cleanup skipped: ${e.message}") + } + } + /** Delete a download's files and DB record. */ suspend fun deleteDownload(audioBookId: String): Unit = finishInOwnerScope(scope) { val downloadEntity = 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 new file mode 100644 index 00000000..0595acd0 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt @@ -0,0 +1,237 @@ +package com.ninelivesaudio.app.service.download + +import com.ninelivesaudio.app.domain.model.DownloadStatus +import java.io.File +import java.io.IOException +import java.net.URI +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path + +// ─── Cancelled download cleanup (decision is unit-testable) ─────────────── +// +// A cancelled download used to leave its partial folder on disk for good +// (#51). Cleaning it up deletes user files, so every rule here leans toward +// keeping them. Any doubt at all is a Keep. + +/** Where a book's download folder sits and whether the app alone owns the root. */ +internal data class DownloadLocation( + val root: File, + val folder: File, + /** + * True when the root is the app's own storage, which nothing else can + * write to on API 30+. False for a user-configured download path, which + * may hold the user's own audiobooks. + */ + val rootIsAppOwned: Boolean, +) + +/** + * Pick the download root the same way the engine does, without creating it. + * + * A configured path wins when it resolves and stays out of system dirs. + * Otherwise [defaultRoot] (the app's own storage). The root only counts as app + * owned when it really is [defaultRoot], so a user who typed that same path in + * by hand still gets the full cleanup. On fallback the [defaultRoot] instance + * itself comes back, which the engine uses to log a rejected path. + */ +internal fun resolveDownloadRoot(configuredPath: String, defaultRoot: File): Pair { + val trimmed = configuredPath.trim() + if (trimmed.isNotEmpty()) { + val candidate = runCatching { File(trimmed).canonicalFile }.getOrNull() + if (candidate != null && FORBIDDEN_DOWNLOAD_ROOTS.none { candidate.absolutePath.startsWith(it) }) { + val defaultCanonical = runCatching { defaultRoot.canonicalFile }.getOrNull() + return candidate to (candidate == defaultCanonical) + } + } + return defaultRoot to true +} + +/** System dirs a configured download path may never point into. */ +internal val FORBIDDEN_DOWNLOAD_ROOTS = listOf("/system", "/data/data", "/data/user", "/proc", "/dev") + +/** + * Whether the engine is provably off this download, so its folder can be + * touched at all. + * + * A Downloading row only qualifies once the stop was confirmed (the stop gives + * up after a timeout and the engine may still be streaming). Paused and Failed + * rows are never picked by the drain. A Queued row can be picked at any + * moment, and a Completed row is a finished download that cancel must leave + * exactly as it was, so both keep their files. + */ +internal fun cancelMayTouchFiles(status: Int, wasDownloading: Boolean, stopConfirmed: Boolean): Boolean = + when { + wasDownloading -> stopConfirmed + status == DownloadStatus.Paused.ordinal -> true + status == DownloadStatus.Failed.ordinal -> true + else -> false + } + +internal enum class CancelKeepReason { + /** The book row is marked downloaded, so this is a finished download. */ + BOOK_DOWNLOADED, + + /** The folder is missing, so there is nothing to clean. */ + NO_FOLDER, + + /** The folder is not a plain direct child of the download root. */ + OUTSIDE_ROOT, + + /** Another book row or download points at this folder (#49). */ + SHARED, + + /** The folder holds something the engine never writes. */ + UNEXPECTED_CONTENTS, + + /** A path could not be resolved, so overlap cannot be ruled out. */ + UNRESOLVABLE, +} + +internal sealed interface CancelCleanupDecision { + data class Keep(val reason: CancelKeepReason) : CancelCleanupDecision + + /** + * Delete exactly [files] (all directly inside the canonical [folder]), then + * [folder] itself only when [removeFolder]. + */ + data class Delete( + val folder: File, + val files: List, + val removeFolder: Boolean, + ) : CancelCleanupDecision +} + +/** + * Decide what a cancelled download may delete from [location]. + * + * Kept, in this order, when: + * - the book row says the book is downloaded, + * - 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, + * - any [otherBookPaths] entry (another book's stored localPath) is the + * folder, sits inside it, or contains it, or cannot be resolved, + * - any [otherDownloadFolders] entry (where another download row would + * write) is the folder, + * - the folder holds a subdirectory, a symlink, or anything not a plain file. + * + * Otherwise, in the app-owned root, every file goes and then the empty folder. + * In a user-configured root only `.part` files go, since finished tracks there + * could be the user's own copy the engine reused through its skip check. + * + * Content URIs in [otherBookPaths] are skipped. They come from the Storage + * Access Framework, which cannot reach app storage on API 30+, and a + * user-configured root only ever loses `.part` files. + */ +internal fun decideCancelCleanup( + location: DownloadLocation, + bookIsDownloaded: Boolean, + otherBookPaths: List, + otherDownloadFolders: List, +): CancelCleanupDecision { + 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) + } + + for (raw in otherBookPaths) { + val other = bookPathAsFile(raw) ?: continue + val otherCanonical = canonicalOrNull(other) + ?: return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) + 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) + } + + 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) + } + if (children.any { !Files.isRegularFile(it, LinkOption.NOFOLLOW_LINKS) }) { + return CancelCleanupDecision.Keep(CancelKeepReason.UNEXPECTED_CONTENTS) + } + + return if (location.rootIsAppOwned) { + CancelCleanupDecision.Delete(folder, children.map(Path::toFile), removeFolder = true) + } else { + CancelCleanupDecision.Delete( + folder, + children.filter { it.fileName.toString().endsWith(".part") }.map(Path::toFile), + removeFolder = false, + ) + } +} + +/** + * Carry out a [CancelCleanupDecision.Delete]. Deletes each listed path without + * following links, then the folder only if it is empty by then. Never recurses. + * Returns how many files went. + */ +internal fun applyCancelCleanup(decision: CancelCleanupDecision.Delete): Int { + val folder = decision.folder + var deleted = 0 + for (file in decision.files) { + val path = file.toPath() + // Re-check right before the delete. Only a plain file directly inside + // this exact folder is ever removed. + if (file.parentFile != folder) continue + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) continue + if (runCatching { Files.deleteIfExists(path) }.getOrDefault(false)) deleted++ + } + if (decision.removeFolder) { + // Fails on a non-empty folder, which is the point: anything that showed + // up since the decision stays. + runCatching { Files.deleteIfExists(folder.toPath()) } + } + return deleted +} + +private fun canonicalOrNull(file: File): File? = try { + file.canonicalFile +} catch (_: IOException) { + null +} catch (_: SecurityException) { + null +} + +/** A stored localPath as a File: plain paths and file:// URIs. Null for anything else. */ +private fun bookPathAsFile(raw: String): File? { + val value = raw.trim() + if (value.isEmpty()) return null + if (value.startsWith("file:", ignoreCase = true)) { + val path = runCatching { URI(value).path }.getOrNull() + // A file URI that will not parse is still a file path of some kind. + return File(path ?: value.removePrefix("file://")) + } + if (value.contains("://")) return null + return File(value) +} + +/** Equal, or one sits inside the other. Both must be canonical. */ +private fun pathsOverlap(a: File, b: File): Boolean { + val pa = a.toPath() + val pb = b.toPath() + return pa == pb || pa.startsWith(pb) || pb.startsWith(pa) +} 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 bc3b57c4..4f93dca3 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 @@ -296,28 +296,37 @@ class DownloadEngine @Inject constructor( return File(basePath, folderName) } + /** + * Where [audioBook]'s download folder is under the current settings, for + * cancel cleanup. Resolves exactly like [getDownloadPath] but creates + * nothing. + */ + internal fun downloadLocationFor(audioBook: AudioBook): 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) + } + /** Base storage directory for all downloads. */ private fun getBasePath(): File { // Respect user-configured path when possible, with path traversal validation. val configuredPath = settingsManager.currentSettings.downloadPath.trim() - if (configuredPath.isNotEmpty()) { - try { - val candidate = File(configuredPath).canonicalFile - // Reject paths targeting sensitive system directories - val forbidden = listOf("/system", "/data/data", "/data/user", "/proc", "/dev") - val isSafe = forbidden.none { candidate.absolutePath.startsWith(it) } - if (isSafe) { - return candidate.also { it.mkdirs() } - } - Log.w(TAG, "getBasePath: Configured path rejected (targets system dir): $configuredPath") - } catch (e: Exception) { - Log.w(TAG, "getBasePath: Failed to resolve configured path: $configuredPath", e) - } + val fallback = defaultBasePath() + val (root, _) = resolveDownloadRoot(configuredPath, fallback) + // The fallback instance itself comes back only when the configured path lost. + if (configuredPath.isNotEmpty() && root === fallback) { + Log.w(TAG, "getBasePath: Configured path rejected or unresolvable: $configuredPath") } + return root.also { it.mkdirs() } + } - // Fallback to app-specific external storage. + /** App-specific external storage, the fallback root. */ + private fun defaultBasePath(): File { val musicDir = context.getExternalFilesDir(Environment.DIRECTORY_MUSIC) ?: context.filesDir - return File(musicDir, "Audiobookshelf").also { it.mkdirs() } + return File(musicDir, "Audiobookshelf") } } 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 new file mode 100644 index 00000000..b961994f --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/CancelCleanupTest.kt @@ -0,0 +1,293 @@ +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.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files + +/** + * A cancelled download cleans up its partial folder (#51), and only that + * folder, and only when nothing else claims it (#49). Every doubt keeps files. + */ +class CancelCleanupTest { + + @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 decide( + root: File, + folder: File, + appOwned: Boolean = true, + downloaded: Boolean = false, + otherBookPaths: List = emptyList(), + otherDownloadFolders: List = emptyList(), + ) = decideCancelCleanup( + DownloadLocation(root, folder, appOwned), + downloaded, + otherBookPaths, + otherDownloadFolders, + ) + + // ─── Deletes ───────────────────────────────────────────────────────────── + + @Test + fun `a contained folder in app storage is deleted whole`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3.part", "cover.jpg") + + val decision = decide(root, folder) as CancelCleanupDecision.Delete + assertEquals(setOf("01.mp3", "02.mp3.part", "cover.jpg"), decision.files.map { it.name }.toSet()) + assertTrue(decision.removeFolder) + + assertEquals(3, applyCancelCleanup(decision)) + assertFalse(folder.exists()) + assertTrue(root.exists()) + } + + @Test + fun `a sibling download's folder survives the cleanup`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + val sibling = File(root, "Other Author - Other Book").apply { mkdirs() } + File(sibling, "01.mp3").writeBytes(byteArrayOf(9)) + + val decision = decide(root, folder, otherBookPaths = listOf(sibling.absolutePath)) + applyCancelCleanup(decision as CancelCleanupDecision.Delete) + + assertFalse(folder.exists()) + assertTrue(File(sibling, "01.mp3").exists()) + } + + @Test + fun `a user-chosen root only loses part files`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3.part") + + val decision = decide(root, folder, appOwned = false) as CancelCleanupDecision.Delete + assertEquals(listOf("02.mp3.part"), decision.files.map { it.name }) + assertFalse(decision.removeFolder) + + applyCancelCleanup(decision) + assertTrue(File(folder, "01.mp3").exists()) + assertFalse(File(folder, "02.mp3.part").exists()) + } + + @Test + fun `a file that shows up after the decision keeps the folder`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + val decision = decide(root, folder) as CancelCleanupDecision.Delete + + File(folder, "late.mp3").writeBytes(byteArrayOf(4)) + applyCancelCleanup(decision) + + assertTrue(File(folder, "late.mp3").exists()) + assertFalse(File(folder, "01.mp3").exists()) + } + + // ─── Shared path (#49) ─────────────────────────────────────────────────── + + @Test + fun `a folder another book row points at is kept`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.SHARED), + decide(root, folder, otherBookPaths = listOf(folder.absolutePath)), + ) + assertTrue(File(folder, "01.mp3").exists()) + } + + @Test + fun `a shared folder written as a file uri or with a trailing slash is still kept`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.SHARED), + decide(root, folder, otherBookPaths = listOf(folder.toURI().toString())), + ) + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.SHARED), + decide(root, folder, otherBookPaths = listOf(folder.absolutePath + "/")), + ) + } + + @Test + fun `a book path inside the folder or above it keeps the folder`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.SHARED), + decide(root, folder, otherBookPaths = listOf(File(folder, "01.mp3").absolutePath)), + ) + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.SHARED), + decide(root, folder, otherBookPaths = listOf(root.absolutePath)), + ) + } + + @Test + fun `a folder another queued download would write to is kept`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.SHARED), + decide(root, folder, otherDownloadFolders = listOf(File(root, folder.name))), + ) + } + + @Test + fun `content uris from a local import do not block the cleanup`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + + val decision = decide( + root, + folder, + otherBookPaths = listOf("content://com.android.externalstorage.documents/tree/primary%3AAudiobooks"), + ) + assertTrue(decision is CancelCleanupDecision.Delete) + } + + // ─── Containment ───────────────────────────────────────────────────────── + + @Test + fun `a folder that escapes the root is never deleted`() { + val root = root() + val outside = tempFolder.newFolder("Music", "My Own Audiobooks") + File(outside, "keep.mp3").writeBytes(byteArrayOf(7)) + + // A title of ".." sanitizes to "..", so the engine path climbs out. + val climbed = File(root, "../Music/My Own Audiobooks") + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decide(root, climbed)) + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decide(root, outside)) + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decide(root, File(root, ".."))) + assertTrue(File(outside, "keep.mp3").exists()) + } + + @Test + fun `the root itself is never deleted`() { + val root = root() + File(root, "loose.mp3").writeBytes(byteArrayOf(1)) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decide(root, File(root, "."))) + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decide(root, root)) + } + + @Test + fun `a folder nested deeper than one level is not touched`() { + val root = root() + val nested = File(root, "a/b").apply { mkdirs() } + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decide(root, nested)) + } + + @Test + fun `a symlinked book folder pointing outside the root is never followed`() { + val root = root() + val outside = tempFolder.newFolder("elsewhere") + File(outside, "keep.mp3").writeBytes(byteArrayOf(7)) + val link = File(root, "Herman Melville - Moby-Dick") + Files.createSymbolicLink(link.toPath(), outside.toPath()) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decide(root, link)) + assertTrue(File(outside, "keep.mp3").exists()) + } + + @Test + fun `a symlink or subfolder inside the book folder keeps everything`() { + val root = root() + val outside = tempFolder.newFolder("elsewhere") + File(outside, "keep.mp3").writeBytes(byteArrayOf(7)) + + val withLink = bookFolder(root, "01.mp3") + Files.createSymbolicLink(File(withLink, "sneaky").toPath(), outside.toPath()) + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.UNEXPECTED_CONTENTS), + decide(root, withLink), + ) + + val withSubdir = File(root, "Another - Book").apply { mkdirs() } + File(withSubdir, "extras").mkdirs() + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.UNEXPECTED_CONTENTS), + decide(root, withSubdir), + ) + assertTrue(File(outside, "keep.mp3").exists()) + } + + // ─── Nothing to do ─────────────────────────────────────────────────────── + + @Test + fun `a downloaded book is left alone`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.BOOK_DOWNLOADED), + decide(root, folder, downloaded = true), + ) + } + + @Test + fun `a missing folder is a no-op`() { + val root = root() + + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.NO_FOLDER), + decide(root, File(root, "Never Started")), + ) + } + + // ─── When cancel may touch files at all ────────────────────────────────── + + @Test + fun `files are touched only once the engine is provably off the book`() { + val downloading = DownloadStatus.Downloading.ordinal + assertTrue(cancelMayTouchFiles(downloading, wasDownloading = true, stopConfirmed = true)) + assertFalse(cancelMayTouchFiles(downloading, wasDownloading = true, stopConfirmed = false)) + assertTrue(cancelMayTouchFiles(DownloadStatus.Paused.ordinal, wasDownloading = false, stopConfirmed = false)) + assertTrue(cancelMayTouchFiles(DownloadStatus.Failed.ordinal, wasDownloading = false, stopConfirmed = false)) + assertFalse(cancelMayTouchFiles(DownloadStatus.Queued.ordinal, wasDownloading = false, stopConfirmed = false)) + assertFalse(cancelMayTouchFiles(DownloadStatus.Preparing.ordinal, wasDownloading = false, stopConfirmed = false)) + assertFalse(cancelMayTouchFiles(DownloadStatus.Completed.ordinal, wasDownloading = false, stopConfirmed = false)) + } + + // ─── Root resolution ───────────────────────────────────────────────────── + + @Test + fun `the default root is app owned and a configured one is not`() { + val defaultRoot = tempFolder.newFolder("app", "Audiobookshelf") + val chosen = tempFolder.newFolder("sdcard", "Audiobooks") + + assertEquals(defaultRoot to true, resolveDownloadRoot("", defaultRoot)) + assertEquals(chosen.canonicalFile to false, resolveDownloadRoot(chosen.absolutePath, defaultRoot)) + assertEquals(defaultRoot.canonicalFile to true, resolveDownloadRoot(defaultRoot.absolutePath, defaultRoot)) + } + + @Test + fun `a configured system dir falls back to the app owned default`() { + val defaultRoot = tempFolder.newFolder("app", "Audiobookshelf") + + val (root, appOwned) = resolveDownloadRoot("/proc/self", defaultRoot) + assertTrue(root === defaultRoot) + assertTrue(appOwned) + } +} From 95e0172f6f774d7be7a83c487ec2cce18e5eac00 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 20:52:05 -0400 Subject: [PATCH 09/69] Home stops asking for a folder when the books are already there With books imported but none played, Home said to add a folder, which read like the import failed. It now says the books are here and points at the Library. Server mode had the same gap (the Archive stands empty, and the Ritual and Unhinged lines said to add something), so a synced library with nothing played gets the same treatment. Truly empty libraries keep the old copy. Closes #59. Co-Authored-By: Claude Opus 5.5 --- .../ninelivesaudio/app/ui/home/HomeScreen.kt | 20 ++-- .../app/ui/home/HomeViewModel.kt | 97 +++++++++++++++++-- .../app/ui/home/HomeEmptyCopyTest.kt | 76 +++++++++++++++ 3 files changed, 176 insertions(+), 17 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/home/HomeEmptyCopyTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeScreen.kt index e8404a10..a825162e 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeScreen.kt @@ -61,7 +61,7 @@ fun HomeScreen( if (uiState.showEmptyState && !uiState.isLoading) { EmptyHomeState( - isLocalMode = uiState.isLocalMode, + kind = homeEmptyKind(uiState.isLocalMode, uiState.libraryHasBooks), connectionPillState = connectionPillState, onReconnect = viewModel::reconnect, onNavigateToLibrary = onNavigateToLibrary, @@ -420,12 +420,13 @@ private fun NineLivesAltar( @Composable private fun EmptyHomeState( - isLocalMode: Boolean, + kind: HomeEmptyKind, connectionPillState: HomeConnectionPillState, onReconnect: () -> Unit, onNavigateToLibrary: () -> Unit, onNavigateToSettings: () -> Unit, ) { + val copy = homeEmptyCopy(kind) Box( modifier = Modifier .fillMaxSize() @@ -465,7 +466,7 @@ private fun EmptyHomeState( Spacer(modifier = Modifier.height(8.dp)) Text( - text = if (isLocalMode) "No local audio yet" else "The Archive stands empty", + text = copy.subtitle, style = MaterialTheme.typography.titleMedium, color = NineLivesTheme.colors.archiveTextSecondary, textAlign = TextAlign.Center, @@ -474,14 +475,12 @@ private fun EmptyHomeState( Spacer(modifier = Modifier.height(4.dp)) Text( - text = if (isLocalMode) { - "Add a folder of audiobooks in Settings to begin" - } else { + text = copy.body ?: ( CopyEngine.getEmptyStateFlavor( CopyStyleGuide.EmptyStates.EMPTY_LIBRARY_RITUAL, CopyStyleGuide.EmptyStates.EMPTY_LIBRARY_UNHINGED, ) ?: "Begin listening to fill these halls with your progress" - }, + ), style = MaterialTheme.typography.bodySmall, color = NineLivesTheme.colors.archiveTextMuted, textAlign = TextAlign.Center, @@ -491,7 +490,10 @@ private fun EmptyHomeState( Spacer(modifier = Modifier.height(32.dp)) Button( - onClick = if (isLocalMode) onNavigateToSettings else onNavigateToLibrary, + onClick = when (copy.action) { + HomeEmptyAction.OPEN_SETTINGS -> onNavigateToSettings + HomeEmptyAction.OPEN_LIBRARY -> onNavigateToLibrary + }, colors = ButtonDefaults.buttonColors( containerColor = NineLivesTheme.colors.goldFilament, contentColor = NineLivesTheme.colors.onAccent, @@ -499,7 +501,7 @@ private fun EmptyHomeState( shape = RoundedCornerShape(12.dp), ) { Text( - text = if (isLocalMode) "Open Settings" else "Enter The Archive", + text = copy.button, fontWeight = FontWeight.SemiBold, ) } diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt index 626c0f14..bb168e57 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt @@ -64,6 +64,12 @@ class HomeViewModel @Inject constructor( val connectionStatus: ConnectionStatus = ConnectionStatus.OFFLINE, val isLocalMode: Boolean = false, val hasAuthToken: Boolean? = null, + /** + * Whether the active library holds any books at all, so an empty Home + * can tell "nothing played yet" apart from "nothing imported" (#59). + * Only looked up while Home has nothing to show. + */ + val libraryHasBooks: Boolean = false, ) private val _uiState = MutableStateFlow(UiState()) @@ -124,10 +130,12 @@ class HomeViewModel @Inject constructor( .collectLatest { (settings, hasAuthToken) -> val libraryId = settings.activeLibraryId if (libraryId != null && canShowHomeBooks(settings, hasAuthToken)) { + // The recent-books flow re-emits on any AudioBooks change, so + // a folder import refreshes the book count along with it. audioBookDao.observeRecentlyPlayedByLibrary(libraryId, 9) - .collect { results -> processRecentlyPlayed(results) } + .collect { results -> processRecentlyPlayed(results, libraryId) } } else { - processRecentlyPlayed(emptyList()) + processRecentlyPlayed(emptyList(), libraryId = null) } } } @@ -139,17 +147,18 @@ class HomeViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true) } try { val libraryId = settingsManager.currentSettings.activeLibraryId - val results = if ( - libraryId != null && canShowHomeBooks( + val visibleLibraryId = libraryId?.takeIf { + canShowHomeBooks( settingsManager.currentSettings, settingsManager.hasAuthToken.value, ) - ) { - audioBookDao.getRecentlyPlayedByLibrary(libraryId, 9) + } + val results = if (visibleLibraryId != null) { + audioBookDao.getRecentlyPlayedByLibrary(visibleLibraryId, 9) } else { emptyList() } - processRecentlyPlayed(results) + processRecentlyPlayed(results, visibleLibraryId) } catch (e: Exception) { _uiState.update { it.copy(showEmptyState = true) } } finally { @@ -188,7 +197,7 @@ class HomeViewModel @Inject constructor( // Future: unlock hidden lore, achievement, or atmospheric event. } - private suspend fun processRecentlyPlayed(results: List) { + private suspend fun processRecentlyPlayed(results: List, libraryId: String?) { val settings = settingsManager.currentSettings val accessibleLocalIds = if (settings.appMode == AppMode.LOCAL) { settings.activeLibraryId @@ -235,10 +244,19 @@ class HomeViewModel @Inject constructor( val totalSeconds = results.sumOf { it.audioBook.currentTimeSeconds } val totalText = formatListeningTime(totalSeconds) + // Counted only when Home is empty. A failed count reads as no books, + // which is the copy Home showed before this check existed. + val libraryHasBooks = if (lives.isEmpty() && libraryId != null) { + runCatching { audioBookDao.countByLibrary(libraryId) > 0 }.getOrDefault(false) + } else { + lives.isNotEmpty() + } + _uiState.update { it.copy( lives = lives, showEmptyState = lives.isEmpty(), + libraryHasBooks = libraryHasBooks, totalListeningTimeText = totalText, totalListeningSeconds = totalSeconds, ) @@ -331,6 +349,69 @@ internal fun canShowHomeBooks( AppMode.LOCAL -> settings.selectedLocalLibraryId != null } +/** Which empty Home to show (#59). */ +internal enum class HomeEmptyKind { + /** Local mode, nothing imported: send them to Settings to add a folder. */ + LOCAL_NO_BOOKS, + + /** Local mode, books imported but none played: send them to the Library. */ + LOCAL_UNPLAYED, + + /** Server mode, empty library: the original server copy. */ + SERVER_NO_BOOKS, + + /** Server mode, books synced but none played: send them to the Library. */ + SERVER_UNPLAYED, +} + +internal fun homeEmptyKind(isLocalMode: Boolean, libraryHasBooks: Boolean): HomeEmptyKind = when { + isLocalMode && libraryHasBooks -> HomeEmptyKind.LOCAL_UNPLAYED + isLocalMode -> HomeEmptyKind.LOCAL_NO_BOOKS + libraryHasBooks -> HomeEmptyKind.SERVER_UNPLAYED + else -> HomeEmptyKind.SERVER_NO_BOOKS +} + +internal enum class HomeEmptyAction { OPEN_SETTINGS, OPEN_LIBRARY } + +/** + * Fixed copy for an empty Home. [body] is null where the screen picks a + * copy-mode flavor instead (the empty server library keeps its Ritual and + * Unhinged lines). + */ +internal data class HomeEmptyCopy( + val subtitle: String, + val body: String?, + val button: String, + val action: HomeEmptyAction, +) + +internal fun homeEmptyCopy(kind: HomeEmptyKind): HomeEmptyCopy = when (kind) { + HomeEmptyKind.LOCAL_NO_BOOKS -> HomeEmptyCopy( + subtitle = "No local audio yet", + body = "Add a folder of audiobooks in Settings to begin", + button = "Open Settings", + action = HomeEmptyAction.OPEN_SETTINGS, + ) + HomeEmptyKind.LOCAL_UNPLAYED -> HomeEmptyCopy( + subtitle = "Your books are here, untouched", + body = "Pick one in the Library and the first life begins", + button = "Open Library", + action = HomeEmptyAction.OPEN_LIBRARY, + ) + HomeEmptyKind.SERVER_NO_BOOKS -> HomeEmptyCopy( + subtitle = "The Archive stands empty", + body = null, + button = "Enter The Archive", + action = HomeEmptyAction.OPEN_LIBRARY, + ) + HomeEmptyKind.SERVER_UNPLAYED -> HomeEmptyCopy( + subtitle = "The Archive is full and untouched", + body = "Pick one in the Library and the first life begins", + button = "Enter The Archive", + action = HomeEmptyAction.OPEN_LIBRARY, + ) +} + internal fun isHomeReconnectAvailable( isLocalMode: Boolean, hasAuthToken: Boolean, diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/home/HomeEmptyCopyTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/home/HomeEmptyCopyTest.kt new file mode 100644 index 00000000..90971cde --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/home/HomeEmptyCopyTest.kt @@ -0,0 +1,76 @@ +package com.ninelivesaudio.app.ui.home + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * An empty Home must not tell someone with an imported library to add a + * folder (#59). Unplayed books point at the Library, a truly empty library + * keeps the original copy. + */ +class HomeEmptyCopyTest { + + @Test + fun `empty home picks its kind from mode and whether books exist`() { + assertEquals(HomeEmptyKind.LOCAL_NO_BOOKS, homeEmptyKind(isLocalMode = true, libraryHasBooks = false)) + assertEquals(HomeEmptyKind.LOCAL_UNPLAYED, homeEmptyKind(isLocalMode = true, libraryHasBooks = true)) + assertEquals(HomeEmptyKind.SERVER_NO_BOOKS, homeEmptyKind(isLocalMode = false, libraryHasBooks = false)) + assertEquals(HomeEmptyKind.SERVER_UNPLAYED, homeEmptyKind(isLocalMode = false, libraryHasBooks = true)) + } + + @Test + fun `local library with books never says add a folder`() { + val copy = homeEmptyCopy(HomeEmptyKind.LOCAL_UNPLAYED) + + assertEquals(HomeEmptyAction.OPEN_LIBRARY, copy.action) + assertTrue(copy.body!!.contains("Library")) + listOf(copy.subtitle, copy.body, copy.button).forEach { line -> + assertFalse(line, line!!.contains("folder", ignoreCase = true)) + assertFalse(line, line.contains("Settings")) + } + } + + @Test + fun `truly empty local library keeps the add a folder copy and Settings button`() { + val copy = homeEmptyCopy(HomeEmptyKind.LOCAL_NO_BOOKS) + + assertEquals("No local audio yet", copy.subtitle) + assertEquals("Add a folder of audiobooks in Settings to begin", copy.body) + assertEquals("Open Settings", copy.button) + assertEquals(HomeEmptyAction.OPEN_SETTINGS, copy.action) + } + + @Test + fun `empty server library keeps its original copy and flavor slot`() { + val copy = homeEmptyCopy(HomeEmptyKind.SERVER_NO_BOOKS) + + assertEquals("The Archive stands empty", copy.subtitle) + assertNull(copy.body) + assertEquals("Enter The Archive", copy.button) + assertEquals(HomeEmptyAction.OPEN_LIBRARY, copy.action) + } + + @Test + fun `server library with books no longer claims the Archive is empty`() { + val copy = homeEmptyCopy(HomeEmptyKind.SERVER_UNPLAYED) + + assertFalse(copy.subtitle.contains("empty", ignoreCase = true)) + // A fixed body, so the empty-library Ritual and Unhinged flavors + // ("Add something to the collection") never show for a full library. + assertTrue(copy.body!!.contains("Library")) + assertEquals(HomeEmptyAction.OPEN_LIBRARY, copy.action) + } + + @Test + fun `empty home copy has no dashes or semicolons`() { + HomeEmptyKind.entries.map(::homeEmptyCopy).forEach { copy -> + listOfNotNull(copy.subtitle, copy.body, copy.button).forEach { line -> + assertFalse(line, line.contains('\u2014') || line.contains('\u2013') || line.contains(';')) + assertFalse(line, line.contains(" - ")) + } + } + } +} From c41072141a26c8ca0f87f28235c3eea1b4735c13 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 21:26:41 -0400 Subject: [PATCH 10/69] Cancel leaves a user-picked download folder alone In a download folder the user picked, cancel cleanup used to delete every .part file in the matching "Author - Title" folder. Other tools write .part files too (a browser or a sync app), so one of theirs could go. Cancel now deletes nothing from a user-picked folder and logs that it kept the files. Only the app's own storage, which nothing else can write to, gets cleaned. Co-Authored-By: Claude Opus 5.5 --- .../app/service/download/CancelCleanup.kt | 37 +++++++++---------- .../app/service/download/CancelCleanupTest.kt | 20 +++++----- 2 files changed, 28 insertions(+), 29 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 0595acd0..f18dd88c 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 @@ -69,6 +69,13 @@ internal fun cancelMayTouchFiles(status: Int, wasDownloading: Boolean, stopConfi } internal enum class CancelKeepReason { + /** + * The root is a user-picked download folder. Other tools can write there + * (a `.part` file from a browser or a sync app looks just like ours), so + * cancel never deletes anything from it. + */ + USER_FOLDER, + /** The book row is marked downloaded, so this is a finished download. */ BOOK_DOWNLOADED, @@ -93,12 +100,11 @@ internal sealed interface CancelCleanupDecision { /** * Delete exactly [files] (all directly inside the canonical [folder]), then - * [folder] itself only when [removeFolder]. + * [folder] itself once it is empty. */ data class Delete( val folder: File, val files: List, - val removeFolder: Boolean, ) : CancelCleanupDecision } @@ -106,6 +112,7 @@ internal sealed interface CancelCleanupDecision { * Decide what a cancelled download may delete from [location]. * * Kept, in this order, when: + * - the root is a user-picked download folder rather than the app's own storage, * - the book row says the book is downloaded, * - the folder is not a real direct child of the root (a symlink, the root * itself, `..`, or anything that resolves elsewhere), @@ -116,13 +123,12 @@ internal sealed interface CancelCleanupDecision { * write) is the folder, * - the folder holds a subdirectory, a symlink, or anything not a plain file. * - * Otherwise, in the app-owned root, every file goes and then the empty folder. - * In a user-configured root only `.part` files go, since finished tracks there - * could be the user's own copy the engine reused through its skip check. + * Otherwise every file goes and then the empty folder. That only ever happens + * in the app's own storage, which nothing else can write to. * * Content URIs in [otherBookPaths] are skipped. They come from the Storage * Access Framework, which cannot reach app storage on API 30+, and a - * user-configured root only ever loses `.part` files. + * user-configured root is never touched at all. */ internal fun decideCancelCleanup( location: DownloadLocation, @@ -130,6 +136,7 @@ internal fun decideCancelCleanup( otherBookPaths: List, otherDownloadFolders: List, ): CancelCleanupDecision { + if (!location.rootIsAppOwned) return CancelCleanupDecision.Keep(CancelKeepReason.USER_FOLDER) if (bookIsDownloaded) return CancelCleanupDecision.Keep(CancelKeepReason.BOOK_DOWNLOADED) val root = canonicalOrNull(location.root) @@ -173,15 +180,7 @@ internal fun decideCancelCleanup( return CancelCleanupDecision.Keep(CancelKeepReason.UNEXPECTED_CONTENTS) } - return if (location.rootIsAppOwned) { - CancelCleanupDecision.Delete(folder, children.map(Path::toFile), removeFolder = true) - } else { - CancelCleanupDecision.Delete( - folder, - children.filter { it.fileName.toString().endsWith(".part") }.map(Path::toFile), - removeFolder = false, - ) - } + return CancelCleanupDecision.Delete(folder, children.map(Path::toFile)) } /** @@ -200,11 +199,9 @@ internal fun applyCancelCleanup(decision: CancelCleanupDecision.Delete): Int { if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) continue if (runCatching { Files.deleteIfExists(path) }.getOrDefault(false)) deleted++ } - if (decision.removeFolder) { - // Fails on a non-empty folder, which is the point: anything that showed - // up since the decision stays. - runCatching { Files.deleteIfExists(folder.toPath()) } - } + // Fails on a non-empty folder, which is the point: anything that showed + // up since the decision stays. + runCatching { Files.deleteIfExists(folder.toPath()) } return deleted } 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 b961994f..e4e255f5 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 @@ -50,7 +50,6 @@ class CancelCleanupTest { val decision = decide(root, folder) as CancelCleanupDecision.Delete assertEquals(setOf("01.mp3", "02.mp3.part", "cover.jpg"), decision.files.map { it.name }.toSet()) - assertTrue(decision.removeFolder) assertEquals(3, applyCancelCleanup(decision)) assertFalse(folder.exists()) @@ -71,18 +70,21 @@ class CancelCleanupTest { assertTrue(File(sibling, "01.mp3").exists()) } + // ─── User-picked folder ────────────────────────────────────────────────── + @Test - fun `a user-chosen root only loses part files`() { + fun `a user-chosen root keeps every file, part files included`() { val root = root() - val folder = bookFolder(root, "01.mp3", "02.mp3.part") - - val decision = decide(root, folder, appOwned = false) as CancelCleanupDecision.Delete - assertEquals(listOf("02.mp3.part"), decision.files.map { it.name }) - assertFalse(decision.removeFolder) + // A .part from a browser or sync app looks exactly like one of ours. + val folder = bookFolder(root, "01.mp3", "02.mp3.part", "someone-elses.zip.part") - applyCancelCleanup(decision) + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.USER_FOLDER), + decide(root, folder, appOwned = false), + ) assertTrue(File(folder, "01.mp3").exists()) - assertFalse(File(folder, "02.mp3.part").exists()) + assertTrue(File(folder, "02.mp3.part").exists()) + assertTrue(File(folder, "someone-elses.zip.part").exists()) } @Test From e0e60089c33497f21508a4eaf73773562dded953 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 21:30:26 -0400 Subject: [PATCH 11/69] Cancel cleanup and resume can no longer cross Cancel read the download rows and deleted files without waiting on resume. A resume could read a Paused row, cancel could delete it and see an empty queue, then resume wrote the row back and started the engine, and cleanup deleted the files the resumed download was reusing. One lock now covers cancel from its row read through the last deleted file, resume from its read to the drain start, the write that makes a newly queued book pickable, and every drain start. Right before deleting, cleanup also checks again that no row for that book or folder exists and the engine is not on that book. Co-Authored-By: Claude Opus 5.5 --- .../app/service/DownloadManager.kt | 157 +++++++++++++----- .../app/service/download/CancelCleanup.kt | 20 +++ .../service/download/DownloadQueueWorker.kt | 28 ++-- .../app/service/download/CancelCleanupTest.kt | 40 +++++ 4 files changed, 188 insertions(+), 57 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 0fe212f6..3426a9ef 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt @@ -16,6 +16,7 @@ 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.DownloadEngine @@ -77,6 +78,21 @@ class DownloadManager @Inject constructor( * first, the fetch runs outside the lock, and promotion re-takes it. */ private val slotMutex = Mutex() + + /** + * 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 + * 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. + */ + private val rowMutex = Mutex() private val workManager: WorkManager by lazy { WorkManager.getInstance(context) } /** @@ -135,6 +151,23 @@ 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 + + /** Called by [DownloadQueueWorker] right before it runs the engine on a book. */ + fun onEngineStarted(audioBookId: String) { + engineBookId = audioBookId + } + + /** Called by [DownloadQueueWorker] from a finally once the engine returns or throws. */ + fun onEngineStopped() { + engineBookId = null + } + /** * Re-show the paused notification when a persisted pause survived the * process that owned its only Resume control. @@ -297,23 +330,25 @@ class DownloadManager @Inject constructor( // entitlement drop resolved a different winner, or a stranded-claim // sweep cleaned it up. A blind upsert would resurrect it and put the // free tier over its cap. - val promoted = slotMutex.withLock { - val existing = downloadItemDao.getById(downloadId) - val rowStillOurs = existing?.status == DownloadStatus.Preparing.ordinal - // Status alone is not sufficient. An entitlement drop during the - // fetch can resolve a DIFFERENT winner while this row sits untouched - // in Preparing, and promoting it then would put a free install over - // its cap with the row looking perfectly legitimate. - val stillTheWinner = slotStore.canClaim(audioBook.id) - - if (!rowStillOurs || !stillTheWinner) { - // Failed promotion deletes its own row rather than leaving an - // orphan occupying the slot forever. - if (existing != null) downloadItemDao.deleteById(downloadId) - return@withLock false + val promoted = rowMutex.withLock { + slotMutex.withLock { + val existing = downloadItemDao.getById(downloadId) + val rowStillOurs = existing?.status == DownloadStatus.Preparing.ordinal + // Status alone is not sufficient. An entitlement drop during the + // fetch can resolve a DIFFERENT winner while this row sits untouched + // in Preparing, and promoting it then would put a free install over + // its cap with the row looking perfectly legitimate. + val stillTheWinner = slotStore.canClaim(audioBook.id) + + if (!rowStillOurs || !stillTheWinner) { + // Failed promotion deletes its own row rather than leaving an + // orphan occupying the slot forever. + if (existing != null) downloadItemDao.deleteById(downloadId) + return@withLock false + } + downloadItemDao.upsert(downloadItem.toEntity()) + true } - downloadItemDao.upsert(downloadItem.toEntity()) - true } claimSettled = true if (!promoted) return QueueResult.BlockedByFreeSlot(slotStore.persistedWinner) @@ -495,13 +530,17 @@ class DownloadManager @Inject constructor( /** Resume a paused/failed download by re-queuing it and ensuring the drain runs. */ suspend fun resumeDownload(downloadId: String) { - val entity = downloadItemDao.getById(downloadId) ?: return - val item = entity.toDomain() - if (item.status != DownloadStatus.Paused && item.status != DownloadStatus.Failed) return - - // Reset to Queued. The engine skips already-finished files on re-run. - downloadItemDao.upsert(entity.copy(status = DownloadStatus.Queued.ordinal)) - if (!downloadsPaused) enqueueDrain(replace = false) + // One critical section from the read to the drain start, so a cancel + // either sees the Queued row or deletes it before this reads it. + rowMutex.withLock { + val entity = downloadItemDao.getById(downloadId) ?: return + val item = entity.toDomain() + if (item.status != DownloadStatus.Paused && item.status != DownloadStatus.Failed) return + + // Reset to Queued. The engine skips already-finished files on re-run. + downloadItemDao.upsert(entity.copy(status = DownloadStatus.Queued.ordinal)) + if (!downloadsPaused) enqueueDrainHoldingRowLock(replace = false) + } } /** Pause the whole download queue: stop the drain and show a Resume notification. */ @@ -515,7 +554,7 @@ class DownloadManager @Inject constructor( } /** Resume the whole download queue. */ - fun resumeQueue() { + suspend fun resumeQueue() { downloadsPaused = false DownloadNotifications.clearPaused(context) enqueueDrain(replace = false) @@ -523,15 +562,21 @@ class DownloadManager @Inject constructor( /** Cancel a download and clean up; restart the drain if it was the active one. */ suspend fun cancelDownload(downloadId: String): Unit = finishInOwnerScope(scope) { - val entity = downloadItemDao.getById(downloadId) - val wasDownloading = entity?.status == DownloadStatus.Downloading.ordinal - var stopConfirmed = false - writeAfterEngineStops(wasDownloading, { stopConfirmed = stopDrainAndAwait() }) { - downloadItemDao.deleteById(downloadId) - // Still before any drain restart below, so nothing is writing here. - if (entity != null && cancelMayTouchFiles(entity.status, wasDownloading, stopConfirmed)) { - removeCancelledPartialFiles(entity.audioBookId) + // 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. + val wasDownloading = rowMutex.withLock { + val entity = downloadItemDao.getById(downloadId) + val downloading = entity?.status == DownloadStatus.Downloading.ordinal + var stopConfirmed = false + writeAfterEngineStops(downloading, { stopConfirmed = stopDrainAndAwait() }) { + downloadItemDao.deleteById(downloadId) + // Still before any drain restart below, so nothing is writing here. + if (entity != null && cancelMayTouchFiles(entity.status, downloading, stopConfirmed)) { + removeCancelledPartialFiles(entity.audioBookId) + } } + downloading } if (downloadItemDao.getDownloadable().isEmpty()) { @@ -549,9 +594,12 @@ class DownloadManager @Inject constructor( * 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. The rules live in [decideCancelCleanup] and keep the - * files on any doubt, including when another book or download points at the - * same folder (#49). Best effort: a failure here never fails the cancel. + * 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. */ private suspend fun removeCancelledPartialFiles(audioBookId: String) { try { @@ -560,19 +608,29 @@ class DownloadManager @Inject constructor( 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. - val otherRows = downloadItemDao.getAll() - val otherBooks = audioBookDao.getByIds(otherRows.map { it.audioBookId }.distinct()) - val otherFolders = otherBooks.map { engine.downloadLocationFor(it.toDomain()).folder } + suspend fun liveRows() = downloadItemDao.getAll().map { it.audioBookId }.distinct() + suspend fun foldersOf(bookIds: List) = + audioBookDao.getByIds(bookIds).map { engine.downloadLocationFor(it.toDomain()).folder } + val otherFolders = foldersOf(liveRows()) val bookIsDownloaded = bookEntity.isDownloaded == 1 || !bookEntity.localPath.isNullOrEmpty() - withContext(Dispatchers.IO) { - when (val decision = decideCancelCleanup(location, bookIsDownloaded, otherBookPaths, otherFolders)) { - is CancelCleanupDecision.Keep -> - android.util.Log.d("DownloadManager", "cancel cleanup kept files: ${decision.reason}") - is CancelCleanupDecision.Delete -> { - val count = applyCancelCleanup(decision) - android.util.Log.d("DownloadManager", "cancel cleanup removed $count files") + val decision = withContext(Dispatchers.IO) { + decideCancelCleanup(location, bookIsDownloaded, otherBookPaths, otherFolders) + } + when (decision) { + 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") } } } catch (cancellation: kotlinx.coroutines.CancellationException) { @@ -661,8 +719,15 @@ class DownloadManager @Inject constructor( * [replace] = true (REPLACE): used when pausing/cancelling the active book. * It cancels the running drain (stopping the engine on the current book) and * starts a fresh drain that skips the now paused/removed item and continues. + * + * Takes [rowMutex], so no drain starts while a cancel is judging files. */ - private fun enqueueDrain(replace: Boolean) { + private suspend fun enqueueDrain(replace: Boolean) { + rowMutex.withLock { enqueueDrainHoldingRowLock(replace) } + } + + /** [enqueueDrain] for a caller that already holds [rowMutex]. */ + private 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 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 f18dd88c..bdb96215 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 @@ -183,6 +183,26 @@ 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. 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 2e93cf9f..64897a71 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 @@ -115,19 +115,25 @@ class DownloadQueueWorker( // and Android rate-limits notification posts, so unthrottled updates // get dropped and the bar appears frozen / far behind. var lastNotifiedPercent = -1 - val result = withContext(Dispatchers.IO) { - engine.download(item, book) { id, downloaded, total -> - manager.publishProgress(id, downloaded, total) - val percent = if (total > 0) { - ((downloaded.toDouble() / total) * 100).toInt() - } else { - -1 - } - if (percent != lastNotifiedPercent) { - lastNotifiedPercent = percent - setForeground(foregroundInfo(item.title, downloaded, total)) + // Cancel cleanup checks this last thing before deleting files. + manager.onEngineStarted(item.audioBookId) + val result = try { + withContext(Dispatchers.IO) { + engine.download(item, book) { id, downloaded, total -> + manager.publishProgress(id, downloaded, total) + val percent = if (total > 0) { + ((downloaded.toDouble() / total) * 100).toInt() + } else { + -1 + } + if (percent != lastNotifiedPercent) { + lastNotifiedPercent = percent + setForeground(foregroundInfo(item.title, downloaded, total)) + } } } + } finally { + manager.onEngineStopped() } manager.notifyTerminal(result) android.util.Log.d(TAG, "done id=${item.id} result=${result.status}") 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 e4e255f5..06b81919 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,6 +272,46 @@ 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 From fba84f249cf5f38bddff90f5da448365d8d32840 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 21:31:40 -0400 Subject: [PATCH 12/69] Home's buttons bring back the tab as it was left Home's "Open Library", "Enter The Archive" and "Open Settings" buttons pushed a fresh copy of the tab instead of the one the user left, so its scroll and loaded shelf were gone. Book detail's jumps to Settings and the Player had the same gap. All of them now switch tabs the way the bottom bar does, saving the tab being left and restoring the one being opened. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/navigation/NineLivesNavHost.kt | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/NineLivesNavHost.kt b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/NineLivesNavHost.kt index 4ffcd350..d6b38037 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/NineLivesNavHost.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/NineLivesNavHost.kt @@ -74,10 +74,10 @@ fun NineLivesNavHost( composable(Routes.HOME) { HomeScreen( + // Tab targets go through navigateToTab so the tab comes back as + // the user left it instead of as a fresh second copy. onNavigateToLibrary = { - navController.navigate(Routes.LIBRARY) { - launchSingleTop = true - } + navController.navigateToTab(Routes.LIBRARY) }, onNavigateToBookDetail = { bookId -> navController.navigate(Routes.bookDetail(bookId)) @@ -93,9 +93,7 @@ fun NineLivesNavHost( } }, onNavigateToSettings = { - navController.navigate(Routes.SETTINGS) { - launchSingleTop = true - } + navController.navigateToTab(Routes.SETTINGS) }, ) } @@ -201,17 +199,10 @@ fun NineLivesNavHost( bookId = bookId, onNavigateBack = { navController.popBackStack() }, onNavigateToSettings = { - navController.navigate(Routes.SETTINGS) { - launchSingleTop = true - } + navController.navigateToTab(Routes.SETTINGS) }, onNavigateToPlayer = { - navController.navigate(Routes.PLAYER) { - popUpTo(TAB_ROOT_ROUTE) { - inclusive = false - } - launchSingleTop = true - } + navController.navigateToTab(Routes.PLAYER) } ) } From cea3b76d4e819bac84c17a5bffe7d2aeddace3f2 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 21:29:57 -0400 Subject: [PATCH 13/69] The background sync pulls progress every 5 minutes and the book list hourly The 5-minute timer re-downloaded every library's full book list on every tick, which on a big library and a slow home server meant tens of megabytes and minutes of server work around the clock. The tick now pulls listening progress and retries the offline queue, and runs the full sync only when the last one is an hour old or was for another server. App start, sign-in, a server or mode switch, Home's reconnect, a server return after a failed sync, Settings' Sync Now, and the Library's pull to refresh still fetch right away. A progress-only tick writes no sync record, so the Library's banners and shelf choices only move on real list syncs, and the Library re-reads its saved shelf after each tick so pulled progress still shows. Co-Authored-By: Claude Opus 5.5 --- .../LibraryLocalCatalogLiveUpdateTest.kt | 1 + .../library/LibraryViewModelFilterRaceTest.kt | 1 + .../ninelivesaudio/app/service/SyncManager.kt | 125 +++++++++++++++--- .../app/ui/library/LibraryViewModel.kt | 12 ++ .../app/service/PeriodicItemSyncTest.kt | 49 +++++++ 5 files changed, 173 insertions(+), 15 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/PeriodicItemSyncTest.kt diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt index aea81e05..25d2372a 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt @@ -171,6 +171,7 @@ class LibraryLocalCatalogLiveUpdateTest { settingsManager = app.settingsManager, entitlements = app.entitlementRepository, localFolderAccess = LocalFolderAccess(app), + syncManager = app.syncManager, ) as T } lateinit var viewModel: LibraryViewModel diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt index 9884b77a..70243590 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt @@ -140,6 +140,7 @@ private class LibraryViewModelFixture(app: NineLivesApp, database: AppDatabase) settingsManager = app.settingsManager, entitlements = app.entitlementRepository, localFolderAccess = LocalFolderAccess(app), + syncManager = app.syncManager, ) suspend fun awaitInitialShelf() { diff --git a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt index 01a884ea..13b4b87e 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt @@ -2,6 +2,7 @@ package com.ninelivesaudio.app.service import com.ninelivesaudio.app.data.remote.ApiService +import android.os.SystemClock import android.util.Log import com.ninelivesaudio.app.data.local.converter.toDomain import com.ninelivesaudio.app.data.local.converter.toEntity @@ -32,8 +33,9 @@ private const val MIN_PROGRESS_DELTA = 0.01 * Manages periodic synchronization with the Audiobookshelf server. * * Responsibilities: - * - Periodic sync of libraries and audiobooks from server - * - Progress sync (pull from server, push offline queue) + * - Full sync of libraries and audiobooks at start, on explicit triggers, + * and at most hourly from the timer (see [shouldRefreshItemList]) + * - Progress sync every tick (pull from server, push offline queue) * - Offline queue flushing on reconnect * - Throttled position reporting during playback * @@ -62,6 +64,13 @@ class SyncManager @Inject constructor( // Sync state @Volatile private var activeItemId: String? = null + // When a full sync last ran the book-list fetch, for the timer's hourly + // decision. Kept in memory on purpose. Every cold start runs the initial + // full sync anyway, so a persisted stamp would never change a decision, + // and the persisted LastSyncRecord cannot stand in for it because the + // Library's single-shelf refresh writes that record too. + @Volatile private var lastFullItemSync: FullItemSyncStamp? = null + private val playbackThrottleOwner = PlaybackThrottleOwner() // ─── Events ────────────────────────────────────────────────────────────── @@ -85,6 +94,14 @@ class SyncManager @Inject constructor( private val _syncCompleted = MutableSharedFlow(replay = 0, extraBufferCapacity = 4) val syncCompleted: SharedFlow = _syncCompleted.asSharedFlow() + /** + * A progress-only tick finished. It writes no [LastSyncRecord], so a + * screen that re-reads its shelf on new records listens here instead to + * pick up the progress it just pulled into Room. + */ + private val _progressPulled = MutableSharedFlow(replay = 0, extraBufferCapacity = 4) + val progressPulled: SharedFlow = _progressPulled.asSharedFlow() + // ─── Lifecycle ─────────────────────────────────────────────────────────── /** @@ -98,10 +115,21 @@ class SyncManager @Inject constructor( delay(INITIAL_DELAY_MS) syncNow() - // Periodic sync + // Periodic sync. Progress every tick, the full book list at + // most hourly. A big library on a slow server used to pay a + // full re-download every 5 minutes here. while (isActive) { delay(DEFAULT_SYNC_INTERVAL_MS) - syncNow() + if (shouldRefreshItemList( + lastFullSync = lastFullItemSync, + serverUrl = settingsManager.currentSettings.serverUrl, + nowElapsedMs = SystemClock.elapsedRealtime(), + ) + ) { + syncNow() + } else { + syncProgressOnly() + } // Also retry the offline queue here. The rising-edge flush only // fires once per reconnect, so a push that failed right after // reconnect would otherwise sit queued until the next full @@ -201,16 +229,7 @@ class SyncManager @Inject constructor( val serverUrlAtStart = settingsManager.currentSettings.serverUrl return runSyncAttempt( - // Cheap pre-check: authenticated, non-LOCAL, and the OS reports a - // network. Evaluated fresh every call — the post-probe recheck - // below must see a mode switch or sign-out that landed mid-probe. - isSyncReady = { - shouldRunSync( - isOnline = connectivityMonitor.isOnline.value, - isLocalMode = settingsManager.currentSettings.appMode == AppMode.LOCAL, - hasAuth = hasAuthToken(), - ) - }, + isSyncReady = { isSyncReady() }, // Actually reach the server before committing to a sync. A live // VPN interface (e.g. Tailscale) keeps isOnline=true with no real // connectivity, so without this probe the sync fires and hangs @@ -236,7 +255,15 @@ class SyncManager @Inject constructor( fetchLibraries = libraryRepository::syncFromServer, // syncLibraryItems already preserves local download state. fetchItems = { library -> audioBookRepository.syncLibraryItems(library.id) }, - ) + ).also { + // Stamped whatever the result. The hourly cap protects a + // slow server, and a timeout there is exactly when a + // 5-minute retry of the whole list hurts most. + lastFullItemSync = FullItemSyncStamp( + serverUrl = serverUrlAtStart, + atElapsedMs = SystemClock.elapsedRealtime(), + ) + } }, persistOutcome = { report -> persistSyncOutcome( @@ -276,6 +303,44 @@ class SyncManager @Inject constructor( ) } + /** + * The timer's tick between full syncs: pull listening progress and + * nothing else. Same gate, reachability probe, and mutex as [syncNow], + * but it writes no [LastSyncRecord]. A progress pull says nothing about + * the book list, so recording one would fake a library verdict and flip + * the Library's sync banners and shelf decisions. An unreachable server + * is left for the next full sync to record. + */ + private suspend fun syncProgressOnly() { + if (!isSyncReady()) return + val reachable = connectivityMonitor.checkServerReachable() + if (!isSyncReady() || !reachable) return + if (!syncMutex.tryLock()) return + _isSyncing.value = true + connectivityMonitor.setSyncing(true) + try { + syncProgress() + } finally { + _isSyncing.value = false + connectivityMonitor.setSyncing(false) + syncMutex.unlock() + } + // Android Auto's browse refresh treats this as its 5-minute heartbeat. + _syncCompleted.tryEmit(Unit) + _progressPulled.tryEmit(Unit) + } + + /** + * Cheap pre-check: authenticated, non-LOCAL, and the OS reports a + * network. Evaluated fresh every call, so a recheck after the probe sees + * a mode switch or sign-out that landed mid-probe. + */ + private suspend fun isSyncReady(): Boolean = shouldRunSync( + isOnline = connectivityMonitor.isOnline.value, + isLocalMode = settingsManager.currentSettings.appMode == AppMode.LOCAL, + hasAuth = hasAuthToken(), + ) + /** * Sync progress from the server. * Server is source of truth (except for actively playing items). @@ -569,6 +634,36 @@ internal fun isServerSessionLive( internal fun shouldResyncOnServerReturn(lastResult: SyncResult?): Boolean = lastResult != null && lastResult != SyncResult.SUCCESS +/** How often the timer may re-download every library's book list. */ +internal const val FULL_ITEM_SYNC_INTERVAL_MS = 3_600_000L // 1 hour + +/** When a full sync last fetched the book list, and for which server. */ +internal data class FullItemSyncStamp( + val serverUrl: String, + val atElapsedMs: Long, +) + +/** + * Whether the periodic tick runs a full sync (every library's book list) or + * only pulls progress. Only the timer asks. App start, sign-in, a server + * switch, a mode switch back to the server, Home's reconnect, a server + * return after a failed sync, and Settings' Sync Now always run the full + * sync, and the Library's pull to refresh fetches its own shelf. A book + * added on the server can take up to an hour to appear without one of those. + * + * No stamp yet, a different server, or a clock that went backward all + * count as due, so the answer is never "skip" without a real recent sync. + */ +internal fun shouldRefreshItemList( + lastFullSync: FullItemSyncStamp?, + serverUrl: String, + nowElapsedMs: Long, +): Boolean { + if (lastFullSync == null || lastFullSync.serverUrl != serverUrl) return true + val elapsedMs = nowElapsedMs - lastFullSync.atElapsedMs + return elapsedMs < 0 || elapsedMs >= FULL_ITEM_SYNC_INTERVAL_MS +} + internal fun shouldReconnectForModeTransition( previousMode: AppMode, newMode: AppMode, 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 bf9abf6f..c92638a6 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 @@ -22,6 +22,7 @@ import com.ninelivesaudio.app.service.ConnectivityMonitor import com.ninelivesaudio.app.service.ConnectivityMonitor.ConnectionStatus import com.ninelivesaudio.app.service.PersistedSyncOutcome import com.ninelivesaudio.app.service.SettingsManager +import com.ninelivesaudio.app.service.SyncManager import com.ninelivesaudio.app.service.buildShelfSyncReport import com.ninelivesaudio.app.service.lastSyncForCurrentServer import com.ninelivesaudio.app.service.persistActiveLibrarySelection @@ -267,6 +268,7 @@ class LibraryViewModel @Inject constructor( private val settingsManager: SettingsManager, private val entitlements: EntitlementRepository, private val localFolderAccess: LocalFolderAccess, + private val syncManager: SyncManager, ) : ViewModel() { // ─── UI State ───────────────────────────────────────────────────────── @@ -443,6 +445,16 @@ class LibraryViewModel @Inject constructor( } } + // The timer's progress-only ticks write no sync record, so the + // collector above never sees them. Re-read the saved shelf so + // progress pulled from the server shows while the tab is open. + viewModelScope.launch { + syncManager.progressPulled.collect { + val state = _uiState.value + if (shelfLoadedFor != null && !state.isLoading && !state.isLocalMode) applyFilter() + } + } + // LOCAL mode has no sync record, so watch the local catalog itself. // A folder scan or rescan running in Settings lands here while the // tab is open, not only on the next visit. diff --git a/app/src/test/java/com/ninelivesaudio/app/service/PeriodicItemSyncTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/PeriodicItemSyncTest.kt new file mode 100644 index 00000000..cb06042d --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/PeriodicItemSyncTest.kt @@ -0,0 +1,49 @@ +package com.ninelivesaudio.app.service + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The 5-minute timer used to re-download every library's book list on every + * tick. A big library on a slow home server paid tens of megabytes each + * time. The timer now pulls progress every tick and the full list at most + * hourly. + */ +class PeriodicItemSyncTest { + + private val server = "https://abs.example.net" + private val minute = 60_000L + + @Test + fun `no full sync yet means the tick runs one`() { + assertTrue(shouldRefreshItemList(lastFullSync = null, serverUrl = server, nowElapsedMs = 0L)) + } + + @Test + fun `a full sync inside the hour leaves the tick to progress only`() { + val stamp = FullItemSyncStamp(server, atElapsedMs = 1_000L) + assertFalse(shouldRefreshItemList(stamp, server, nowElapsedMs = 1_000L + 5 * minute)) + assertFalse(shouldRefreshItemList(stamp, server, nowElapsedMs = 1_000L + 59 * minute)) + assertFalse(shouldRefreshItemList(stamp, server, nowElapsedMs = 1_000L + 60 * minute - 1)) + } + + @Test + fun `an hour after the last full sync the tick runs another`() { + val stamp = FullItemSyncStamp(server, atElapsedMs = 1_000L) + assertTrue(shouldRefreshItemList(stamp, server, nowElapsedMs = 1_000L + 60 * minute)) + assertTrue(shouldRefreshItemList(stamp, server, nowElapsedMs = 1_000L + 65 * minute)) + } + + @Test + fun `a full sync of another server does not count for this one`() { + val stamp = FullItemSyncStamp("https://old.example.net", atElapsedMs = 1_000L) + assertTrue(shouldRefreshItemList(stamp, server, nowElapsedMs = 1_000L + 5 * minute)) + } + + @Test + fun `a clock that went backward counts as due`() { + val stamp = FullItemSyncStamp(server, atElapsedMs = 10 * minute) + assertTrue(shouldRefreshItemList(stamp, server, nowElapsedMs = 5 * minute)) + } +} From b9afbd5281aa400d9d6a0ed5e09e0667b4bcb78f Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 22:51:42 -0400 Subject: [PATCH 14/69] The server can be asked what was added, and big downloads retry and stream Audiobookshelf cannot list books edited since a time, but every 2.x server sorts the library list by added date (sort=addedAt, with desc=1 for newest first) and reports the library's total book count. This adds the calls a change check needs: a one-book head request that returns the count and the newest book, and an added-since fetch that pages newest first and stops at the first book older than a cutoff. The added-since fetch checks the order instead of trusting it, refuses books without an added date, gives up past 20 pages, and treats a count that moves mid-fetch as a reason to do a full download instead. The full download gets a streaming form that hands each page to the caller and keeps only a count, so a big shelf no longer has to sit in memory until the last page. It sorts oldest-added first, so a book added during a long download lands at the end instead of shifting every later page, and drops repeated rows by id. A failed page is retried twice (2 s, then 6 s) when the failure is worth retrying, a 5xx, a timeout, or a rate limit, never a 401. The old list-returning loop now runs on the streaming one, so both share one set of stopping rules. Co-Authored-By: Claude Opus 5.5 --- .../app/data/remote/ApiService.kt | 129 +++++++++++++++++ .../app/data/remote/AudiobookshelfApi.kt | 10 +- .../app/data/remote/IncrementalFetch.kt | 136 ++++++++++++++++++ .../app/data/remote/RemoteResult.kt | 120 +++++++++++++--- .../app/data/remote/IncrementalFetchTest.kt | 134 +++++++++++++++++ .../data/remote/PageRetryAndStreamingTest.kt | 108 ++++++++++++++ 6 files changed, 619 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/data/remote/IncrementalFetch.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/data/remote/IncrementalFetchTest.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/data/remote/PageRetryAndStreamingTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt index 90ffdb89..10e824cb 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt @@ -168,6 +168,7 @@ class ApiService @Inject constructor( // any real library, so a normal shelf never reaches it. private const val LIBRARY_ITEMS_PAGE_CAP = 1000 private const val LISTENING_SESSIONS_PAGE_SIZE = 50 + private const val SORT_ADDED_AT = "addedAt" private const val LISTENING_SESSIONS_ITEM_PAGE_CAP = 3 private const val LISTENING_SESSIONS_ALL_PAGE_CAP = 20 } @@ -706,6 +707,134 @@ class ApiService @Inject constructor( } } + /** + * Every item in a library, paginated, handed to [onPage] a page at a time + * so the caller can save each page as it lands instead of holding the + * whole shelf in memory. Returns the count of distinct books delivered. + * + * A page that fails part-way through yields [RemoteResult.Partial] (or + * Failed with nothing delivered), and so does a page that merely comes + * back empty or short while the server's own reported total says more + * exist; [runPaginatedFetchStreaming] decides that (issue #14, PR #30 + * review, finding B). Only reaching the reported total is a genuine Ok. + * + * Sorted oldest-added first: a book added during a multi-minute download + * lands at the end instead of pushing every later page down by one row, + * which used to repeat one book and skip another. Rows repeated anyway + * (a removal mid-download shifts the other way) are dropped by id, and + * the count check then reports the run short rather than complete. ABS + * indexes (libraryId, mediaType, createdAt), so the order is cheap. + * A failed page gets [PAGE_RETRY_DELAYS_MS] more tries before the run + * stops short. + */ + internal suspend fun streamLibraryItems( + libraryId: String, + limit: Int = 100, + onPage: suspend (List) -> Unit, + ): RemoteResult = + withContext(Dispatchers.IO) { + runPaginatedFetchStreaming( + limit = limit, + maxPages = LIBRARY_ITEMS_PAGE_CAP, + onPageFailure = { page, e -> Log.w(TAG, "streamLibraryItems($libraryId) failed at page $page", e) }, + itemKey = { it.id }, + onPage = onPage, + ) { page -> + fetchPageWithRetry { + val response = api.getLibraryItems( + libraryId = libraryId, + limit = limit, + page = page, + sort = SORT_ADDED_AT, + desc = 0, + ) + if (!response.isSuccessful) { + Log.w(TAG, "streamLibraryItems($libraryId): HTTP ${response.code()} at page $page") + return@fetchPageWithRetry PageOutcome.Stopped( + "page $page: HTTP ${response.code()}", + retryable = isRetryableHttpStatus(response.code()), + ) + } + + val body = response.body() + if (body == null) { + Log.w(TAG, "streamLibraryItems($libraryId): no body at page $page") + return@fetchPageWithRetry PageOutcome.Stopped("page $page: empty body", retryable = true) + } + + PageOutcome.Page(body.results.map { mapToAudioBook(it, libraryId) }, body.total) + } + } + } + + /** + * The library's book count and its most recently added book, in one tiny + * request (one minified item). Used to ask "anything new since the last + * sync?" without downloading the shelf. A missing total next to a + * non-empty page is reported as a failure, because a zero total would + * otherwise read as "the library emptied". + */ + internal suspend fun getLibraryHead(libraryId: String): RemoteResult = + withContext(Dispatchers.IO) { + remoteResultCatching(onFailure = { e -> Log.w(TAG, "getLibraryHead($libraryId) failed", e) }) { + val response = api.getLibraryItems( + libraryId = libraryId, + limit = 1, + page = 0, + minified = 1, + sort = SORT_ADDED_AT, + desc = 1, + ) + if (!response.isSuccessful) return@remoteResultCatching RemoteResult.Failed("head: HTTP ${response.code()}") + val body = response.body() ?: return@remoteResultCatching RemoteResult.Failed("head: empty body") + val newest = body.results.firstOrNull() + if (newest != null && body.total <= 0) { + return@remoteResultCatching RemoteResult.Failed("head: no book count reported") + } + RemoteResult.Ok(LibraryHead(total = body.total, newestItemId = newest?.id, newestAddedAt = newest?.addedAt)) + } + } + + /** + * Books added at or after [cutoff], newest first, mapped exactly as the + * full download maps them. Pages stop at the first older book; see + * [runIncrementalFetch] for when this gives up instead. + */ + internal suspend fun getLibraryItemsAddedSince( + libraryId: String, + cutoff: Long, + pageSize: Int, + maxPages: Int, + ): RemoteResult> = withContext(Dispatchers.IO) { + runIncrementalFetch( + pageSize = pageSize, + maxPages = maxPages, + cutoff = cutoff, + addedAt = { it.addedAt }, + itemKey = { it.id }, + onPageFailure = { page, e -> Log.w(TAG, "getLibraryItemsAddedSince($libraryId) failed at page $page", e) }, + ) { page -> + fetchPageWithRetry { + val response = api.getLibraryItems( + libraryId = libraryId, + limit = pageSize, + page = page, + minified = 0, + sort = SORT_ADDED_AT, + desc = 1, + ) + if (!response.isSuccessful) { + return@fetchPageWithRetry PageOutcome.Stopped( + "HTTP ${response.code()}", + retryable = isRetryableHttpStatus(response.code()), + ) + } + val body = response.body() ?: return@fetchPageWithRetry PageOutcome.Stopped("empty body", retryable = true) + PageOutcome.Page(body.results.map { mapToAudioBook(it, libraryId) }, body.total) + } + } + } + // ─── Single Item ───────────────────────────────────────────────────── suspend fun getAudioBook(itemId: String): AudioBook? = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/AudiobookshelfApi.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/AudiobookshelfApi.kt index 0a59cbec..12c5e0a1 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/remote/AudiobookshelfApi.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/AudiobookshelfApi.kt @@ -49,13 +49,21 @@ interface AudiobookshelfApi { @GET("api/libraries") suspend fun getLibraries(): Response - /** Get paginated library items. */ + /** + * Get paginated library items. [sort] and [desc] are left off the URL + * when null, which keeps the full download's request exactly as it was. + * `sort=addedAt` is honoured by every Audiobookshelf 2.x server (the + * SQL-backed list since 2.4 maps it to the item's createdAt column, the + * older in-memory list sorts by the item's addedAt field). + */ @GET("api/libraries/{libraryId}/items") suspend fun getLibraryItems( @Path("libraryId") libraryId: String, @Query("limit") limit: Int = 100, @Query("page") page: Int = 0, @Query("minified") minified: Int = 0, + @Query("sort") sort: String? = null, + @Query("desc") desc: Int? = null, ): Response // ─── Items ─────────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/IncrementalFetch.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/IncrementalFetch.kt new file mode 100644 index 00000000..e41a2172 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/IncrementalFetch.kt @@ -0,0 +1,136 @@ +package com.ninelivesaudio.app.data.remote + +import kotlinx.coroutines.CancellationException + +/** + * The cheapest question a library can be asked: how many books it has and + * which one was added last. One `limit=1&minified=1&sort=addedAt&desc=1` + * request on Audiobookshelf. [newestItemId] and [newestAddedAt] are null when + * the library is empty. + */ +internal data class LibraryHead( + val total: Int, + val newestItemId: String?, + val newestAddedAt: Long?, +) + +/** What an "added since" fetch brought back: the books and the server's book count. */ +internal data class IncrementalFetch( + val items: List, + val total: Int, +) + +/** What to do after one page of an "added since" fetch. */ +internal sealed interface IncrementalPageStep { + /** Every book on the page is newer than the cutoff and more may follow. */ + data object Continue : IncrementalPageStep + + /** The page reached the cutoff or the end of the list. */ + data object Done : IncrementalPageStep + + /** + * The answer cannot be trusted or is not worth finishing. The caller + * falls back to a full download instead of guessing. + */ + data class GiveUp(val reason: String) : IncrementalPageStep +} + +/** + * Decides one page of a newest-first "added since" fetch. + * + * Audiobookshelf has no "updated since" sort, so the fetch pages through books + * newest-added first and stops at the first book older than [cutoff]. The + * order is checked rather than assumed: a server that ignored the sort would + * hand back books in any order, and stopping at the first old one would then + * miss new books further down. Equal neighbours are fine, because a book + * added while paging pushes the next page down by one and repeats a row. + * + * [previousPageLast] is the last added time seen on the page before, so the + * order is checked across the page boundary too. A book with no added time + * gives up, as does running out of [maxPages] (that many new books is a job + * for the full download). + */ +internal fun incrementalPageStep( + addedAts: List, + previousPageLast: Long?, + cutoff: Long, + pageSize: Int, + pageIndex: Int, + maxPages: Int, +): IncrementalPageStep { + if (addedAts.isEmpty()) return IncrementalPageStep.Done + if (addedAts.any { it == null }) return IncrementalPageStep.GiveUp("a book came back without an added date") + val times = addedAts.map { it!! } + var last = previousPageLast + for (time in times) { + if (last != null && time > last) { + return IncrementalPageStep.GiveUp("the server did not sort by added date") + } + last = time + } + if (times.last() < cutoff) return IncrementalPageStep.Done + if (times.size < pageSize) return IncrementalPageStep.Done + if (pageIndex + 1 >= maxPages) return IncrementalPageStep.GiveUp("more than $maxPages pages of new books") + return IncrementalPageStep.Continue +} + +/** + * The paging loop for an "added since" fetch, kept free of Retrofit and + * logging so its stopping rules are pinned by tests against a fake + * [fetchPage]. Rows repeated across pages (a book added mid-fetch shifts the + * offsets) are dropped by [itemKey]. A server whose reported total changes + * between pages is mid-change, and the fetch gives up rather than reason + * about a moving count. + * + * Every failure comes back as [RemoteResult.Failed]. There is no partial + * answer here: the caller either trusts the whole thing or does a full + * download. Cancellation is rethrown. + */ +internal suspend fun runIncrementalFetch( + pageSize: Int, + maxPages: Int, + cutoff: Long, + addedAt: (T) -> Long?, + itemKey: (T) -> String, + onPageFailure: (page: Int, e: Exception) -> Unit = { _, _ -> }, + fetchPage: suspend (page: Int) -> PageOutcome, +): RemoteResult> { + require(maxPages > 0) { "maxPages must be positive" } + val items = mutableListOf() + val seen = mutableSetOf() + var page = 0 + var total: Int? = null + var previousLast: Long? = null + return try { + while (true) { + val outcome = fetchPage(page) + if (outcome is PageOutcome.Stopped) return RemoteResult.Failed(outcome.reason) + outcome as PageOutcome.Page + if (total != null && outcome.total != total) { + return RemoteResult.Failed("page $page: book count moved from $total to ${outcome.total}") + } + total = outcome.total + val addedAts = outcome.results.map(addedAt) + val step = incrementalPageStep( + addedAts = addedAts, + previousPageLast = previousLast, + cutoff = cutoff, + pageSize = pageSize, + pageIndex = page, + maxPages = maxPages, + ) + if (step is IncrementalPageStep.GiveUp) return RemoteResult.Failed("page $page: ${step.reason}") + outcome.results.forEach { if (seen.add(itemKey(it))) items.add(it) } + previousLast = addedAts.lastOrNull() ?: previousLast + if (step == IncrementalPageStep.Done) return RemoteResult.Ok(IncrementalFetch(items.toList(), outcome.total)) + page++ + } + @Suppress("UNREACHABLE_CODE") + error("unreachable: the loop above only exits via return") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + onPageFailure(page, e) + RemoteResult.Failed("page $page: ${describeFailure(e)}") + } +} diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/RemoteResult.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/RemoteResult.kt index eb85969c..f5714972 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/remote/RemoteResult.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/RemoteResult.kt @@ -65,11 +65,12 @@ internal sealed class PageOutcome { val reportedPage: Int? = null, val reportedPageCount: Int? = null, ) : PageOutcome() - data class Stopped(val reason: String) : PageOutcome() + /** [retryable] marks a stop worth another try (a 5xx, a rate limit), not a 4xx. */ + data class Stopped(val reason: String, val retryable: Boolean = false) : PageOutcome() } /** - * The pagination loop ApiService.getLibraryItems() (and any future paginated + * The pagination loop ApiService.streamLibraryItems() (and any future paginated * fetch) runs, extracted so its termination logic is pinned directly against * a fake [fetchPage] instead of only being exercised through a live Retrofit * call. A page whose [PageOutcome.Page.results] come back empty, or shorter @@ -96,8 +97,53 @@ internal suspend fun runPaginatedFetch( itemKey: ((T) -> String)? = null, fetchPage: suspend (page: Int) -> PageOutcome, ): RemoteResult> { - require(maxPages > 0) { "maxPages must be positive" } val allItems = mutableListOf() + return when ( + val counted = runPaginatedFetchStreaming( + limit = limit, + maxPages = maxPages, + onPageFailure = onPageFailure, + itemKey = itemKey, + onPage = { allItems.addAll(it) }, + fetchPage = fetchPage, + ) + ) { + is RemoteResult.Ok -> RemoteResult.Ok(allItems.toList()) + is RemoteResult.Partial -> RemoteResult.Partial(allItems.toList(), counted.reason) + is RemoteResult.Failed -> counted + } +} + +/** [stoppedShort] by count, for a fetch that hands its pages on instead of keeping them. */ +private fun stoppedShortCount(fetched: Int, reason: String): RemoteResult = + if (fetched == 0) RemoteResult.Failed(reason) else RemoteResult.Partial(fetched, reason) + +/** [paginationResult] by count. */ +private fun paginationCountResult(fetched: Int, total: Int, currentPage: Int): RemoteResult = + if (total == 0 || fetched >= total) { + RemoteResult.Ok(fetched) + } else { + stoppedShortCount(fetched, "page $currentPage: got $fetched of $total reported") + } + +/** + * [runPaginatedFetch] without holding the pages: each page's new rows go to + * [onPage] as they arrive and only the count comes back. The termination + * rules are the same code, so Ok still means the reported total was reached. + * A big library's sync saves each page as it lands instead of keeping every + * book in memory until the last page, and a page that throws in [onPage] + * stops the fetch short exactly like a failed request. + */ +internal suspend fun runPaginatedFetchStreaming( + limit: Int, + maxPages: Int, + onPageFailure: (page: Int, e: Exception) -> Unit = { _, _ -> }, + itemKey: ((T) -> String)? = null, + onPage: suspend (List) -> Unit, + fetchPage: suspend (page: Int) -> PageOutcome, +): RemoteResult { + require(maxPages > 0) { "maxPages must be positive" } + var fetched = 0 val seenKeys = mutableSetOf() var currentPage = 0 var highestReportedTotal = 0 @@ -105,14 +151,14 @@ internal suspend fun runPaginatedFetch( return try { while (true) { if (currentPage >= maxPages) { - return stoppedShort(allItems, "page $currentPage: reached the $maxPages page cap") + return stoppedShortCount(fetched, "page $currentPage: reached the $maxPages page cap") } when (val outcome = fetchPage(currentPage)) { - is PageOutcome.Stopped -> return stoppedShort(allItems, outcome.reason) + is PageOutcome.Stopped -> return stoppedShortCount(fetched, outcome.reason) is PageOutcome.Page -> { if (outcome.reportedPage != null && outcome.reportedPage != currentPage) { - return stoppedShort( - allItems, + return stoppedShortCount( + fetched, "page $currentPage: server reported page ${outcome.reportedPage}", ) } @@ -123,37 +169,38 @@ internal suspend fun runPaginatedFetch( if (outcome.results.isNotEmpty() && outcome.reportedPageCount != null && outcome.reportedPageCount <= currentPage ) { - return stoppedShort( - allItems, + return stoppedShortCount( + fetched, "page $currentPage: invalid page count ${outcome.reportedPageCount}", ) } if (expectedPageCount != null && outcome.reportedPageCount != null && expectedPageCount != outcome.reportedPageCount ) { - return stoppedShort( - allItems, + return stoppedShortCount( + fetched, "page $currentPage: server changed page count from $expectedPageCount to ${outcome.reportedPageCount}", ) } if (outcome.reportedPageCount != null) expectedPageCount = outcome.reportedPageCount highestReportedTotal = maxOf(highestReportedTotal, outcome.total) if (outcome.results.isEmpty()) { - return paginationResult(allItems, highestReportedTotal, currentPage) + return paginationCountResult(fetched, highestReportedTotal, currentPage) } val newResults = itemKey?.let { key -> outcome.results.filter { seenKeys.add(key(it)) } } ?: outcome.results if (newResults.isEmpty()) { - return stoppedShort(allItems, "page $currentPage: repeated rows made no progress") + return stoppedShortCount(fetched, "page $currentPage: repeated rows made no progress") } - allItems.addAll(newResults) + onPage(newResults) + fetched += newResults.size if ( - (highestReportedTotal > 0 && allItems.size >= highestReportedTotal) || + (highestReportedTotal > 0 && fetched >= highestReportedTotal) || outcome.results.size < limit || (outcome.reportedPageCount != null && currentPage == outcome.reportedPageCount - 1) ) { - return paginationResult(allItems, highestReportedTotal, currentPage) + return paginationCountResult(fetched, highestReportedTotal, currentPage) } currentPage++ } @@ -165,10 +212,49 @@ internal suspend fun runPaginatedFetch( throw e } catch (e: Exception) { onPageFailure(currentPage, e) - stoppedShort(allItems, "page $currentPage: ${describeFailure(e)}") + stoppedShortCount(fetched, "page $currentPage: ${describeFailure(e)}") + } +} + +/** How long a failed library page waits before each retry. */ +internal val PAGE_RETRY_DELAYS_MS = listOf(2_000L, 6_000L) + +/** + * Fetches one page, trying again after each of [delaysMs] when it fails in a + * way worth retrying: a thrown network error, or a [PageOutcome.Stopped] + * marked retryable (a 5xx or a rate limit). A 401 or 404 is returned at + * once. One slow page on a slow home server used to end the whole download + * short with no second try. The last failure is returned (or rethrown) as + * is, so the paging loop reports it exactly as before. Cancellation is + * never retried. + */ +internal suspend fun fetchPageWithRetry( + delaysMs: List = PAGE_RETRY_DELAYS_MS, + sleep: suspend (Long) -> Unit = { kotlinx.coroutines.delay(it) }, + fetch: suspend () -> PageOutcome, +): PageOutcome { + var attempt = 0 + while (true) { + val outcome = try { + fetch() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (attempt >= delaysMs.size) throw e + null + } + if (outcome != null) { + val retry = outcome is PageOutcome.Stopped && outcome.retryable && attempt < delaysMs.size + if (!retry) return outcome + } + sleep(delaysMs[attempt]) + attempt++ } } +/** HTTP statuses worth another try: server errors, timeouts, and rate limits. */ +internal fun isRetryableHttpStatus(code: Int): Boolean = code >= 500 || code == 408 || code == 429 + private const val MAX_FAILURE_REASON = 120 /** diff --git a/app/src/test/java/com/ninelivesaudio/app/data/remote/IncrementalFetchTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/remote/IncrementalFetchTest.kt new file mode 100644 index 00000000..96117b93 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/remote/IncrementalFetchTest.kt @@ -0,0 +1,134 @@ +package com.ninelivesaudio.app.data.remote + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Audiobookshelf can be asked for books newest-added first but not "updated + * since", so the incremental sync pages down until it passes the stored + * watermark. These pin where it stops, and when it refuses to guess. + */ +class IncrementalFetchTest { + + private data class Row(val id: String, val addedAt: Long?) + + private fun page(vararg rows: Row, total: Int) = PageOutcome.Page(rows.toList(), total) + + private suspend fun fetch( + cutoff: Long, + pageSize: Int = 3, + maxPages: Int = 5, + pages: (Int) -> PageOutcome, + ): RemoteResult> = runIncrementalFetch( + pageSize = pageSize, + maxPages = maxPages, + cutoff = cutoff, + addedAt = { it.addedAt }, + itemKey = { it.id }, + fetchPage = { pages(it) }, + ) + + @Test + fun `a page that crosses the watermark ends the fetch`() = runBlocking { + val requested = mutableListOf() + val result = fetch(cutoff = 100) { p -> + requested += p + when (p) { + 0 -> page(Row("e", 500), Row("d", 400), Row("c", 300), total = 10) + 1 -> page(Row("b", 200), Row("a", 90), Row("z", 80), total = 10) + else -> error("must not fetch page $p") + } + } + assertEquals(listOf(0, 1), requested) + val value = (result as RemoteResult.Ok).value + assertEquals(listOf("e", "d", "c", "b", "a", "z"), value.items.map { it.id }) + assertEquals(10, value.total) + } + + @Test + fun `a short page is the end of the list`() = runBlocking { + val result = fetch(cutoff = 0) { p -> + if (p == 0) page(Row("b", 200), Row("a", 100), total = 2) else error("no page $p") + } + assertEquals(2, (result as RemoteResult.Ok).value.items.size) + } + + @Test + fun `a server that ignored the sort gives up instead of missing books`() = runBlocking { + val result = fetch(cutoff = 100) { page(Row("a", 300), Row("b", 900), Row("c", 50), total = 3) } + assertTrue(result is RemoteResult.Failed) + } + + @Test + fun `the order is checked across pages too`() = runBlocking { + val result = fetch(cutoff = 0) { p -> + when (p) { + 0 -> page(Row("c", 300), Row("b", 200), Row("a", 150), total = 6) + else -> page(Row("x", 160), Row("y", 140), total = 6) + } + } + assertTrue(result is RemoteResult.Failed) + } + + @Test + fun `a book repeated across pages counts once and equal times are fine`() = runBlocking { + val result = fetch(cutoff = 0) { p -> + when (p) { + 0 -> page(Row("c", 300), Row("b", 200), Row("a", 200), total = 4) + else -> page(Row("a", 200), Row("z", 100), total = 4) + } + } + assertEquals(listOf("c", "b", "a", "z"), (result as RemoteResult.Ok).value.items.map { it.id }) + } + + @Test + fun `a book with no added date gives up`() = runBlocking { + val result = fetch(cutoff = 0) { page(Row("a", null), total = 1) } + assertTrue(result is RemoteResult.Failed) + } + + @Test + fun `too many pages of new books hands over to the full download`() = runBlocking { + val result = fetch(cutoff = 0, pageSize = 1, maxPages = 3) { p -> page(Row("b$p", 1000L - p), total = 50) } + assertTrue(result is RemoteResult.Failed) + } + + @Test + fun `a count that moves between pages gives up`() = runBlocking { + val result = fetch(cutoff = 0) { p -> + when (p) { + 0 -> page(Row("c", 300), Row("b", 200), Row("a", 150), total = 6) + else -> page(Row("z", 100), total = 7) + } + } + assertTrue(result is RemoteResult.Failed) + } + + @Test + fun `a failed page is a failed fetch, never a partial one`() = runBlocking { + val result = fetch(cutoff = 0) { p -> + if (p == 0) page(Row("c", 300), Row("b", 200), Row("a", 150), total = 6) else PageOutcome.Stopped("HTTP 500") + } + assertTrue(result is RemoteResult.Failed) + } + + @Test + fun `an empty library comes back empty and complete`() = runBlocking { + val result = fetch(cutoff = 0) { page(total = 0) } + val value = (result as RemoteResult.Ok).value + assertEquals(0, value.items.size) + assertEquals(0, value.total) + } + + @Test + fun `page step decisions`() { + assertEquals(IncrementalPageStep.Done, incrementalPageStep(emptyList(), null, 0, 3, 0, 5)) + assertEquals(IncrementalPageStep.Continue, incrementalPageStep(listOf(9L, 8L, 7L), null, 5, 3, 0, 5)) + assertEquals(IncrementalPageStep.Done, incrementalPageStep(listOf(9L, 8L, 4L), null, 5, 3, 0, 5)) + assertEquals(IncrementalPageStep.Done, incrementalPageStep(listOf(9L, 8L), null, 5, 3, 0, 5)) + assertTrue(incrementalPageStep(listOf(9L, 8L, 7L), null, 5, 3, 4, 5) is IncrementalPageStep.GiveUp) + assertTrue(incrementalPageStep(listOf(9L, 8L, 7L), 8L, 5, 3, 1, 5) is IncrementalPageStep.GiveUp) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/data/remote/PageRetryAndStreamingTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/remote/PageRetryAndStreamingTest.kt new file mode 100644 index 00000000..f844e56f --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/remote/PageRetryAndStreamingTest.kt @@ -0,0 +1,108 @@ +package com.ninelivesaudio.app.data.remote + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * A full download of a big library is a long run of pages against a slow + * home server. One bad page used to end it short with no second try, and + * every page sat in memory until the last one landed. + */ +class PageRetryAndStreamingTest { + + @Test + fun `a thrown network error is retried and the page still arrives`() = runBlocking { + var calls = 0 + val slept = mutableListOf() + val outcome = fetchPageWithRetry(delaysMs = listOf(10L, 20L), sleep = { slept += it }) { + calls++ + if (calls < 3) throw IOException("timeout") else PageOutcome.Page(listOf("a"), 1) + } + assertEquals(3, calls) + assertEquals(listOf(10L, 20L), slept) + assertTrue(outcome is PageOutcome.Page) + } + + @Test + fun `a retryable stop is retried, then returned as is when tries run out`() = runBlocking { + var calls = 0 + val outcome = fetchPageWithRetry(delaysMs = listOf(1L, 1L), sleep = {}) { + calls++ + PageOutcome.Stopped("HTTP 503", retryable = true) + } + assertEquals(3, calls) + assertEquals("HTTP 503", (outcome as PageOutcome.Stopped).reason) + } + + @Test + fun `a 401 is not retried`() = runBlocking { + var calls = 0 + fetchPageWithRetry(delaysMs = listOf(1L, 1L), sleep = {}) { + calls++ + PageOutcome.Stopped("HTTP 401", retryable = isRetryableHttpStatus(401)) + } + assertEquals(1, calls) + } + + @Test(expected = IOException::class) + fun `the last thrown error escapes for the paging loop to report`() { + runBlocking { + fetchPageWithRetry(delaysMs = listOf(1L), sleep = {}) { throw IOException("down") } + } + } + + @Test + fun `retryable statuses are server errors, timeouts and rate limits`() { + assertTrue(isRetryableHttpStatus(500)) + assertTrue(isRetryableHttpStatus(503)) + assertTrue(isRetryableHttpStatus(408)) + assertTrue(isRetryableHttpStatus(429)) + assertTrue(!isRetryableHttpStatus(401)) + assertTrue(!isRetryableHttpStatus(404)) + } + + @Test + fun `streaming hands each page over and reports the distinct count`() = runBlocking { + val delivered = mutableListOf>() + val result = runPaginatedFetchStreaming( + limit = 2, + maxPages = 10, + itemKey = { it }, + onPage = { delivered += it }, + ) { page -> + when (page) { + 0 -> PageOutcome.Page(listOf("a", "b"), 3) + else -> PageOutcome.Page(listOf("b", "c"), 3) + } + } + assertEquals(listOf(listOf("a", "b"), listOf("c")), delivered) + assertEquals(RemoteResult.Ok(3), result) + } + + @Test + fun `a page that fails after others were handed over is partial by count`() = runBlocking { + val delivered = mutableListOf() + val result = runPaginatedFetchStreaming( + limit = 2, + maxPages = 10, + onPage = { delivered += it }, + ) { page -> + if (page == 0) PageOutcome.Page(listOf("a", "b"), 5) else PageOutcome.Stopped("HTTP 500") + } + assertEquals(listOf("a", "b"), delivered) + assertEquals(RemoteResult.Partial(2, "HTTP 500"), result) + } + + @Test + fun `a save that throws stops the fetch short like a failed page`() = runBlocking { + val result = runPaginatedFetchStreaming( + limit = 2, + maxPages = 10, + onPage = { throw IllegalStateException("disk full") }, + ) { PageOutcome.Page(listOf("a", "b"), 4) } + assertTrue(result is RemoteResult.Failed) + } +} From b8f9e527bcfb6116233a36637451a4158bc6dc2e Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 22:52:34 -0400 Subject: [PATCH 15/69] The Library checks for changes in the foreground instead of downloading every launch A user with thousands of books on a slow home server waited minutes on every launch, and sign-in plus opening the Library downloaded the whole library twice back to back. Syncing now works like this. The timer runs only while the app is in the foreground. Every foreground entry checks at once unless a sync ran in the last 2 minutes, and the check repeats every 15 minutes. Nothing polls in the background. Background playback still pushes its own progress and the offline queue still flushes on reconnect. A check pulls progress, flushes the offline queue, and asks each book library one tiny question: how many books, and which was added last. If that matches the last good sync, nothing is fetched or written. If books were added, only those are fetched, newest first, down to 5 minutes before the stored newest book, and saved the way a full sync saves them. The server's count must then equal the old count plus the new books, or something was also removed and the full download runs, prunes and all. Any doubt, an error, a first sync, or a cache missing rows falls back to the full download too, and so does a full download more than a day old, because the server cannot be asked for edited books. Where each library stands (newest book, count, last full download) is kept per server, account, and library in the settings document, written only after its books are saved, so a cold start checks instead of downloading. A full download a check finds necessary waits for an unmetered network when a shelf is cached. Pull to refresh, Retry, Sync Now, sign-in, and a switch back from Local mode always run in full, right away. Background full downloads that fail back off from 15 minutes up to 4 hours, and a server coming back after a failure now runs a check under the same debounce instead of restarting the whole download on every flap. One sync per library runs at a time and everyone who asks while it runs shares it, including a check that turns into a full download. It runs outside the caller, so leaving the Library no longer throws away a download. Each library has its own lock, so a small library no longer waits behind a big one, and an older response still can never land after a newer prune. Full downloads save each page as it lands, so a first load fills the shelf progressively, and the merge reads only the columns it keeps instead of every cached row whole. Podcast libraries, which the app cannot play, are skipped by background syncs. Co-Authored-By: Claude Opus 5.5 --- .../LibraryLocalCatalogLiveUpdateTest.kt | 2 + .../library/LibraryViewModelFilterRaceTest.kt | 2 + .../com/ninelivesaudio/app/NineLivesApp.kt | 16 + .../app/data/local/dao/AudioBookDao.kt | 16 + .../app/data/local/entity/SyncRowStates.kt | 20 + .../app/data/remote/ApiService.kt | 32 -- .../data/repository/AudioBookRepository.kt | 405 +++++++++++++--- .../app/data/repository/LibrarySyncPlanner.kt | 440 ++++++++++++++++++ .../repository/LibrarySyncWatermarkStore.kt | 54 +++ .../app/domain/model/AppSettings.kt | 27 ++ .../app/service/ConnectivityMonitor.kt | 26 ++ .../app/service/ForegroundSyncPolicy.kt | 83 ++++ .../app/service/MediaBrowseTree.kt | 12 +- .../app/service/PlaybackService.kt | 4 +- .../ninelivesaudio/app/service/SyncManager.kt | 307 ++++++------ .../ninelivesaudio/app/service/SyncReport.kt | 28 +- .../app/ui/home/HomeViewModel.kt | 4 +- .../app/ui/library/LibraryViewModel.kt | 129 ++++- .../app/ui/settings/SettingsViewModel.kt | 5 + .../LibraryItemFetchSerializationTest.kt | 17 +- .../data/repository/LibrarySyncPlannerTest.kt | 207 ++++++++ .../repository/LibrarySyncSingleFlightTest.kt | 259 +++++++++++ .../app/data/repository/SyncMergeTest.kt | 37 ++ .../app/service/ForegroundSyncPolicyTest.kt | 98 ++++ .../app/service/PeriodicItemSyncTest.kt | 49 -- 25 files changed, 1954 insertions(+), 325 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/data/local/entity/SyncRowStates.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncWatermarkStore.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/service/ForegroundSyncPolicy.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncSingleFlightTest.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/ForegroundSyncPolicyTest.kt delete mode 100644 app/src/test/java/com/ninelivesaudio/app/service/PeriodicItemSyncTest.kt diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt index 25d2372a..f0a801b3 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt @@ -11,6 +11,7 @@ import com.ninelivesaudio.app.data.local.AppDatabase import com.ninelivesaudio.app.data.local.entity.AudioBookEntity import com.ninelivesaudio.app.data.local.entity.LibraryEntity import com.ninelivesaudio.app.data.repository.AudioBookRepository +import com.ninelivesaudio.app.data.repository.LibrarySyncWatermarkStore import com.ninelivesaudio.app.data.repository.LibraryRepository import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.service.local.LocalFolderAccess @@ -154,6 +155,7 @@ class LibraryLocalCatalogLiveUpdateTest { localBookmarkDao = database.localBookmarkDao(), playbackProgressDao = database.playbackProgressDao(), database = database, + watermarkStore = LibrarySyncWatermarkStore(app.settingsManager), ) val libraryRepository = LibraryRepository( libraryDao = database.libraryDao(), diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt index 70243590..43e3c31a 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt @@ -15,6 +15,7 @@ import com.ninelivesaudio.app.data.local.entity.LibraryEntity import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry import com.ninelivesaudio.app.data.local.entity.RecentlyPlayedResult import com.ninelivesaudio.app.data.repository.AudioBookRepository +import com.ninelivesaudio.app.data.repository.LibrarySyncWatermarkStore import com.ninelivesaudio.app.data.repository.LibraryRepository import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.Library @@ -125,6 +126,7 @@ private class LibraryViewModelFixture(app: NineLivesApp, database: AppDatabase) localBookmarkDao = emptyDao(), playbackProgressDao = emptyDao(), database = database, + watermarkStore = LibrarySyncWatermarkStore(app.settingsManager), ) private val libraryRepository = LibraryRepository( libraryDao = localLibraryDao(), diff --git a/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt b/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt index cbd14418..7f582891 100644 --- a/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt +++ b/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt @@ -2,6 +2,9 @@ package com.ninelivesaudio.app import android.app.Application import android.content.Context +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner import coil.ImageLoader import coil.ImageLoaderFactory import coil.disk.DiskCache @@ -122,6 +125,19 @@ class NineLivesApp : Application(), ImageLoaderFactory { // screen happened to ask for it. billingManager.start() + // The library check timer runs only while the app is visible. The + // process lifecycle covers every activity together, and its stop + // waits a moment past the last activity so a rotation is not a trip + // to the background. Registered here, before settings load, so the + // first foreground entry is never missed: the flag is state, and the + // timer reads it whenever SyncManager.start() gets to it. + ProcessLifecycleOwner.get().lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) = syncManager.setAppForeground(true) + override fun onStop(owner: LifecycleOwner) = syncManager.setAppForeground(false) + } + ) + // Load settings from disk FIRST, then bring up everything that depends // on them. Order matters: the server URL and auth token must be in // place before any network call fires. Otherwise the first request 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 895554ee..0af48ded 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 @@ -7,6 +7,7 @@ import androidx.room.Query import androidx.room.RawQuery import androidx.sqlite.db.SupportSQLiteQuery import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.data.local.entity.SyncMergeState import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.data.local.entity.RecentlyPlayedResult @@ -43,6 +44,21 @@ interface AudioBookDao { @Query("SELECT * FROM AudioBooks WHERE Id IN (:ids)") suspend fun getByIds(ids: List): List + /** Only the local-only fields a server sync keeps, for up to 500 IDs at a time. */ + @Query( + "SELECT Id, IsDownloaded, LocalPath, LocalCoverPath, CurrentTimeSeconds, Progress, IsFinished, ArchivedAt " + + "FROM AudioBooks WHERE Id IN (:ids)" + ) + suspend fun getSyncMergeStates(ids: List): List + + /** How many SERVER books one library has cached, downloads included. */ + @Query("SELECT COUNT(*) FROM AudioBooks WHERE LibraryId = :libraryId AND IsLocal = 0") + suspend fun countServerBooksByLibrary(libraryId: String): Int + + /** Which of up to 500 IDs are already cached as SERVER books of this library. */ + @Query("SELECT Id FROM AudioBooks WHERE LibraryId = :libraryId AND IsLocal = 0 AND Id IN (:ids)") + suspend fun getServerIdsInLibrary(libraryId: String, ids: List): List + /** Every stored localPath except [excludeId]'s, for the shared-folder check on cancel. */ @Query("SELECT LocalPath FROM AudioBooks WHERE Id != :excludeId AND LocalPath IS NOT NULL AND LocalPath != ''") suspend fun getLocalPathsExcept(excludeId: String): List diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/SyncRowStates.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/SyncRowStates.kt new file mode 100644 index 00000000..980b2f77 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/SyncRowStates.kt @@ -0,0 +1,20 @@ +package com.ninelivesaudio.app.data.local.entity + +import androidx.room.ColumnInfo + +/** + * The local-only fields a server sync must keep on a cached book: download + * state, on-disk paths, local progress, and the archive flag. Read instead of + * the whole row so a big library's sync does not load every book's audio + * file and chapter lists into memory just to keep a handful of columns. + */ +data class SyncMergeState( + @ColumnInfo(name = "Id") val id: String, + @ColumnInfo(name = "IsDownloaded") val isDownloaded: Int = 0, + @ColumnInfo(name = "LocalPath") val localPath: String? = null, + @ColumnInfo(name = "LocalCoverPath") val localCoverPath: String? = null, + @ColumnInfo(name = "CurrentTimeSeconds") val currentTimeSeconds: Double = 0.0, + @ColumnInfo(name = "Progress") val progress: Double = 0.0, + @ColumnInfo(name = "IsFinished") val isFinished: Int = 0, + @ColumnInfo(name = "ArchivedAt") val archivedAt: Long? = null, +) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt index 10e824cb..91c211bc 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt @@ -675,38 +675,6 @@ class ApiService @Inject constructor( // ─── Library Items (Paginated batch load) ──────────────────────────── - /** - * Every item in a library, paginated. A page that fails part-way through - * yields [RemoteResult.Partial]: the books already fetched are still worth - * showing, but the caller has to know the shelf stopped short rather than - * ended. The same is true when a page merely comes back empty or shorter - * than [limit] while the server's own reported total says more exist — - * [runPaginatedFetch] is what decides that (issue #14, PR #30 review, - * finding B); only actually reaching the reported total is a genuine Ok. - */ - suspend fun getLibraryItems(libraryId: String, limit: Int = 100): RemoteResult> = - withContext(Dispatchers.IO) { - runPaginatedFetch( - limit = limit, - maxPages = LIBRARY_ITEMS_PAGE_CAP, - onPageFailure = { page, e -> Log.w(TAG, "getLibraryItems($libraryId) failed at page $page", e) }, - ) { page -> - val response = api.getLibraryItems(libraryId, limit, page) - if (!response.isSuccessful) { - Log.w(TAG, "getLibraryItems($libraryId): HTTP ${response.code()} at page $page") - return@runPaginatedFetch PageOutcome.Stopped("page $page: HTTP ${response.code()}") - } - - val body = response.body() - if (body == null) { - Log.w(TAG, "getLibraryItems($libraryId): no body at page $page") - return@runPaginatedFetch PageOutcome.Stopped("page $page: empty body") - } - - PageOutcome.Page(body.results.map { mapToAudioBook(it, libraryId) }, body.total) - } - } - /** * Every item in a library, paginated, handed to [onPage] a page at a time * so the caller can save each page as it lands instead of holding the 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 90c472ce..76452806 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 @@ -12,6 +12,7 @@ import com.ninelivesaudio.app.data.local.dao.LocalListeningSessionDao import com.ninelivesaudio.app.data.local.dao.PlaybackProgressDao import com.ninelivesaudio.app.data.local.entity.AudioBookEntity import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry +import com.ninelivesaudio.app.data.local.entity.SyncMergeState import com.ninelivesaudio.app.data.remote.ApiService import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.domain.model.AudioBook @@ -20,14 +21,24 @@ import com.ninelivesaudio.app.service.local.LocalBookFingerprint import com.ninelivesaudio.app.service.local.folderNameOfTrackUri import com.ninelivesaudio.app.service.local.matchMovedLocalBooks import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow +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.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.seconds @@ -41,8 +52,32 @@ class AudioBookRepository @Inject constructor( private val localBookmarkDao: LocalBookmarkDao, private val playbackProgressDao: PlaybackProgressDao, private val database: AppDatabase, + private val watermarkStore: LibrarySyncWatermarkStore, ) { - private val syncLibraryItemsMutex = Mutex() + // Library syncs run here, not in whoever asked, so a caller that leaves + // (the Library tab closing, a newer load replacing an older one) stops + // waiting without throwing away a download someone else is sharing. + private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val syncFlights = LibrarySyncSingleFlight(syncScope) + + // One lock per library. Fetch, save, and prune for a library happen under + // its lock, so an older response can never land after a newer complete + // one pruned. Other libraries no longer wait behind a big one's download. + private val libraryLocks = ConcurrentHashMap() + private fun lockFor(libraryId: String): Mutex = libraryLocks.computeIfAbsent(libraryId) { Mutex() } + + // Background full downloads that keep failing wait before trying again. + private val fullSyncFailures = ConcurrentHashMap() + + private val _booksSaved = MutableSharedFlow(extraBufferCapacity = 16) + + /** A library id, each time a sync saves books for it (every page of a full download). */ + val booksSaved: SharedFlow = _booksSaved.asSharedFlow() + + private val _deferredFullSyncs = MutableStateFlow>(emptySet()) + + /** Libraries whose full download is waiting for an unmetered network. */ + val deferredFullSyncs: StateFlow> = _deferredFullSyncs.asStateFlow() /** Observe all audiobooks (reactive). */ fun observeAll(): Flow> = @@ -212,38 +247,186 @@ class AudioBookRepository @Inject constructor( } /** - * Fetch all items for a library from server and save to local DB. A - * partial fetch still saves what it got: some of the shelf beats none of - * it, and the caller keeps the failure reason for the bug report. + * Download every item in a library and save it, a page at a time. This is + * the full sync: sign-in, a server or account switch, a switch back from + * Local mode, an explicit refresh (pull to refresh, Retry, Sync Now), and + * the fallback whenever a change check cannot be trusted. It never waits + * for an unmetered network or a failure backoff. * - * A complete (Ok) fetch, by contrast, is authoritative for this library - * — including a confirmed-empty one, so the cache is reconciled to match - * it exactly rather than just upserted-into. See [reconcileServerLibrary]. + * A partial fetch still saves what it got: some of the shelf beats none + * of it, and the caller keeps the failure reason for the bug report. A + * complete (Ok) fetch is authoritative for this library, including a + * confirmed-empty one, so the cache is pruned to match it exactly. See + * [pruneServerLibraryTo]. A sync already running for this library is + * shared, not repeated (see [LibrarySyncSingleFlight]). + * + * Returns how many books the fetch saved. */ - suspend fun syncLibraryItems(libraryId: String): RemoteResult> = runSerializedLibraryItemSync( - mutex = syncLibraryItemsMutex, - libraryId = libraryId, - fetchItems = { apiService.getLibraryItems(libraryId) }, - // Thousands of books are merged and JSON-encoded here, so keep it off - // the main thread the Library calls from. - mergeItems = { remote -> - withContext(Dispatchers.Default) { mergeSyncedBooks(remote, audioBookDao::getByIds) } - }, - upsertAll = { books -> - audioBookDao.upsertAll(withContext(Dispatchers.Default) { books.map { it.toEntity() } }) - }, - cachedNonDownloadedIds = audioBookDao::getNonDownloadedServerIdsByLibrary, - deleteByIds = audioBookDao::deleteServerBooksByIds, - deleteAllServerBooks = { id -> audioBookDao.deleteServerBooksByLibrary(id) }, - ) + suspend fun syncLibraryItems(libraryId: String): RemoteResult = + syncFlights.run(libraryId, LibrarySyncKind.FULL) { + lockFor(libraryId).withLock { runFullSyncLocked(libraryId) } + }.itemCountResult() ?: RemoteResult.Failed("sync did not run") + + /** + * The background check for one library: ask the server whether anything + * was added or removed since the last good sync (one tiny request), and + * do the least work that brings the shelf back in line. Nothing new means + * nothing fetched and nothing written. Added books are fetched on their + * own. Removals, doubt, a first sync, or a day-old last full download + * fall back to the full download, which waits for an unmetered network + * when [isMetered] and a cached shelf exists. See [planLibrarySync]. + */ + internal suspend fun refreshLibraryItemsIfChanged(libraryId: String, isMetered: Boolean): LibraryRefreshOutcome = + syncFlights.run(libraryId, LibrarySyncKind.CHECK) { + lockFor(libraryId).withLock { runCheckLocked(libraryId, isMetered) } + } + + private suspend fun runCheckLocked(libraryId: String, isMetered: Boolean): LibraryRefreshOutcome { + val key = watermarkStore.currentAccount() + val watermark = watermarkStore.get(key, libraryId) + val localCount = audioBookDao.countServerBooksByLibrary(libraryId) + val hasCachedRows = localCount > 0 + val change = if (watermark == null) { + LibraryChange.Full("no earlier sync of this library") + } else { + when (val head = apiService.getLibraryHead(libraryId)) { + is RemoteResult.Ok -> decideLibraryChange(watermark, head.value, localCount) + is RemoteResult.Partial -> return LibraryRefreshOutcome.CheckFailed(head.reason) + is RemoteResult.Failed -> return LibraryRefreshOutcome.CheckFailed(head.reason) + } + } + val plan = planLibrarySync( + change = change, + isStale = watermark?.let { isWatermarkStale(it, System.currentTimeMillis()) } ?: false, + isMetered = isMetered, + hasCachedRows = hasCachedRows, + ) + val reason = (change as? LibraryChange.Full)?.reason ?: "last full download is over a day old" + return when (plan) { + LibrarySyncPlan.SKIP -> LibraryRefreshOutcome.Unchanged(watermark?.itemCount ?: localCount) + LibrarySyncPlan.DEFER_FULL -> deferUntilUnmetered(libraryId, reason) + LibrarySyncPlan.FULL -> runBackgroundFullLocked(libraryId, reason) + LibrarySyncPlan.INCREMENTAL -> + runIncrementalLocked(libraryId, key, watermark!!, isMetered, hasCachedRows) + } + } + + private suspend fun runIncrementalLocked( + libraryId: String, + key: SyncAccountKey, + watermark: com.ninelivesaudio.app.domain.model.LibrarySyncWatermark, + isMetered: Boolean, + hasCachedRows: Boolean, + ): LibraryRefreshOutcome { + val fetched = apiService.getLibraryItemsAddedSince( + libraryId = libraryId, + cutoff = incrementalCutoff(watermark), + pageSize = INCREMENTAL_PAGE_SIZE, + maxPages = INCREMENTAL_MAX_PAGES, + ) + val value = (fetched as? RemoteResult.Ok)?.value + ?: return fallBackToFullLocked(libraryId, (fetched as? RemoteResult.Failed)?.reason ?: "added-since fetch failed", isMetered, hasCachedRows) + val books = value.items + val alreadyCached = fetchByIdChunks(books.map { it.id }) { ids -> + audioBookDao.getServerIdsInLibrary(libraryId, ids) + }.toHashSet() + val newBookCount = books.count { it.id !in alreadyCached } + if (books.isNotEmpty()) { + val merged = mergeForSave(books) + // Added books are saved even when the counts disagree below: they + // are real, and the full download that follows prunes, not this. + withContext(NonCancellable) { saveMerged(merged) } + _booksSaved.tryEmit(libraryId) + } + if (!incrementalCountsAgree(watermark.itemCount, newBookCount, value.total)) { + return fallBackToFullLocked( + libraryId, + "server has ${value.total} books, expected ${watermark.itemCount} + $newBookCount new", + isMetered, + hasCachedRows, + ) + } + watermarkStore.put(watermarkAfterIncremental(watermark, books, value.total, System.currentTimeMillis())) + return LibraryRefreshOutcome.Incremental(newBookCount = newBookCount, itemCount = value.total) + } + + private suspend fun fallBackToFullLocked( + libraryId: String, + reason: String, + isMetered: Boolean, + hasCachedRows: Boolean, + ): LibraryRefreshOutcome = + if (shouldDeferFullSync(isMetered, explicit = false, hasCachedRows)) { + deferUntilUnmetered(libraryId, reason) + } else { + runBackgroundFullLocked(libraryId, reason) + } + + private suspend fun runBackgroundFullLocked(libraryId: String, reason: String): LibraryRefreshOutcome { + if (isFullSyncBackedOff(fullSyncFailures[libraryId], monotonicNowMs())) { + return LibraryRefreshOutcome.Deferred("$reason (waiting after failed downloads)", untilUnmetered = false) + } + return runFullSyncLocked(libraryId) + } + + private fun deferUntilUnmetered(libraryId: String, reason: String): LibraryRefreshOutcome { + _deferredFullSyncs.update { it + libraryId } + return LibraryRefreshOutcome.Deferred(reason, untilUnmetered = true) + } + + private suspend fun runFullSyncLocked(libraryId: String): LibraryRefreshOutcome { + // A check that got here is now a full download: let a full refresh + // asked for meanwhile share it rather than queue a second one. + syncFlights.upgradeToFull(libraryId) + val key = watermarkStore.currentAccount() + val tally = FullSyncTally() + val result = runLibraryItemSyncPass( + libraryId = libraryId, + fetchPages = { onPage -> apiService.streamLibraryItems(libraryId, onPage = onPage) }, + mergeItems = ::mergeForSave, + upsertAll = ::saveMerged, + cachedNonDownloadedIds = audioBookDao::getNonDownloadedServerIdsByLibrary, + deleteByIds = audioBookDao::deleteServerBooksByIds, + deleteAllServerBooks = { id -> audioBookDao.deleteServerBooksByLibrary(id) }, + onPageSaved = { books -> + tally.add(books) + _booksSaved.tryEmit(libraryId) + }, + ) + if (result is RemoteResult.Ok) { + fullSyncFailures.remove(libraryId) + _deferredFullSyncs.update { it - libraryId } + val watermark = watermarkAfterFullSync(key, libraryId, tally, System.currentTimeMillis()) + if (watermark != null) watermarkStore.put(watermark) else watermarkStore.remove(key, libraryId) + } else { + fullSyncFailures.compute(libraryId) { _, previous -> + FullSyncFailures(count = (previous?.count ?: 0) + 1, lastFailureAtMs = monotonicNowMs()) + } + } + return LibraryRefreshOutcome.Full(result) + } + + // Thousands of books are merged and JSON-encoded here, so keep it off the + // main thread the Library calls from. + private suspend fun mergeForSave(remote: List): List = + withContext(Dispatchers.Default) { + mergeSyncedBooksLean(remote, audioBookDao::getSyncMergeStates, audioBookDao::getByIds) + } + + private suspend fun saveMerged(books: List) { + audioBookDao.upsertAll(withContext(Dispatchers.Default) { books.map { it.toEntity() } }) + } + + private fun monotonicNowMs(): Long = System.nanoTime() / 1_000_000L /** - * Applies an authoritative library-list removal through the same mutex as - * item sync. A list response cannot prune this library while an older item - * response is still in flight and about to upsert its stale books. + * Applies an authoritative library-list removal under the same lock as + * that library's item sync. A list response cannot prune this library + * while an older item response is still in flight and about to upsert its + * stale books. */ internal suspend fun pruneServerBooksForRemovedLibrary(libraryId: String): Boolean = - runSerializedLibraryItemPrune(syncLibraryItemsMutex) { + runSerializedLibraryItemPrune(lockFor(libraryId)) { audioBookDao.deleteServerBooksByLibrary(libraryId) audioBookDao.hasDownloadedServerBooks(libraryId) } @@ -481,16 +664,15 @@ internal suspend fun mergeSyncedBooks( /** * Reconciles the DAO's cached SERVER rows for one library against a - * completed [syncLibraryItems] fetch, extracted so the branching is - * unit-testable without Room (issue #14, PR #30 review). + * completed fetch, extracted so the branching is unit-testable without Room + * (issue #14, PR #30 review). * - * A complete ([isComplete]) fetch is authoritative for [libraryId] — the + * A complete ([isComplete]) fetch is authoritative for [libraryId]: the * cache should end up matching it exactly, including down to nothing when * [merged] is empty (a previously populated library that genuinely emptied * out). An incomplete (Partial) fetch is never authoritative: it upserts * what it got (some of the shelf beats none of it) but never prunes, - * because a page it couldn't reach is not proof those books are gone — - * that retention is unchanged from before this fix. + * because a page it couldn't reach is not proof those books are gone. * * [cachedNonDownloadedIds], [deleteByIds], and [deleteAllServerBooks] are * expected to scope to server (non-local) rows and exempt downloaded books. @@ -510,10 +692,31 @@ internal suspend fun reconcileServerLibrary( upsertAll(merged) } if (!isComplete) return - if (merged.isEmpty()) { + pruneServerLibraryTo( + keptIds = merged.mapTo(mutableSetOf()) { it.id }, + libraryId = libraryId, + cachedNonDownloadedIds = cachedNonDownloadedIds, + deleteByIds = deleteByIds, + deleteAllServerBooks = deleteAllServerBooks, + ) +} + +/** + * The prune half of [reconcileServerLibrary], for a complete fetch whose + * books were already saved page by page: every cached non-downloaded server + * book of [libraryId] not in [keptIds] goes, and an empty [keptIds] empties + * the library. Only ever called after a complete fetch. + */ +internal suspend fun pruneServerLibraryTo( + keptIds: Set, + libraryId: String, + cachedNonDownloadedIds: suspend (libraryId: String) -> List, + deleteByIds: suspend (libraryId: String, ids: List) -> Unit, + deleteAllServerBooks: suspend (libraryId: String) -> Unit, +) { + if (keptIds.isEmpty()) { deleteAllServerBooks(libraryId) } else { - val keptIds = merged.mapTo(mutableSetOf()) { it.id } val missingIds = cachedNonDownloadedIds(libraryId).filterNot { it in keptIds } for (ids in missingIds.chunked(MAXIMUM_SERVER_BOOK_DELETE_BIND_COUNT)) { deleteByIds(libraryId, ids) @@ -522,46 +725,75 @@ internal suspend fun reconcileServerLibrary( } /** - * Serializes one item's full fetch, merge, and reconciliation sequence. Both - * SyncManager and LibraryViewModel can refresh the same library independently. - * Holding [mutex] across the network fetch makes a later caller fetch only - * after every earlier response has been applied, so an old response cannot - * upsert books that a newer complete response already pruned. + * One full fetch-save-prune pass for a library, the caller holding its lock. + * + * Each page is merged and saved as it arrives ([fetchPages] hands pages to + * the callback), so a big library fills the shelf progressively and never + * sits in memory whole. Saves run NonCancellable so a page is saved entirely + * or not at all. Only a complete (Ok) fetch prunes, against every id it + * delivered, and the prune also runs NonCancellable: once the fetch has + * proved what the server holds, the cache is brought in line even if the + * caller is cancelled in between. A Partial or Failed fetch keeps the pages + * it saved and prunes nothing. */ -internal suspend fun runSerializedLibraryItemSync( - mutex: Mutex, +internal suspend fun runLibraryItemSyncPass( libraryId: String, - fetchItems: suspend () -> RemoteResult>, + fetchPages: suspend (onPage: suspend (List) -> Unit) -> RemoteResult, mergeItems: suspend (List) -> List, upsertAll: suspend (List) -> Unit, cachedNonDownloadedIds: suspend (libraryId: String) -> List, deleteByIds: suspend (libraryId: String, ids: List) -> Unit, deleteAllServerBooks: suspend (libraryId: String) -> Unit, -): RemoteResult> = mutex.withLock { - val result = fetchItems() - val remote = when (result) { - is RemoteResult.Ok -> result.value - is RemoteResult.Partial -> result.value - is RemoteResult.Failed -> return@withLock result + onPageSaved: suspend (List) -> Unit = {}, +): RemoteResult { + val keptIds = HashSet() + val result = fetchPages { page -> + val merged = mergeItems(page) + withContext(NonCancellable) { + if (merged.isNotEmpty()) upsertAll(merged) + } + merged.mapTo(keptIds) { it.id } + onPageSaved(merged) } - val merged = mergeItems(remote) - - withContext(NonCancellable) { - reconcileServerLibrary( - isComplete = result is RemoteResult.Ok, - merged = merged, - libraryId = libraryId, - upsertAll = upsertAll, - cachedNonDownloadedIds = cachedNonDownloadedIds, - deleteByIds = deleteByIds, - deleteAllServerBooks = deleteAllServerBooks, - ) + if (result is RemoteResult.Ok) { + withContext(NonCancellable) { + pruneServerLibraryTo( + keptIds = keptIds, + libraryId = libraryId, + cachedNonDownloadedIds = cachedNonDownloadedIds, + deleteByIds = deleteByIds, + deleteAllServerBooks = deleteAllServerBooks, + ) + } } + return result +} - when (result) { - is RemoteResult.Partial -> RemoteResult.Partial(merged, result.reason) - else -> RemoteResult.Ok(merged) - } +/** + * [runLibraryItemSyncPass] under [mutex]. Holding the lock across the whole + * fetch makes a later caller fetch only after every earlier response has + * been applied, so an old response cannot upsert books that a newer + * complete response already pruned. + */ +internal suspend fun runSerializedLibraryItemSync( + mutex: Mutex, + libraryId: String, + fetchPages: suspend (onPage: suspend (List) -> Unit) -> RemoteResult, + mergeItems: suspend (List) -> List, + upsertAll: suspend (List) -> Unit, + cachedNonDownloadedIds: suspend (libraryId: String) -> List, + deleteByIds: suspend (libraryId: String, ids: List) -> Unit, + deleteAllServerBooks: suspend (libraryId: String) -> Unit, +): RemoteResult = mutex.withLock { + runLibraryItemSyncPass( + libraryId = libraryId, + fetchPages = fetchPages, + mergeItems = mergeItems, + upsertAll = upsertAll, + cachedNonDownloadedIds = cachedNonDownloadedIds, + deleteByIds = deleteByIds, + deleteAllServerBooks = deleteAllServerBooks, + ) } /** Runs an authoritative item prune in the same ordering domain as item sync. */ @@ -570,13 +802,52 @@ internal suspend fun runSerializedLibraryItemPrune( prune: suspend () -> T, ): T = mutex.withLock { prune() } +/** + * [mergeSyncedBooks] reading only the columns the merge keeps. The whole row + * (audio file and chapter lists) is read only for a downloaded book whose + * server copy came back without tracks or chapters, the one case the merge + * borrows them from the local row. + */ +internal suspend fun mergeSyncedBooksLean( + remote: List, + getMergeStates: suspend (List) -> List, + getFullRows: suspend (List) -> List, +): List { + if (remote.isEmpty()) return emptyList() + val states = fetchByIdChunks(remote.map { it.id }, getMergeStates).associateBy { it.id } + val needDetail = remote.filter { book -> + states[book.id]?.isDownloaded == 1 && (book.audioFiles.isEmpty() || book.chapters.isEmpty()) + }.map { it.id } + val details = if (needDetail.isEmpty()) emptyMap() else fetchByIdChunks(needDetail, getFullRows).associateBy { it.id } + return remote.map { book -> mergeSyncedBook(book, states[book.id], details[book.id]) } +} + +internal fun AudioBookEntity.toSyncMergeState(): SyncMergeState = SyncMergeState( + id = id, + isDownloaded = isDownloaded, + localPath = localPath, + localCoverPath = localCoverPath, + currentTimeSeconds = currentTimeSeconds, + progress = progress, + isFinished = isFinished, + archivedAt = archivedAt, +) + /** * Merge a server book with its local row during sync, preserving local-only * state the server does not know about: the download flag, the on-disk audio * path, the cover persisted at download time, and any local playback progress * that is ahead of the server. Pure, so it is unit-testable without the DB. */ -internal fun mergeSyncedBook(remote: AudioBook, local: AudioBookEntity?): AudioBook { +internal fun mergeSyncedBook(remote: AudioBook, local: AudioBookEntity?): AudioBook = + mergeSyncedBook(remote, local?.toSyncMergeState(), local) + +/** + * [mergeSyncedBook] from the kept columns alone. [detail] is the whole local + * row, needed only to borrow tracks or chapters for a downloaded book whose + * server copy has none. Without it those stay as the server sent them. + */ +internal fun mergeSyncedBook(remote: AudioBook, local: SyncMergeState?, detail: AudioBookEntity?): AudioBook { if (local == null) return remote val withDownload = if (local.isDownloaded == 1) { @@ -584,8 +855,8 @@ internal fun mergeSyncedBook(remote: AudioBook, local: AudioBookEntity?): AudioB isDownloaded = true, localPath = local.localPath, localCoverPath = local.localCoverPath, - audioFiles = remote.audioFiles.ifEmpty { local.toDomain().audioFiles }, - chapters = remote.chapters.ifEmpty { local.toDomain().chapters }, + audioFiles = remote.audioFiles.ifEmpty { detail?.toDomain()?.audioFiles.orEmpty() }, + chapters = remote.chapters.ifEmpty { detail?.toDomain()?.chapters.orEmpty() }, ) } else remote diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt new file mode 100644 index 00000000..56354edf --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt @@ -0,0 +1,440 @@ +package com.ninelivesaudio.app.data.repository + +import com.ninelivesaudio.app.data.remote.LibraryHead +import com.ninelivesaudio.app.data.remote.RemoteResult +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.LibrarySyncWatermark +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.selects.select + +// The pure decisions behind the library change check and the "added since" +// sync. Nothing here touches Room, Retrofit, or the clock, so every rule is +// pinned by unit tests (LibrarySyncPlannerTest, LibrarySyncSingleFlightTest). + +/** How old the last full download may get before a check refreshes everything. */ +internal const val FULL_REFRESH_MAX_AGE_MS = 24 * 60 * 60_000L + +/** + * How far before the stored newest book an "added since" fetch reaches back. + * Books that land in the same moment, or a server clock that ticks coarsely, + * can put a book we never saw just under the newest one we did. Re-fetching + * that sliver is cheap and idempotent. + */ +internal const val INCREMENTAL_OVERLAP_MS = 5 * 60_000L + +/** + * Page size for the "added since" fetch. Smaller than the full download's 100 + * because a check that finds changes usually finds a handful of new books, + * and each full-detail book is a few kilobytes on what may be a cell network. + */ +internal const val INCREMENTAL_PAGE_SIZE = 25 + +/** Past this many pages (500 books) the full download is the simpler answer. */ +internal const val INCREMENTAL_MAX_PAGES = 20 + +/** How many libraries' watermarks are kept across every server and account. */ +internal const val MAX_STORED_WATERMARKS = 64 + +/** A server and the account signed in to it. Watermarks never cross accounts. */ +internal data class SyncAccountKey(val serverUrl: String, val username: String) + +internal fun List.watermarkFor( + key: SyncAccountKey, + libraryId: String, +): LibrarySyncWatermark? = firstOrNull { + it.serverUrl == key.serverUrl && it.username == key.username && it.libraryId == libraryId +} + +/** Replace this library's watermark, keeping only the most recently written [cap]. */ +internal fun List.withWatermark( + watermark: LibrarySyncWatermark, + cap: Int = MAX_STORED_WATERMARKS, +): List { + val others = filterNot { + it.serverUrl == watermark.serverUrl && it.username == watermark.username && it.libraryId == watermark.libraryId + } + return (others + watermark).sortedByDescending { it.updatedAtMs }.take(cap) +} + +internal fun List.withoutWatermark( + key: SyncAccountKey, + libraryId: String, +): List = filterNot { + it.serverUrl == key.serverUrl && it.username == key.username && it.libraryId == libraryId +} + +/** What the tiny head request says about a library since its last good sync. */ +internal sealed interface LibraryChange { + data object NoChange : LibraryChange + + /** Books were added and nothing suggests anything was removed. */ + data object Incremental : LibraryChange + + /** Only a full download can bring the shelf back in line. */ + data class Full(val reason: String) : LibraryChange +} + +/** + * Compares the server's head (book count and newest added book) with what + * the last good sync recorded. + * + * - Same newest book and same count: nothing was added or removed. + * - A newer newest book: books were added. The "added since" fetch then + * checks the count again to catch removals that happened alongside. + * - Anything else (fewer books, the newest one gone, a count change with + * the same newest book, missing fields) can only be explained by a + * removal or a server oddity, and only a full download answers that. + * + * [localServerRowCount] is how many server books for this library are in the + * cache. Fewer than the watermark recorded means the cache lost rows (a + * server switch wipes them, for one) and the watermark no longer describes it. + */ +internal fun decideLibraryChange( + watermark: LibrarySyncWatermark?, + head: LibraryHead, + localServerRowCount: Int, +): LibraryChange { + if (watermark == null) return LibraryChange.Full("no earlier sync of this library") + if (localServerRowCount < watermark.itemCount) { + return LibraryChange.Full("the saved shelf has $localServerRowCount of ${watermark.itemCount} books") + } + val newestTime = head.newestAddedAt + if (head.newestItemId == null || newestTime == null) { + return if (head.total == 0 && head.newestItemId == null) { + if (watermark.itemCount == 0) LibraryChange.NoChange else LibraryChange.Full("the library is now empty") + } else { + LibraryChange.Full("the newest book came back without an id or added date") + } + } + val storedNewest = watermark.newestAddedAt ?: return LibraryChange.Incremental + return when { + newestTime < storedNewest -> LibraryChange.Full("the newest book was removed") + newestTime == storedNewest && head.newestItemId == watermark.newestItemId -> + if (head.total == watermark.itemCount) { + LibraryChange.NoChange + } else { + LibraryChange.Full("book count moved from ${watermark.itemCount} to ${head.total} with no new book") + } + else -> LibraryChange.Incremental + } +} + +/** + * Whether the last full download is old enough that a check should refresh + * everything. This is the only way a metadata edit on the server (a title + * fix, a new series) reaches a shelf nobody pulls to refresh, because the + * server cannot be asked for edited books. A clock that moved backward also + * counts as stale, so a bad clock can never freeze the shelf. + */ +internal fun isWatermarkStale( + watermark: LibrarySyncWatermark, + nowMs: Long, + maxAgeMs: Long = FULL_REFRESH_MAX_AGE_MS, +): Boolean { + val age = nowMs - watermark.lastFullSyncAtMs + return age < 0 || age >= maxAgeMs +} + +/** + * A full download found necessary by a background check waits for an + * unmetered network. One the user asked for (pull to refresh, Retry, Sync + * Now, sign-in) never waits, and neither does one for a library with nothing + * cached, because then the shelf would sit empty. + */ +internal fun shouldDeferFullSync( + isMetered: Boolean, + explicit: Boolean, + hasCachedRows: Boolean, +): Boolean = isMetered && !explicit && hasCachedRows + +internal enum class LibrarySyncPlan { SKIP, INCREMENTAL, FULL, DEFER_FULL } + +/** + * What a background check does about one library. A full download that is + * due only because the last one is old ([isStale]) is not worth cell data, + * so on a metered network it steps down to whatever the change check says. + */ +internal fun planLibrarySync( + change: LibraryChange, + isStale: Boolean, + isMetered: Boolean, + hasCachedRows: Boolean, +): LibrarySyncPlan = when (change) { + is LibraryChange.Full -> + if (shouldDeferFullSync(isMetered, explicit = false, hasCachedRows)) LibrarySyncPlan.DEFER_FULL else LibrarySyncPlan.FULL + LibraryChange.Incremental -> + if (isStale && !isMetered) LibrarySyncPlan.FULL else LibrarySyncPlan.INCREMENTAL + LibraryChange.NoChange -> + if (isStale && !isMetered) LibrarySyncPlan.FULL else LibrarySyncPlan.SKIP +} + +/** The lower edge of an "added since" fetch. A library that was empty fetches everything. */ +internal fun incrementalCutoff(watermark: LibrarySyncWatermark): Long = + watermark.newestAddedAt?.let { it - INCREMENTAL_OVERLAP_MS } ?: Long.MIN_VALUE + +/** + * After applying the added books, the server's count must equal the old + * count plus the books that are new to the cache. Any gap means books were + * also removed (or moved away), which only a full download can prune. + */ +internal fun incrementalCountsAgree( + previousCount: Int, + newBookCount: Int, + serverTotal: Int, +): Boolean = previousCount + newBookCount == serverTotal + +/** + * Running totals of a full download, kept page by page so the books + * themselves never have to be held: how many distinct books, the newest by + * added date, and whether any book came without an added date. + */ +internal class FullSyncTally { + private val seen = HashSet() + var count: Int = 0 + private set + var newestAddedAt: Long? = null + private set + var newestItemId: String? = null + private set + var missingAddedAt: Boolean = false + private set + + fun add(books: List) { + for (book in books) { + if (!seen.add(book.id)) continue + count++ + val time = book.addedAt + if (time == null) { + missingAddedAt = true + } else if (newestAddedAt == null || time > newestAddedAt!!) { + newestAddedAt = time + newestItemId = book.id + } + } + } +} + +/** + * The watermark a complete full download leaves. Null when any book lacks an + * added date: without it the next check could not compare, so the library + * stays on full downloads instead of trusting a guess. + */ +internal fun watermarkAfterFullSync( + key: SyncAccountKey, + libraryId: String, + tally: FullSyncTally, + nowMs: Long, +): LibrarySyncWatermark? { + if (tally.missingAddedAt) return null + return LibrarySyncWatermark( + serverUrl = key.serverUrl, + username = key.username, + libraryId = libraryId, + newestAddedAt = tally.newestAddedAt, + newestItemId = tally.newestItemId, + itemCount = tally.count, + lastFullSyncAtMs = nowMs, + updatedAtMs = nowMs, + ) +} + +/** The first wait after a background full download fails or stops short. */ +internal const val FULL_SYNC_BACKOFF_BASE_MS = 15 * 60_000L + +/** The longest wait between background full download attempts. */ +internal const val FULL_SYNC_BACKOFF_MAX_MS = 4 * 60 * 60_000L + +/** How a library's background full downloads have been failing lately. */ +internal data class FullSyncFailures(val count: Int, val lastFailureAtMs: Long) + +/** 15 minutes after the first failure, doubling each time, capped at 4 hours. */ +internal fun fullSyncBackoffMs(failureCount: Int): Long { + if (failureCount <= 0) return 0L + val shift = (failureCount - 1).coerceAtMost(10) + return (FULL_SYNC_BACKOFF_BASE_MS shl shift).coerceAtMost(FULL_SYNC_BACKOFF_MAX_MS) +} + +/** + * Whether a background full download should wait because recent ones kept + * failing. A Partial run on a flaky server used to leave a non-success record, + * and every flap of the server back to reachable restarted the whole + * multi-minute download. Only background checks honour this. A refresh the + * user asked for always runs. [nowMs] is a monotonic clock, and a reading + * before the last failure counts as waited out. + */ +internal fun isFullSyncBackedOff(failures: FullSyncFailures?, nowMs: Long): Boolean { + if (failures == null || failures.count <= 0) return false + val waited = nowMs - failures.lastFailureAtMs + return waited in 0 until fullSyncBackoffMs(failures.count) +} + +/** + * The watermark after an "added since" fetch whose counts agreed. The newest + * book is the first one the server listed (it sorted newest first), and the + * last full download time carries over unchanged. + */ +internal fun watermarkAfterIncremental( + previous: LibrarySyncWatermark, + fetched: List, + serverTotal: Int, + nowMs: Long, +): LibrarySyncWatermark { + val first = fetched.firstOrNull() + val firstTime = first?.addedAt + val advance = first != null && firstTime != null && + (previous.newestAddedAt == null || firstTime >= previous.newestAddedAt) + return previous.copy( + newestAddedAt = if (advance) firstTime else previous.newestAddedAt, + newestItemId = if (advance) first!!.id else previous.newestItemId, + itemCount = serverTotal, + updatedAtMs = nowMs, + ) +} + +/** What one library's sync or check actually did. */ +internal sealed interface LibraryRefreshOutcome { + /** The check found nothing new. Nothing was fetched or written. */ + data class Unchanged(val itemCount: Int) : LibraryRefreshOutcome + + /** + * A full download is needed but waits: for an unmetered network when + * [untilUnmetered], otherwise for the failure backoff to run out. + */ + data class Deferred(val reason: String, val untilUnmetered: Boolean = true) : LibraryRefreshOutcome + + /** The head request itself failed, so nothing is known either way. */ + data class CheckFailed(val reason: String) : LibraryRefreshOutcome + + /** Added books were fetched and saved, and the counts agreed. */ + data class Incremental(val newBookCount: Int, val itemCount: Int) : LibraryRefreshOutcome + + /** The full download ran, as asked or as the fallback. [result] counts the books it saved. */ + data class Full(val result: RemoteResult) : LibraryRefreshOutcome +} + +/** + * The outcome as a book-count result for sync reports. Null for a deferral, + * which is no verdict at all: the shelf is known to be behind, so it must + * not be reported as current, and nothing failed either. + */ +internal fun LibraryRefreshOutcome.itemCountResult(): RemoteResult? = when (this) { + is LibraryRefreshOutcome.Unchanged -> RemoteResult.Ok(itemCount) + is LibraryRefreshOutcome.Deferred -> null + is LibraryRefreshOutcome.CheckFailed -> RemoteResult.Failed(reason) + is LibraryRefreshOutcome.Incremental -> RemoteResult.Ok(itemCount) + is LibraryRefreshOutcome.Full -> result +} + +/** Whether this outcome wrote books to the cache, so a shelf showing them should re-read. */ +internal val LibraryRefreshOutcome.wroteBooks: Boolean + get() = when (this) { + is LibraryRefreshOutcome.Incremental -> true + is LibraryRefreshOutcome.Full -> result !is RemoteResult.Failed + else -> false + } + +/** A background check or a full download. */ +internal enum class LibrarySyncKind { CHECK, FULL } + +/** + * Whether a new request can share the sync already running for the same + * library. A full download answers anything. A check answers only another + * check: someone who asked for a full refresh waits for the check to finish + * and then gets their own full download. + */ +internal fun canJoinInFlight(inFlight: LibrarySyncKind, requested: LibrarySyncKind): Boolean = + inFlight == LibrarySyncKind.FULL || requested == LibrarySyncKind.CHECK + +/** + * One sync per library at a time, shared by everyone who asks while it runs. + * + * Sign-in's sync and the Library's own load used to queue behind one mutex + * and each download the whole library, back to back. Now a second request + * that [canJoinInFlight] awaits the running one and gets the same result. A + * request that cannot join waits for the running one to finish and then + * starts its own, so the old ordering still holds: one fetch-and-apply after + * another, never an older response landing after a newer one. + * + * The sync runs in [scope], not in the caller. A caller that is cancelled + * (the Library leaving, a newer load replacing an older one) stops waiting, + * but the download carries on for whoever else is waiting and still lands in + * the cache, instead of throwing away minutes of work. + */ +internal class LibrarySyncSingleFlight( + private val scope: CoroutineScope, +) { + private class Flight( + @Volatile var kind: LibrarySyncKind, + val deferred: Deferred, + val upgraded: CompletableDeferred = CompletableDeferred(), + ) + + private val lock = Any() + private val flights = HashMap>() + + suspend fun run(libraryId: String, kind: LibrarySyncKind, block: suspend () -> R): R { + while (true) { + var blocking: Flight? = null + val joined: Flight? = synchronized(lock) { + val running = flights[libraryId] + when { + running != null && canJoinInFlight(running.kind, kind) -> running + running != null -> { + blocking = running + null + } + else -> startLocked(libraryId, kind, block) + } + } + if (joined != null) return joined.deferred.await() + // Not ours to share yet. Wait until it finishes or turns into a + // full download (see [upgradeToFull]), then look again. + val waitOn = blocking!! + select { + waitOn.deferred.onJoin {} + waitOn.upgraded.onJoin {} + } + // Upgraded means this flight is (or was) the full download we + // wanted, even if it already finished and left the map. + if (canJoinInFlight(waitOn.kind, kind)) return waitOn.deferred.await() + } + } + + /** + * Marks the running sync of [libraryId] as a full download. A check that + * finds it needs one calls this, so a full refresh asked for meanwhile + * (sign-in's sync while the Library's first check downloads a never + * synced library) joins it instead of downloading everything again + * right after. + */ + fun upgradeToFull(libraryId: String) { + synchronized(lock) { + val running = flights[libraryId] ?: return + if (running.kind != LibrarySyncKind.FULL) { + running.kind = LibrarySyncKind.FULL + running.upgraded.complete(Unit) + } + } + } + + private fun startLocked(libraryId: String, kind: LibrarySyncKind, block: suspend () -> R): Flight { + lateinit var flight: Flight + val deferred = scope.async(start = CoroutineStart.LAZY) { + try { + block() + } finally { + synchronized(lock) { + if (flights[libraryId] === flight) flights.remove(libraryId) + } + } + } + flight = Flight(kind, deferred) + flights[libraryId] = flight + deferred.start() + return flight + } +} diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncWatermarkStore.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncWatermarkStore.kt new file mode 100644 index 00000000..3375d8ed --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncWatermarkStore.kt @@ -0,0 +1,54 @@ +package com.ninelivesaudio.app.data.repository + +import com.ninelivesaudio.app.domain.model.LibrarySyncWatermark +import com.ninelivesaudio.app.service.SettingsManager +import kotlinx.coroutines.CancellationException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Where each library's last good sync stands, per server and account, + * persisted in the settings document next to the last sync record. + * + * Written only after the books it describes are saved, and only while the + * same server and account are still signed in, so a write can never land on + * someone else's session. A failed write is logged by the caller's absence of + * a watermark next time, which only costs a full download. + */ +@Singleton +class LibrarySyncWatermarkStore @Inject constructor( + private val settingsManager: SettingsManager, +) { + internal fun currentAccount(): SyncAccountKey { + val settings = settingsManager.currentSettings + return SyncAccountKey(serverUrl = settings.serverUrl, username = settings.username) + } + + internal fun get(key: SyncAccountKey, libraryId: String): LibrarySyncWatermark? = + settingsManager.currentSettings.librarySyncWatermarks.watermarkFor(key, libraryId) + + /** Saves [watermark] if its server and account are still the signed-in ones. */ + internal suspend fun put(watermark: LibrarySyncWatermark): Boolean = write { settings -> + if (settings.serverUrl != watermark.serverUrl || settings.username != watermark.username) { + settings + } else { + settings.copy(librarySyncWatermarks = settings.librarySyncWatermarks.withWatermark(watermark)) + } + } + + /** Forgets a library's watermark so its next check runs a full download. */ + internal suspend fun remove(key: SyncAccountKey, libraryId: String): Boolean = write { settings -> + val kept = settings.librarySyncWatermarks.withoutWatermark(key, libraryId) + if (kept.size == settings.librarySyncWatermarks.size) settings else settings.copy(librarySyncWatermarks = kept) + } + + private suspend fun write( + transform: (com.ninelivesaudio.app.domain.model.AppSettings) -> com.ninelivesaudio.app.domain.model.AppSettings, + ): Boolean = try { + settingsManager.updateSettingsIfAuthenticated(transform) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + false + } +} 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 2fef35cf..26a9a6d7 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 @@ -51,6 +51,13 @@ data class AppSettings( // Per-install high water mark for persisted sync outcomes. Ordering a // verdict by wall time lets a clock rollback reject every later sync. val lastSyncOutcomeSequence: Long = 0L, + // Where each library's book list stood after its last good sync, so a + // cold start can ask the server "anything new?" instead of downloading + // every book again. Kept here rather than in Room: no schema migration, + // it rides the same durable document as lastSync, and it is written only + // after the books it describes are already saved, so a crash can only + // leave it older than the shelf, never ahead of it. + val librarySyncWatermarks: List = emptyList(), ) { /** The selected library for the current source mode. The two modes never share a fallback. */ val activeLibraryId: String? @@ -93,6 +100,26 @@ data class LastSyncRecord( val serverUrl: String = "", ) +/** + * One library's book list as of its last complete sync, for one server and + * account. [newestAddedAt] and [newestItemId] are the most recently added + * book the server had then (null for an empty library), [itemCount] is how + * many books it reported, and [lastFullSyncAtMs] is the wall clock time of + * the last full download, which bounds how long a metadata edit can go + * unseen (the server cannot be asked for edited books, only added ones). + */ +@Serializable +data class LibrarySyncWatermark( + val serverUrl: String, + val username: String, + val libraryId: String, + val newestAddedAt: Long? = null, + val newestItemId: String? = null, + val itemCount: Int = 0, + val lastFullSyncAtMs: Long = 0L, + val updatedAtMs: Long = 0L, +) + @Serializable data class ServerProfile( val name: String = "", diff --git a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt index e51b5da5..0cdef195 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt @@ -72,6 +72,16 @@ class ConnectivityMonitor @Inject constructor( private val _isOnline = MutableStateFlow(false) val isOnline: StateFlow = _isOnline.asStateFlow() + // Unknown counts as metered, so nothing big waits on a guess of free data. + private val _isMetered = MutableStateFlow(true) + + /** + * Whether the active network may cost the user money (cell data, a + * hotspot, Wi-Fi marked metered). Background full library downloads wait + * while this is true. Re-read from the OS on every network callback. + */ + val isMetered: StateFlow = _isMetered.asStateFlow() + private val _isServerReachable = MutableStateFlow(false) val isServerReachable: StateFlow = _isServerReachable.asStateFlow() @@ -91,6 +101,7 @@ class ConnectivityMonitor @Inject constructor( private val networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { + refreshIsMetered() _isOnline.value = true updateConnectionStatus() // Check server reachability on reconnect (deduplicated) @@ -98,6 +109,7 @@ class ConnectivityMonitor @Inject constructor( } override fun onLost(network: Network) { + refreshIsMetered() // Check if we still have any active network val activeNetwork = connectivityManager.activeNetwork val capabilities = activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) } @@ -111,6 +123,8 @@ class ConnectivityMonitor @Inject constructor( } override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { + // Fires when Wi-Fi is marked metered or not, too. + refreshIsMetered() val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) if (_isOnline.value != hasInternet) { _isOnline.value = hasInternet @@ -205,9 +219,21 @@ class ConnectivityMonitor @Inject constructor( val activeNetwork = connectivityManager.activeNetwork val capabilities = activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) } _isOnline.value = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + refreshIsMetered() updateConnectionStatus() } + /** Re-reads the default network's metered state from the OS. */ + fun refreshIsMetered(): Boolean { + val metered = try { + connectivityManager.isActiveNetworkMetered + } catch (_: Exception) { + true + } + _isMetered.value = metered + return metered + } + suspend fun checkServerReachable(): Boolean = reachabilityCheckGate.run() suspend fun probeServerReachable(): Boolean = checkServerReachable() diff --git a/app/src/main/java/com/ninelivesaudio/app/service/ForegroundSyncPolicy.kt b/app/src/main/java/com/ninelivesaudio/app/service/ForegroundSyncPolicy.kt new file mode 100644 index 00000000..9076b403 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/service/ForegroundSyncPolicy.kt @@ -0,0 +1,83 @@ +package com.ninelivesaudio.app.service + +import com.ninelivesaudio.app.domain.model.Library +import com.ninelivesaudio.app.domain.model.SyncResult + +// The pure rules behind SyncManager's foreground timer and its check +// records, pinned by ForegroundSyncPolicyTest. + +/** A foreground entry inside this long after the last check does not check again. */ +internal const val FOREGROUND_CHECK_DEBOUNCE_MS = 2 * 60_000L + +/** How often a check repeats while the app stays in the foreground. */ +internal const val FOREGROUND_CHECK_INTERVAL_MS = 15 * 60_000L + +/** + * How long to wait after the app comes to the foreground before its check. + * No check yet, one at least [FOREGROUND_CHECK_DEBOUNCE_MS] old, or a clock + * reading before the last one all mean "now". A check under two minutes old + * (flipping to another app and straight back) waits out the rest of its + * 15-minute interval instead. + */ +internal fun delayBeforeEntryCheck(lastCheckAtMs: Long?, nowMs: Long): Long { + if (lastCheckAtMs == null) return 0L + val elapsed = nowMs - lastCheckAtMs + if (elapsed < 0 || elapsed >= FOREGROUND_CHECK_DEBOUNCE_MS) return 0L + return (FOREGROUND_CHECK_INTERVAL_MS - elapsed).coerceAtLeast(0L) +} + +/** + * Whether a timer tick or a server return should run a check. Anything that + * ran a sync in the last two minutes (Sync Now, sign-in, the entry check) + * already answered the question. + */ +internal fun isCheckDue(lastCheckAtMs: Long?, nowMs: Long): Boolean { + if (lastCheckAtMs == null) return true + val elapsed = nowMs - lastCheckAtMs + return elapsed < 0 || elapsed >= FOREGROUND_CHECK_DEBOUNCE_MS +} + +/** What a background check did across every library, for deciding its record. */ +internal data class CheckTally( + val anyDeferred: Boolean = false, + val wroteBooks: Boolean = false, + val libraryListChanged: Boolean = false, +) + +/** + * Whether a background check writes a [com.ninelivesaudio.app.domain.model.LastSyncRecord]. + * + * - A failure or a short fetch is written: that is the truth and the banner + * should say so. + * - A deferred full download writes nothing more: the shelf is known to be + * behind, so a success would be a lie, and nothing failed. + * - Books saved or the library list changing is written as the success it + * is, which also tells the Library to re-read its shelf. + * - Nothing changed writes nothing, unless the last record was not a + * success (or there is none), in which case the clean check heals it. + */ +internal fun shouldPersistCheckReport( + reportResult: SyncResult, + tally: CheckTally, + previousResult: SyncResult?, +): Boolean = when { + reportResult != SyncResult.SUCCESS -> true + tally.anyDeferred -> false + tally.wroteBooks || tally.libraryListChanged -> true + else -> previousResult != SyncResult.SUCCESS +} + +/** + * Whether the server's library list differs from the cached one in a way the + * Library shows: a library added, removed, or renamed. + */ +internal fun libraryListChanged(cached: List, fetched: List): Boolean = + cached.map { it.id to it.name }.toSet() != fetched.map { it.id to it.name }.toSet() + +/** + * The app plays audiobooks only. Podcast libraries need per-episode playback + * the app does not have, so background syncs skip them instead of + * downloading shelves nobody can play. Picking one in the Library still + * loads it on demand. + */ +internal fun isSyncedLibrary(library: Library): Boolean = library.mediaType == "book" diff --git a/app/src/main/java/com/ninelivesaudio/app/service/MediaBrowseTree.kt b/app/src/main/java/com/ninelivesaudio/app/service/MediaBrowseTree.kt index 6a1bc465..a9669321 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/MediaBrowseTree.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/MediaBrowseTree.kt @@ -594,12 +594,12 @@ internal class ArtworkFetchBurstTracker { * syncNow() bails on the reachability probe, so the epoch stops clearing for * the whole drive. * - * [MAX_EPOCH_AGE_MS] deliberately mirrors SyncManager.DEFAULT_SYNC_INTERVAL_MS. - * In ABS mode syncCompleted is already effectively a 5-minute heartbeat (it - * emits on every sync pass that clears the pre-checks, whether or not - * anything actually changed), and that is the configuration verified clean on - * a real head unit. So the floor does not invent a new traffic profile, it - * gives LOCAL and offline modes the one that was already proven. + * [MAX_EPOCH_AGE_MS] keeps the 5-minute cadence the old background sync + * timer gave syncCompleted, the configuration verified clean on a real head + * unit. That timer now runs only while the phone app is in the foreground + * (every 15 minutes), and a car session usually has the phone screen off, so + * this floor is now what keeps the epoch moving in every mode, not just LOCAL + * and offline. */ internal class ArtworkFetchEpoch( private val nowMs: () -> Long = System::currentTimeMillis, diff --git a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackService.kt b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackService.kt index 29fc5e44..0be6b6d8 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackService.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackService.kt @@ -136,8 +136,8 @@ class PlaybackService : MediaLibraryService() { // Start a fresh browse-artwork epoch so every book is // eligible for another fetch attempt. Note this emits on // every sync pass that clears SyncManager's pre-checks, - // changed data or not, so in ABS mode it behaves as a - // ~5-minute heartbeat rather than a strict change signal. + // changed data or not (a foreground check every 15 + // minutes), so it is a heartbeat, not a change signal. // That is deliberate and harmless: a working set that fits // the cache re-queries into hits and fetches nothing. mediaBrowseTree.invalidateArtworkEpoch() diff --git a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt index 13b4b87e..140770ba 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt @@ -11,6 +11,10 @@ import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.data.repository.AudioBookRepository import com.ninelivesaudio.app.data.repository.LibraryRepository import com.ninelivesaudio.app.data.repository.ProgressRepository +import com.ninelivesaudio.app.data.repository.itemCountResult +import com.ninelivesaudio.app.data.repository.wroteBooks +import com.ninelivesaudio.app.data.repository.LibraryRefreshOutcome +import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.LastSyncRecord @@ -29,14 +33,22 @@ private const val MIN_POSITION_SYNC_INTERVAL_MS = 30_000L private const val MIN_POSITION_DELTA = 2.0 private const val MIN_PROGRESS_DELTA = 0.01 +/** A full sync of every library, or the change check that does the least work needed. */ +internal enum class SyncMode { FULL, CHECK } + /** - * Manages periodic synchronization with the Audiobookshelf server. + * Manages synchronization with the Audiobookshelf server. * * Responsibilities: - * - Full sync of libraries and audiobooks at start, on explicit triggers, - * and at most hourly from the timer (see [shouldRefreshItemList]) - * - Progress sync every tick (pull from server, push offline queue) - * - Offline queue flushing on reconnect + * - A check while the app is in the foreground: on every foreground entry + * (debounced, see [delayBeforeEntryCheck]) and every 15 minutes after. + * A check pulls progress, flushes the offline queue, and asks each + * library whether anything changed ([SyncMode.CHECK]). Nothing runs on a + * timer in the background. Background playback pushes its own progress + * ([reportPlaybackPosition], [flushPlaybackProgress]) and the offline + * queue still flushes on reconnect. + * - A full sync of every library on sign-in, a mode switch back to the + * server, and Settings' Sync Now ([SyncMode.FULL]) * - Throttled position reporting during playback * * Port of C# SyncService.cs. @@ -51,10 +63,6 @@ class SyncManager @Inject constructor( private val connectivityMonitor: ConnectivityMonitor, private val settingsManager: SettingsManager, ) { - companion object { - private const val INITIAL_DELAY_MS = 500L // 0.5s — populate home screen fast - private const val DEFAULT_SYNC_INTERVAL_MS = 300_000L // 5 minutes - } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val lifecycleOwner = SyncLifecycleOwner(scope) @@ -64,12 +72,13 @@ class SyncManager @Inject constructor( // Sync state @Volatile private var activeItemId: String? = null - // When a full sync last ran the book-list fetch, for the timer's hourly - // decision. Kept in memory on purpose. Every cold start runs the initial - // full sync anyway, so a persisted stamp would never change a decision, - // and the persisted LastSyncRecord cannot stand in for it because the - // Library's single-shelf refresh writes that record too. - @Volatile private var lastFullItemSync: FullItemSyncStamp? = null + // When a sync last ran (any mode, monotonic clock), for the foreground + // debounce. In memory on purpose: a cold start should always check. + @Volatile private var lastCheckAtMs: Long? = null + + // Set from the process lifecycle (NineLivesApp). The timer only runs + // while this is true. + private val appInForeground = MutableStateFlow(false) private val playbackThrottleOwner = PlaybackThrottleOwner() @@ -95,9 +104,9 @@ class SyncManager @Inject constructor( val syncCompleted: SharedFlow = _syncCompleted.asSharedFlow() /** - * A progress-only tick finished. It writes no [LastSyncRecord], so a - * screen that re-reads its shelf on new records listens here instead to - * pick up the progress it just pulled into Room. + * A check pulled progress. A check that found the book list unchanged + * writes no [LastSyncRecord], so a screen that re-reads its shelf on new + * records listens here instead to pick up the progress it just pulled. */ private val _progressPulled = MutableSharedFlow(replay = 0, extraBufferCapacity = 4) val progressPulled: SharedFlow = _progressPulled.asSharedFlow() @@ -105,39 +114,46 @@ class SyncManager @Inject constructor( // ─── Lifecycle ─────────────────────────────────────────────────────────── /** - * Start the periodic sync timer. + * The app came to the foreground (true) or left it (false). Called from + * the process lifecycle, so it reflects every activity together, not one + * screen. Leaving stops the timer at once. + */ + fun setAppForeground(inForeground: Boolean) { + appInForeground.value = inForeground + } + + /** + * Start the foreground check timer and the reconnect listeners. * Call this once when the app is initialized and authenticated. */ fun start() { lifecycleOwner.restart { + // The foreground timer. collectLatest cancels the loop the moment + // the app leaves the foreground, so nothing polls in the background. launch { - // Fast first sync - delay(INITIAL_DELAY_MS) - syncNow() - - // Periodic sync. Progress every tick, the full book list at - // most hourly. A big library on a slow server used to pay a - // full re-download every 5 minutes here. - while (isActive) { - delay(DEFAULT_SYNC_INTERVAL_MS) - if (shouldRefreshItemList( - lastFullSync = lastFullItemSync, - serverUrl = settingsManager.currentSettings.serverUrl, - nowElapsedMs = SystemClock.elapsedRealtime(), - ) - ) { - syncNow() - } else { - syncProgressOnly() + appInForeground.collectLatest { inForeground -> + if (!inForeground) return@collectLatest + delay(delayBeforeEntryCheck(lastCheckAtMs, monotonicNowMs())) + runForegroundCheck() + while (isActive) { + delay(FOREGROUND_CHECK_INTERVAL_MS) + runForegroundCheck() } - // Also retry the offline queue here. The rising-edge flush only - // fires once per reconnect, so a push that failed right after - // reconnect would otherwise sit queued until the next full - // disconnect/reconnect cycle. - flushOfflineQueue() } } + // A full download a check put off on a metered network runs as + // soon as the foreground app is on an unmetered one. + launch { + combine( + appInForeground, + connectivityMonitor.isMetered, + audioBookRepository.deferredFullSyncs, + ) { inForeground, metered, deferred -> inForeground && !metered && deferred.isNotEmpty() } + .distinctUntilChanged() + .collect { ready -> if (ready) checkNow() } + } + // Flush offline queue on the rising edge of (connected AND not LOCAL). // Triggers on both connectivity returns and mode switches back to AUDIOBOOKSHELF // while already connected, so progress queued during LOCAL mode doesn't sit indefinitely. @@ -155,10 +171,14 @@ class SyncManager @Inject constructor( } } - // Resync when the server comes back after a failed or partial sync. + // Check when the server comes back after a failed or partial sync. // The flush above only pushes queued progress, so without this the // library and its "Last sync failed" banner stayed stale until a - // manual Retry or the next periodic tick (up to 5 minutes). + // manual Retry or the next check. Foreground only, under the same + // two-minute debounce as every other check, and a check (not a + // full download): a flapping server used to restart the whole + // download on every return. A full download a check still needs + // after a failure waits out the repository's backoff. // SYNCING counts as live so a sync's own status flip is not an edge. launch { combine( @@ -167,11 +187,13 @@ class SyncManager @Inject constructor( ) { status, settings -> isServerSessionLive(status, settings.appMode) } .distinctUntilChanged() .collect { live -> - if (live && shouldResyncOnServerReturn( + if (live && appInForeground.value && + isCheckDue(lastCheckAtMs, monotonicNowMs()) && + shouldResyncOnServerReturn( settingsManager.currentSettings.lastSyncForCurrentServer()?.result, ) ) { - syncNow() + checkNow() } } } @@ -191,7 +213,7 @@ class SyncManager @Inject constructor( if (oldMode != null && shouldReconnectForModeTransition(oldMode, newMode)) { performModeSwitchReconnect( refreshIsOnline = connectivityMonitor::refreshIsOnlineFromSystem, - syncNow = { syncNow() }, + syncNow = { syncNow(SyncMode.FULL) }, ) } } @@ -206,29 +228,46 @@ class SyncManager @Inject constructor( // ─── Sync Operations ───────────────────────────────────────────────────── + /** A timer tick or foreground entry: a check, unless one just ran, then the offline queue. */ + private suspend fun runForegroundCheck() { + if (isCheckDue(lastCheckAtMs, monotonicNowMs())) checkNow() + // Retry the offline queue too. The rising-edge flush only fires once + // per reconnect, so a push that failed right after reconnect would + // otherwise sit queued until the next full disconnect/reconnect cycle. + flushOfflineQueue() + } + + /** Run a [SyncMode.CHECK] now: progress, then each library's change check. */ + internal suspend fun checkNow(): SyncNowResult = syncNow(SyncMode.CHECK) + /** - * Execute a full sync: libraries + audiobooks + progress. - * Thread-safe via Mutex. + * Execute a sync: progress, the library list, then each book library's + * items, fully ([SyncMode.FULL], the default) or through the change + * check ([SyncMode.CHECK]). Thread-safe via Mutex. * * Returns the [SyncNowResult] this attempt actually produced. Callers * that report the outcome to the user (Settings' manual "Sync Now") must * render [SyncNowResult.record] directly rather than rereading - * [SettingsManager.currentSettings] afterward — a concurrent periodic - * sync can write its own outcome in the gap between this attempt - * finishing and a reread, and the reread would then report THAT - * attempt's result instead of this one's. A [SyncAttempt.SKIPPED] result - * means nothing ran at all: [SyncNowResult.record] is null and must not - * be read as this call's outcome either. + * [SettingsManager.currentSettings] afterward — a concurrent sync can + * write its own outcome in the gap between this attempt finishing and a + * reread, and the reread would then report THAT attempt's result instead + * of this one's. A [SyncAttempt.SKIPPED_NOT_READY] or + * [SyncAttempt.SKIPPED_BUSY] result means nothing ran at all, and + * [SyncAttempt.CHECKED_NOTHING_TO_RECORD] means a check ran and found + * nothing worth a new record: [SyncNowResult.record] is null for all three + * and must not be read as this call's outcome. * * The actual control flow (gate, reachability probe, mutex, persist) is * [runSyncAttempt] — extracted so its invariants are pinned directly by * tests instead of through a stand-in helper this method could quietly * stop calling. */ - internal suspend fun syncNow(): SyncNowResult { + internal suspend fun syncNow(mode: SyncMode = SyncMode.FULL): SyncNowResult { val serverUrlAtStart = settingsManager.currentSettings.serverUrl + var tally = CheckTally() + var recordSkipped = false - return runSyncAttempt( + val result = runSyncAttempt( isSyncReady = { isSyncReady() }, // Actually reach the server before committing to a sync. A live // VPN interface (e.g. Tailscale) keeps isOnline=true with no real @@ -245,40 +284,74 @@ class SyncManager @Inject constructor( onLockReleasing = { _isSyncing.value = false connectivityMonitor.setSyncing(false) + lastCheckAtMs = monotonicNowMs() }, // Progress sync FIRST — this populates the home screen grid // immediately. Library sync runs after (heavier, fetches all // book metadata). runSyncWork = { syncProgress() - fetchLibrarySyncReport( - fetchLibraries = libraryRepository::syncFromServer, - // syncLibraryItems already preserves local download state. - fetchItems = { library -> audioBookRepository.syncLibraryItems(library.id) }, - ).also { - // Stamped whatever the result. The hourly cap protects a - // slow server, and a timeout there is exactly when a - // 5-minute retry of the whole list hurts most. - lastFullItemSync = FullItemSyncStamp( - serverUrl = serverUrlAtStart, - atElapsedMs = SystemClock.elapsedRealtime(), + when (mode) { + SyncMode.FULL -> fetchLibrarySyncReportFromCounts( + fetchLibraries = libraryRepository::syncFromServer, + fetchItemCounts = { library -> + if (!isSyncedLibrary(library)) RemoteResult.Ok(0) + else audioBookRepository.syncLibraryItems(library.id) + }, ) + SyncMode.CHECK -> { + val cachedLibraries = libraryRepository.getAudiobookshelf() + fetchLibrarySyncReportFromCounts( + fetchLibraries = { + libraryRepository.syncFromServer().also { fetched -> + if (fetched is RemoteResult.Ok && libraryListChanged(cachedLibraries, fetched.value)) { + tally = tally.copy(libraryListChanged = true) + } + } + }, + fetchItemCounts = { library -> + if (!isSyncedLibrary(library)) { + RemoteResult.Ok(0) + } else { + val outcome = audioBookRepository.refreshLibraryItemsIfChanged( + libraryId = library.id, + isMetered = connectivityMonitor.refreshIsMetered(), + ) + tally = tally.copy( + anyDeferred = tally.anyDeferred || outcome is LibraryRefreshOutcome.Deferred, + wroteBooks = tally.wroteBooks || outcome.wroteBooks, + ) + // A deferral is no verdict. Report the cached + // shelf so it neither fails nor counts as zero. + outcome.itemCountResult() + ?: RemoteResult.Ok(audioBookRepository.countByLibrary(library.id)) + } + }, + ) + } } }, persistOutcome = { report -> - persistSyncOutcome( - report = report, - completedAtMs = System.currentTimeMillis(), - serverUrlAtStart = serverUrlAtStart, - isEligibleSession = { settings -> - settings.appMode == AppMode.AUDIOBOOKSHELF - }, - updateSettingsIfAuthenticated = settingsManager::updateSettingsIfAuthenticated, - ).also { outcome -> - if (!outcome.persisted) { - Log.e(TAG, "syncNow: failed to persist sync outcome") - } + val previousResult = settingsManager.currentSettings.lastSyncForCurrentServer()?.result + if (mode == SyncMode.CHECK && !shouldPersistCheckReport(report.result, tally, previousResult)) { + recordSkipped = true _syncCompleted.tryEmit(Unit) + PersistedSyncOutcome(recorded = false, persisted = true, record = null) + } else { + persistSyncOutcome( + report = report, + completedAtMs = System.currentTimeMillis(), + serverUrlAtStart = serverUrlAtStart, + isEligibleSession = { settings -> + settings.appMode == AppMode.AUDIOBOOKSHELF + }, + updateSettingsIfAuthenticated = settingsManager::updateSettingsIfAuthenticated, + ).also { outcome -> + if (!outcome.persisted) { + Log.e(TAG, "syncNow: failed to persist sync outcome") + } + _syncCompleted.tryEmit(Unit) + } } }, // A failed probe still has to be recorded. Returning silently @@ -301,35 +374,19 @@ class SyncManager @Inject constructor( } }, ) - } - - /** - * The timer's tick between full syncs: pull listening progress and - * nothing else. Same gate, reachability probe, and mutex as [syncNow], - * but it writes no [LastSyncRecord]. A progress pull says nothing about - * the book list, so recording one would fake a library verdict and flip - * the Library's sync banners and shelf decisions. An unreachable server - * is left for the next full sync to record. - */ - private suspend fun syncProgressOnly() { - if (!isSyncReady()) return - val reachable = connectivityMonitor.checkServerReachable() - if (!isSyncReady() || !reachable) return - if (!syncMutex.tryLock()) return - _isSyncing.value = true - connectivityMonitor.setSyncing(true) - try { - syncProgress() - } finally { - _isSyncing.value = false - connectivityMonitor.setSyncing(false) - syncMutex.unlock() + if (result.attempt == SyncAttempt.RAN || result.attempt == SyncAttempt.DISCARDED_SERVER_CHANGED) { + // Progress was pulled even when the book list said nothing new. + _progressPulled.tryEmit(Unit) + } + return if (recordSkipped && result.attempt == SyncAttempt.DISCARDED_SERVER_CHANGED) { + SyncNowResult(SyncAttempt.CHECKED_NOTHING_TO_RECORD, null) + } else { + result } - // Android Auto's browse refresh treats this as its 5-minute heartbeat. - _syncCompleted.tryEmit(Unit) - _progressPulled.tryEmit(Unit) } + private fun monotonicNowMs(): Long = SystemClock.elapsedRealtime() + /** * Cheap pre-check: authenticated, non-LOCAL, and the OS reports a * network. Evaluated fresh every call, so a recheck after the probe sees @@ -634,36 +691,6 @@ internal fun isServerSessionLive( internal fun shouldResyncOnServerReturn(lastResult: SyncResult?): Boolean = lastResult != null && lastResult != SyncResult.SUCCESS -/** How often the timer may re-download every library's book list. */ -internal const val FULL_ITEM_SYNC_INTERVAL_MS = 3_600_000L // 1 hour - -/** When a full sync last fetched the book list, and for which server. */ -internal data class FullItemSyncStamp( - val serverUrl: String, - val atElapsedMs: Long, -) - -/** - * Whether the periodic tick runs a full sync (every library's book list) or - * only pulls progress. Only the timer asks. App start, sign-in, a server - * switch, a mode switch back to the server, Home's reconnect, a server - * return after a failed sync, and Settings' Sync Now always run the full - * sync, and the Library's pull to refresh fetches its own shelf. A book - * added on the server can take up to an hour to appear without one of those. - * - * No stamp yet, a different server, or a clock that went backward all - * count as due, so the answer is never "skip" without a real recent sync. - */ -internal fun shouldRefreshItemList( - lastFullSync: FullItemSyncStamp?, - serverUrl: String, - nowElapsedMs: Long, -): Boolean { - if (lastFullSync == null || lastFullSync.serverUrl != serverUrl) return true - val elapsedMs = nowElapsedMs - lastFullSync.atElapsedMs - return elapsedMs < 0 || elapsedMs >= FULL_ITEM_SYNC_INTERVAL_MS -} - internal fun shouldReconnectForModeTransition( previousMode: AppMode, newMode: AppMode, @@ -724,6 +751,14 @@ internal enum class SyncAttempt { /** Another sync already held the mutex. */ SKIPPED_BUSY, + /** + * A check ran (progress pulled, every library asked) and found nothing + * that called for a new record: the book lists matched, or a full + * download is waiting for an unmetered network. The record already + * stored is not this attempt's result. + */ + CHECKED_NOTHING_TO_RECORD, + /** A sync actually ran (including the reachability-probe-failure path, which records FAILED). */ RAN, diff --git a/app/src/main/java/com/ninelivesaudio/app/service/SyncReport.kt b/app/src/main/java/com/ninelivesaudio/app/service/SyncReport.kt index f0a6b63b..b2dfd46d 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/SyncReport.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/SyncReport.kt @@ -106,6 +106,13 @@ internal fun buildShelfSyncReport( libraries: RemoteResult>?, selectedLibrary: Library?, items: RemoteResult>?, +): SyncReport? = buildShelfSyncReportFromCount(libraries, selectedLibrary, items?.map { it.size }) + +/** [buildShelfSyncReport] for an item fetch that reports how many books it saved. */ +internal fun buildShelfSyncReportFromCount( + libraries: RemoteResult>?, + selectedLibrary: Library?, + items: RemoteResult?, ): SyncReport? { if (libraries == null && items == null) return null @@ -116,8 +123,8 @@ internal fun buildShelfSyncReport( null -> if (selectedLibrary == null) 0 else 1 } val bookCount = when (items) { - is RemoteResult.Ok -> items.value.size - is RemoteResult.Partial -> items.value.size + is RemoteResult.Ok -> items.value + is RemoteResult.Partial -> items.value is RemoteResult.Failed, null -> 0 } val itemName = selectedLibrary?.name ?: "library" @@ -165,6 +172,18 @@ internal fun unreachableServerSyncReport(): SyncReport = SyncReport( internal suspend fun fetchLibrarySyncReport( fetchLibraries: suspend () -> RemoteResult>, fetchItems: suspend (Library) -> RemoteResult>, +): SyncReport = fetchLibrarySyncReportFromCounts( + fetchLibraries = fetchLibraries, + fetchItemCounts = { library -> fetchItems(library).map { books -> books.size } }, +) + +/** + * [fetchLibrarySyncReport] for item fetches that report how many books they + * saved rather than handing the books back. + */ +internal suspend fun fetchLibrarySyncReportFromCounts( + fetchLibraries: suspend () -> RemoteResult>, + fetchItemCounts: suspend (Library) -> RemoteResult, ): SyncReport { val librariesResult = try { fetchLibraries() @@ -176,14 +195,13 @@ internal suspend fun fetchLibrarySyncReport( val libraries = librariesResult.valueOrEmpty() val itemResults = libraries.map { library -> - val result = try { - fetchItems(library) + try { + fetchItemCounts(library) } catch (e: CancellationException) { throw e } catch (e: Exception) { RemoteResult.Failed(describeFailure(e)) } - result.map { books -> books.size } } return buildSyncReport( diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt index bb168e57..13b16605 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt @@ -183,7 +183,9 @@ class HomeViewModel @Inject constructor( settingsManager.currentSettings.appMode == AppMode.AUDIOBOOKSHELF }, refreshIsOnline = connectivityMonitor::refreshIsOnlineFromSystem, - syncNow = syncManager::syncNow, + // A reconnect asks each library what changed rather than + // downloading them all again. Sync Now still does the full pass. + syncNow = { syncManager.checkNow() }, ) } } 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 c92638a6..84f508a0 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 @@ -10,8 +10,10 @@ import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.data.remote.describeFailure import com.ninelivesaudio.app.data.remote.valueOrEmpty import com.ninelivesaudio.app.data.repository.AudioBookRepository +import com.ninelivesaudio.app.data.repository.LibraryRefreshOutcome import com.ninelivesaudio.app.data.repository.LibraryRepository import com.ninelivesaudio.app.data.repository.ReconciledServerLibraryList +import com.ninelivesaudio.app.data.repository.itemCountResult import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.AppSettings import com.ninelivesaudio.app.domain.model.AudioBook @@ -24,6 +26,7 @@ import com.ninelivesaudio.app.service.PersistedSyncOutcome import com.ninelivesaudio.app.service.SettingsManager import com.ninelivesaudio.app.service.SyncManager import com.ninelivesaudio.app.service.buildShelfSyncReport +import com.ninelivesaudio.app.service.buildShelfSyncReportFromCount import com.ninelivesaudio.app.service.lastSyncForCurrentServer import com.ninelivesaudio.app.service.persistActiveLibrarySelection import com.ninelivesaudio.app.service.persistSyncOutcome @@ -79,6 +82,7 @@ data class GroupedSection( val books: List, ) +private const val SHELF_REREAD_THROTTLE_MS = 1_000L private const val UNKNOWN_SERIES_GROUP = "Standalone/Unknown Series" private const val UNKNOWN_AUTHOR_GROUP = "Unknown Author" private const val UNKNOWN_GENRE_GROUP = "Uncategorized Genre" @@ -445,8 +449,8 @@ class LibraryViewModel @Inject constructor( } } - // The timer's progress-only ticks write no sync record, so the - // collector above never sees them. Re-read the saved shelf so + // A check that found the book list unchanged writes no sync record, + // so the collector above never sees it. Re-read the saved shelf so // progress pulled from the server shows while the tab is open. viewModelScope.launch { syncManager.progressPulled.collect { @@ -455,6 +459,22 @@ class LibraryViewModel @Inject constructor( } } + // A full download saves a page at a time, so a big library's first + // load fills in as pages land instead of after the last one. At most + // one re-read per second or so, whoever started the download. + viewModelScope.launch { + audioBookRepository.booksSaved + .filter { libraryId -> + val state = _uiState.value + shelfLoadedFor != null && !state.isLocalMode && state.selectedLibrary?.id == libraryId + } + .conflate() + .collect { + applyFilter()?.join() + delay(SHELF_REREAD_THROTTLE_MS) + } + } + // LOCAL mode has no sync record, so watch the local catalog itself. // A folder scan or rescan running in Settings lands here while the // tab is open, not only on the next visit. @@ -512,7 +532,13 @@ class LibraryViewModel @Inject constructor( // ─── Loading ────────────────────────────────────────────────────────── - private suspend fun loadLibraries(keepPendingPick: Boolean = false) { + /** + * [explicit] is a refresh the user asked for (pull to refresh, Retry): + * the selected library downloads in full. Otherwise (first load, a + * return, a reload after a background sync) it only asks the server what + * changed. See [loadAudioBooks]. + */ + private suspend fun loadLibraries(keepPendingPick: Boolean = false, explicit: Boolean = false) { shelfLoadedFor = settingsManager.currentSettings.shelfIdentity() _uiState.update { it.copy(isLoading = true, errorMessage = null) } localCatalogBaseline.await() @@ -582,13 +608,18 @@ class LibraryViewModel @Inject constructor( } filterPublication.invalidate() - val itemResult = selected?.let { - loadAudioBooks(it.id, persistResult = false, serverReachable = serverReachable) + val itemLoad = selected?.let { + loadAudioBooks(it.id, persistResult = false, serverReachable = serverReachable, explicit = explicit) } - if (!isLocalMode) { - buildShelfSyncReport(libraryResult, selected, itemResult)?.let { report -> + val itemResult = itemLoad?.result + // A check that found nothing new, or a download put off until + // unmetered, has no news for the record. A library list failure + // still does. + val recordWorthy = itemLoad == null || itemLoad.recordWorthy || libraryResult !is RemoteResult.Ok + if (!isLocalMode && recordWorthy) { + buildShelfSyncReportFromCount(libraryResult, selected, itemResult)?.let { report -> val selectedLibraryFetchResult = selected?.let { - buildShelfSyncReport(libraries = null, selectedLibrary = it, items = itemResult)?.result + buildShelfSyncReportFromCount(libraries = null, selectedLibrary = it, items = itemResult)?.result } persistLastSync( report = report, @@ -630,12 +661,20 @@ class LibraryViewModel @Inject constructor( applyFilter()?.join() } + /** + * Shows the saved shelf, then brings it up to date: a full download when + * [explicit], otherwise the change check, which may find nothing, fetch + * only added books, or fall back to a full download (deferred on a + * metered network). A check is shared with SyncManager's if both run at + * once, as is a full download. + */ private suspend fun loadAudioBooks( libraryId: String, persistResult: Boolean = true, serverReachable: Boolean? = null, - ): RemoteResult>? { - var itemResult: RemoteResult>? = null + explicit: Boolean = false, + ): ShelfItemLoad? { + var itemLoad: ShelfItemLoad? = null try { val serverUrlAtStart = settingsManager.currentSettings.serverUrl val selected = _uiState.value.selectedLibrary @@ -653,15 +692,26 @@ class LibraryViewModel @Inject constructor( isOnline = connectivityMonitor.isOnline.value, ) && (serverReachable ?: connectivityMonitor.checkServerReachable()) ) { - itemResult = refreshSelectedLibraryItems( - libraryId = libraryId, - fetchRemote = audioBookRepository::syncLibraryItems, - ) + val load = if (explicit) { + ShelfItemLoad( + result = refreshSelectedLibraryItems( + libraryId = libraryId, + fetchRemote = audioBookRepository::syncLibraryItems, + ), + recordWorthy = true, + ) + } else { + checkSelectedLibraryItems(libraryId) { id -> + audioBookRepository.refreshLibraryItemsIfChanged(id, connectivityMonitor.refreshIsMetered()) + } + } + itemLoad = load + val itemResult = load.result // The selected library's own outcome, tracked separately // from the whole-account aggregate lastSyncResult (issue // #14, PR #30 review, finding A) — see decideLibraryShelf. val ownShelfReport = selected?.let { - buildShelfSyncReport(libraries = null, selectedLibrary = it, items = itemResult) + buildShelfSyncReportFromCount(libraries = null, selectedLibrary = it, items = itemResult) } _uiState.update { it.copy( @@ -670,7 +720,7 @@ class LibraryViewModel @Inject constructor( selectedLibraryFetchPersisted = false, ) } - if (persistResult && ownShelfReport != null) { + if (persistResult && load.recordWorthy && ownShelfReport != null) { persistLastSync( report = ownShelfReport, serverUrlAtStart = serverUrlAtStart, @@ -689,7 +739,7 @@ class LibraryViewModel @Inject constructor( it.copy(errorMessage = "Failed to load audiobooks: ${e.message}") } } - return itemResult + return itemLoad } private suspend fun persistLastSync( @@ -758,7 +808,8 @@ class LibraryViewModel @Inject constructor( } if (pendingLibraryPickId == library.id) pendingLibraryPickId = null shelfLoadedFor = settingsManager.currentSettings.shelfIdentity() - // Full resync for the newly selected library + // Brings the newly selected library up to date: a full download + // the first time, only what changed after that. loadAudioBooks(library.id) _uiState.update { it.copy(isLoading = false) } } @@ -825,17 +876,18 @@ class LibraryViewModel @Inject constructor( } } + /** Pull to refresh and every Retry button: the selected library downloads in full. */ fun refresh() { libraryLoadLaunch.launch(viewModelScope) { _uiState.update { it.copy(isRefreshing = true) } - loadLibrariesOwningRefresh() + loadLibrariesOwningRefresh(explicit = true) } } /** Load, then clear the refresh spinner unless a newer load took over the lane. */ - private suspend fun loadLibrariesOwningRefresh(keepPendingPick: Boolean = false) { + private suspend fun loadLibrariesOwningRefresh(keepPendingPick: Boolean = false, explicit: Boolean = false) { try { - loadLibraries(keepPendingPick) + loadLibraries(keepPendingPick, explicit) } finally { updateLibraryLoadStateIfActive { _uiState.update { it.copy(isRefreshing = false) } @@ -1039,10 +1091,10 @@ internal suspend fun refreshRemoteLibraryList( ) } -internal suspend fun refreshSelectedLibraryItems( +internal suspend fun refreshSelectedLibraryItems( libraryId: String, - fetchRemote: suspend (String) -> RemoteResult>, -): RemoteResult> = try { + fetchRemote: suspend (String) -> RemoteResult, +): RemoteResult = try { fetchRemote(libraryId) } catch (e: CancellationException) { throw e @@ -1050,6 +1102,35 @@ internal suspend fun refreshSelectedLibraryItems( RemoteResult.Failed(describeFailure(e)) } +/** + * What the selected library's load brought back: its book count result (null + * when there is no verdict, as for a download waiting on an unmetered + * network) and whether that is news worth a sync record. A check that found + * nothing new is a fine verdict for this screen but not a new record. + */ +internal data class ShelfItemLoad( + val result: RemoteResult?, + val recordWorthy: Boolean, +) + +/** The change check for the selected library, as a [ShelfItemLoad]. A thrown failure reads as a failed check. */ +internal suspend fun checkSelectedLibraryItems( + libraryId: String, + check: suspend (String) -> LibraryRefreshOutcome, +): ShelfItemLoad { + val outcome = try { + check(libraryId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LibraryRefreshOutcome.CheckFailed(describeFailure(e)) + } + return ShelfItemLoad( + result = outcome.itemCountResult(), + recordWorthy = outcome !is LibraryRefreshOutcome.Unchanged && outcome !is LibraryRefreshOutcome.Deferred, + ) +} + internal fun rethrowLibraryLoadCancellation(error: Exception) { if (error is CancellationException) throw error } 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 384d7a9f..ffe95b42 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 @@ -1629,6 +1629,11 @@ internal fun syncNowOutcome(result: SyncNowResult): SyncNowOutcome = when (resul successMessage = null, errorMessage = "Sync did not run. Check your connection and try again.", ) + // Only a background check returns this. Sync Now always runs a full sync. + SyncAttempt.CHECKED_NOTHING_TO_RECORD -> SyncNowOutcome( + successMessage = "Library is up to date", + errorMessage = null, + ) SyncAttempt.DISCARDED_SERVER_CHANGED -> SyncNowOutcome( successMessage = null, errorMessage = "The server changed while syncing. Sync again to refresh the new server.", diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibraryItemFetchSerializationTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibraryItemFetchSerializationTest.kt index 8810b6fa..22f9cbe4 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibraryItemFetchSerializationTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibraryItemFetchSerializationTest.kt @@ -14,15 +14,26 @@ class LibraryItemFetchSerializationTest { private fun book(id: String) = AudioBook(id = id, title = id) + /** A fetch that hands its whole response over as one page, then reports how many books it had. */ + private fun pagesOf( + fetchItems: suspend () -> RemoteResult>, + ): suspend (suspend (List) -> Unit) -> RemoteResult = { onPage -> + when (val result = fetchItems()) { + is RemoteResult.Ok -> { onPage(result.value); RemoteResult.Ok(result.value.size) } + is RemoteResult.Partial -> { onPage(result.value); RemoteResult.Partial(result.value.size, result.reason) } + is RemoteResult.Failed -> result + } + } + private fun fakeSync( mutex: Mutex, cache: MutableList, fetchItems: suspend () -> RemoteResult>, - ): suspend () -> RemoteResult> = { + ): suspend () -> RemoteResult = { runSerializedLibraryItemSync( mutex = mutex, libraryId = "lib-a", - fetchItems = fetchItems, + fetchPages = pagesOf(fetchItems), mergeItems = { it }, upsertAll = { books -> books.forEach { if (it.id !in cache) cache.add(it.id) } }, cachedNonDownloadedIds = { cache.toList() }, @@ -99,7 +110,7 @@ class LibraryItemFetchSerializationTest { runSerializedLibraryItemSync( mutex = mutex, libraryId = "lib-a", - fetchItems = { RemoteResult.Ok(listOf(book("current-book"))) }, + fetchPages = pagesOf { RemoteResult.Ok(listOf(book("current-book"))) }, mergeItems = { it }, upsertAll = { books -> cache.addAll(books.map { it.id }) diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt new file mode 100644 index 00000000..eb09f320 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt @@ -0,0 +1,207 @@ +package com.ninelivesaudio.app.data.repository + +import com.ninelivesaudio.app.data.remote.LibraryHead +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.LibrarySyncWatermark +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A big library on a slow server used to download in full on every launch. + * The change check compares one tiny head request against the last good + * sync and does the least work that keeps the shelf truthful. + */ +class LibrarySyncPlannerTest { + + private val key = SyncAccountKey("https://abs.example.net", "jeff") + private val day = 24 * 60 * 60_000L + + private fun watermark( + newestAddedAt: Long? = 5_000L, + newestItemId: String? = "li_newest", + itemCount: Int = 100, + lastFullSyncAtMs: Long = 0L, + ) = LibrarySyncWatermark( + serverUrl = key.serverUrl, + username = key.username, + libraryId = "lib", + newestAddedAt = newestAddedAt, + newestItemId = newestItemId, + itemCount = itemCount, + lastFullSyncAtMs = lastFullSyncAtMs, + ) + + private fun head(total: Int = 100, id: String? = "li_newest", addedAt: Long? = 5_000L) = + LibraryHead(total = total, newestItemId = id, newestAddedAt = addedAt) + + // ─── The change check ───────────────────────────────────────────────── + + @Test + fun `same newest book and same count is no change`() { + assertEquals(LibraryChange.NoChange, decideLibraryChange(watermark(), head(), localServerRowCount = 100)) + } + + @Test + fun `a newer newest book is an incremental sync`() { + assertEquals( + LibraryChange.Incremental, + decideLibraryChange(watermark(), head(total = 103, id = "li_new", addedAt = 9_000L), 100), + ) + } + + @Test + fun `no watermark means the first sync is full`() { + assertTrue(decideLibraryChange(null, head(), 100) is LibraryChange.Full) + } + + @Test + fun `fewer books with the same newest one means a removal and a full sync`() { + assertTrue(decideLibraryChange(watermark(), head(total = 99), 100) is LibraryChange.Full) + } + + @Test + fun `the newest book gone means a full sync`() { + assertTrue(decideLibraryChange(watermark(), head(total = 99, id = "li_older", addedAt = 4_000L), 100) is LibraryChange.Full) + } + + @Test + fun `a cache missing rows the watermark promised means a full sync`() { + assertTrue(decideLibraryChange(watermark(itemCount = 100), head(), localServerRowCount = 0) is LibraryChange.Full) + } + + @Test + fun `an emptied library is a full sync so the prune can run`() { + assertTrue(decideLibraryChange(watermark(), head(total = 0, id = null, addedAt = null), 100) is LibraryChange.Full) + assertEquals( + LibraryChange.NoChange, + decideLibraryChange(watermark(newestAddedAt = null, newestItemId = null, itemCount = 0), head(0, null, null), 0), + ) + } + + @Test + fun `a head without an added date is not trusted`() { + assertTrue(decideLibraryChange(watermark(), head(addedAt = null), 100) is LibraryChange.Full) + } + + @Test + fun `a library that was empty and now has books fetches them incrementally`() { + assertEquals( + LibraryChange.Incremental, + decideLibraryChange(watermark(newestAddedAt = null, newestItemId = null, itemCount = 0), head(total = 2), 0), + ) + } + + // ─── Plans, staleness, and metered deferral ─────────────────────────── + + @Test + fun `a full sync found by a check waits for unmetered when a shelf is cached`() { + val full = LibraryChange.Full("removed") + assertEquals(LibrarySyncPlan.DEFER_FULL, planLibrarySync(full, isStale = false, isMetered = true, hasCachedRows = true)) + assertEquals(LibrarySyncPlan.FULL, planLibrarySync(full, isStale = false, isMetered = false, hasCachedRows = true)) + assertEquals(LibrarySyncPlan.FULL, planLibrarySync(full, isStale = false, isMetered = true, hasCachedRows = false)) + } + + @Test + fun `an explicit refresh never waits for unmetered`() { + assertTrue(shouldDeferFullSync(isMetered = true, explicit = false, hasCachedRows = true)) + assertFalse(shouldDeferFullSync(isMetered = true, explicit = true, hasCachedRows = true)) + assertFalse(shouldDeferFullSync(isMetered = false, explicit = false, hasCachedRows = true)) + } + + @Test + fun `incremental updates run on metered networks`() { + assertEquals( + LibrarySyncPlan.INCREMENTAL, + planLibrarySync(LibraryChange.Incremental, isStale = false, isMetered = true, hasCachedRows = true), + ) + } + + @Test + fun `a day-old full sync refreshes everything on unmetered and steps down on metered`() { + assertEquals(LibrarySyncPlan.FULL, planLibrarySync(LibraryChange.NoChange, isStale = true, isMetered = false, hasCachedRows = true)) + assertEquals(LibrarySyncPlan.SKIP, planLibrarySync(LibraryChange.NoChange, isStale = true, isMetered = true, hasCachedRows = true)) + assertEquals(LibrarySyncPlan.INCREMENTAL, planLibrarySync(LibraryChange.Incremental, isStale = true, isMetered = true, hasCachedRows = true)) + assertEquals(LibrarySyncPlan.SKIP, planLibrarySync(LibraryChange.NoChange, isStale = false, isMetered = false, hasCachedRows = true)) + } + + @Test + fun `staleness is a day, and a clock that went backward counts as stale`() { + val w = watermark(lastFullSyncAtMs = 10 * day) + assertFalse(isWatermarkStale(w, nowMs = 10 * day + day - 1)) + assertTrue(isWatermarkStale(w, nowMs = 11 * day)) + assertTrue(isWatermarkStale(w, nowMs = 9 * day)) + } + + // ─── The added-since fetch and its deletion check ───────────────────── + + @Test + fun `the cutoff reaches five minutes before the stored newest book`() { + assertEquals(5_000L - INCREMENTAL_OVERLAP_MS, incrementalCutoff(watermark(newestAddedAt = 5_000L))) + assertEquals(Long.MIN_VALUE, incrementalCutoff(watermark(newestAddedAt = null))) + } + + @Test + fun `counts that disagree after the added books mean something was removed`() { + assertTrue(incrementalCountsAgree(previousCount = 100, newBookCount = 3, serverTotal = 103)) + assertFalse(incrementalCountsAgree(previousCount = 100, newBookCount = 3, serverTotal = 102)) + assertFalse(incrementalCountsAgree(previousCount = 100, newBookCount = 1, serverTotal = 100)) + } + + @Test + fun `an incremental sync moves the watermark to the server's newest book`() { + val fetched = listOf(AudioBook(id = "li_new", addedAt = 9_000L), AudioBook(id = "li_newest", addedAt = 5_000L)) + val next = watermarkAfterIncremental(watermark(lastFullSyncAtMs = 7L), fetched, serverTotal = 101, nowMs = 50L) + assertEquals(9_000L, next.newestAddedAt) + assertEquals("li_new", next.newestItemId) + assertEquals(101, next.itemCount) + assertEquals(7L, next.lastFullSyncAtMs) + } + + @Test + fun `a full sync tally finds the newest book and refuses a watermark without added dates`() { + val tally = FullSyncTally() + tally.add(listOf(AudioBook(id = "a", addedAt = 3L), AudioBook(id = "b", addedAt = 9L))) + tally.add(listOf(AudioBook(id = "b", addedAt = 9L), AudioBook(id = "c", addedAt = 5L))) + val w = watermarkAfterFullSync(key, "lib", tally, nowMs = 42L)!! + assertEquals(3, w.itemCount) + assertEquals("b", w.newestItemId) + assertEquals(9L, w.newestAddedAt) + assertEquals(42L, w.lastFullSyncAtMs) + + val missing = FullSyncTally().apply { add(listOf(AudioBook(id = "x", addedAt = null))) } + assertNull(watermarkAfterFullSync(key, "lib", missing, nowMs = 42L)) + } + + @Test + fun `watermarks are kept per server, account, and library`() { + val mine = watermark() + val other = mine.copy(username = "guest", itemCount = 5) + val list = emptyList().withWatermark(mine).withWatermark(other) + assertEquals(100, list.watermarkFor(key, "lib")!!.itemCount) + assertEquals(5, list.watermarkFor(key.copy(username = "guest"), "lib")!!.itemCount) + assertNull(list.watermarkFor(key.copy(serverUrl = "https://other"), "lib")) + assertEquals(1, list.withoutWatermark(key, "lib").size) + val capped = (1..10).fold(emptyList()) { acc, i -> + acc.withWatermark(mine.copy(libraryId = "lib$i", updatedAtMs = i.toLong()), cap = 3) + } + assertEquals(listOf("lib10", "lib9", "lib8"), capped.map { it.libraryId }) + } + + // ─── Failure backoff ────────────────────────────────────────────────── + + @Test + fun `background full downloads back off after failures and cap at four hours`() { + val minute = 60_000L + assertEquals(15 * minute, fullSyncBackoffMs(1)) + assertEquals(30 * minute, fullSyncBackoffMs(2)) + assertEquals(4 * 60 * minute, fullSyncBackoffMs(20)) + val failures = FullSyncFailures(count = 1, lastFailureAtMs = 1_000L) + assertTrue(isFullSyncBackedOff(failures, nowMs = 1_000L + 14 * minute)) + assertFalse(isFullSyncBackedOff(failures, nowMs = 1_000L + 15 * minute)) + assertFalse(isFullSyncBackedOff(null, nowMs = 0L)) + assertFalse(isFullSyncBackedOff(failures, nowMs = 0L)) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncSingleFlightTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncSingleFlightTest.kt new file mode 100644 index 00000000..04382ae1 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncSingleFlightTest.kt @@ -0,0 +1,259 @@ +package com.ninelivesaudio.app.data.repository + +import com.ninelivesaudio.app.data.remote.RemoteResult +import com.ninelivesaudio.app.domain.model.AudioBook +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger + +/** + * Sign-in's sync and the Library's first load each downloaded the whole + * library, one after the other (214 s instead of 107 s on a 51-page shelf). + * A request that arrives while a sync of the same library runs now shares it. + */ +class LibrarySyncSingleFlightTest { + + private fun flightScope() = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @Test + fun `a second request while a sync runs shares its result and fetches nothing`() = runBlocking { + val scope = flightScope() + val flights = LibrarySyncSingleFlight(scope) + val fetches = AtomicInteger() + val started = CompletableDeferred() + val release = CompletableDeferred() + val fetcher: suspend () -> Int = { + fetches.incrementAndGet() + started.complete(Unit) + release.await() + 51 + } + + val first = async { flights.run("lib", LibrarySyncKind.FULL, fetcher) } + started.await() + val second = async { flights.run("lib", LibrarySyncKind.FULL, fetcher) } + val check = async { flights.run("lib", LibrarySyncKind.CHECK, fetcher) } + yield() + release.complete(Unit) + + assertEquals(51, first.await()) + assertEquals(51, second.await()) + assertEquals(51, check.await()) + assertEquals(1, fetches.get()) + scope.cancel() + } + + @Test + fun `a full refresh asked for during a check waits and then runs its own`() = runBlocking { + val scope = flightScope() + val flights = LibrarySyncSingleFlight(scope) + val order = mutableListOf() + val checkStarted = CompletableDeferred() + val releaseCheck = CompletableDeferred() + + val check = async { + flights.run("lib", LibrarySyncKind.CHECK) { + checkStarted.complete(Unit) + releaseCheck.await() + synchronized(order) { order += "check" } + "unchanged" + } + } + checkStarted.await() + val full = async { + flights.run("lib", LibrarySyncKind.FULL) { + synchronized(order) { order += "full" } + "downloaded" + } + } + yield() + assertFalse(full.isCompleted) + releaseCheck.complete(Unit) + + assertEquals("unchanged", check.await()) + assertEquals("downloaded", full.await()) + assertEquals(listOf("check", "full"), order) + scope.cancel() + } + + @Test + fun `a check that turns into a full download is shared with a full refresh already waiting`() = runBlocking { + val scope = flightScope() + val flights = LibrarySyncSingleFlight(scope) + val fetches = AtomicInteger() + val checkStarted = CompletableDeferred() + val upgraded = CompletableDeferred() + val releaseDownload = CompletableDeferred() + + val check = async { + flights.run("lib", LibrarySyncKind.CHECK) { + checkStarted.complete(Unit) + upgraded.await() + // No watermark: the check decides on a full download. + flights.upgradeToFull("lib") + fetches.incrementAndGet() + releaseDownload.await() + "51 pages" + } + } + checkStarted.await() + val signIn = async { flights.run("lib", LibrarySyncKind.FULL) { fetches.incrementAndGet(); "second download" } } + yield() + upgraded.complete(Unit) + releaseDownload.complete(Unit) + + assertEquals("51 pages", check.await()) + assertEquals("51 pages", signIn.await()) + assertEquals(1, fetches.get()) + scope.cancel() + } + + @Test + fun `different libraries do not share`() = runBlocking { + val scope = flightScope() + val flights = LibrarySyncSingleFlight(scope) + val a = async { flights.run("a", LibrarySyncKind.FULL) { "a" } } + val b = async { flights.run("b", LibrarySyncKind.FULL) { "b" } } + assertEquals("a", a.await()) + assertEquals("b", b.await()) + scope.cancel() + } + + @Test + fun `a caller that leaves does not cancel the sync others are waiting on`() = runBlocking { + val scope = flightScope() + val flights = LibrarySyncSingleFlight(scope) + val started = CompletableDeferred() + val release = CompletableDeferred() + val saved = AtomicInteger() + + val leaving = launch { + flights.run("lib", LibrarySyncKind.FULL) { + started.complete(Unit) + release.await() + saved.incrementAndGet() + 7 + } + } + started.await() + val staying = async { flights.run("lib", LibrarySyncKind.CHECK) { error("must join, not fetch") } } + yield() + leaving.cancelAndJoin() + release.complete(Unit) + + assertEquals(7, staying.await()) + assertEquals(1, saved.get()) + scope.cancel() + } + + @Test + fun `a failed sync fails every waiter and the next request starts fresh`() = runBlocking { + val scope = flightScope() + val flights = LibrarySyncSingleFlight(scope) + val failure = runCatching { flights.run("lib", LibrarySyncKind.FULL) { throw IllegalStateException("boom") } } + assertTrue(failure.exceptionOrNull() is IllegalStateException) + assertEquals(3, flights.run("lib", LibrarySyncKind.FULL) { 3 }) + scope.cancel() + } + + @Test + fun `joining rules`() { + assertTrue(canJoinInFlight(LibrarySyncKind.FULL, LibrarySyncKind.FULL)) + assertTrue(canJoinInFlight(LibrarySyncKind.FULL, LibrarySyncKind.CHECK)) + assertTrue(canJoinInFlight(LibrarySyncKind.CHECK, LibrarySyncKind.CHECK)) + assertFalse(canJoinInFlight(LibrarySyncKind.CHECK, LibrarySyncKind.FULL)) + } + + @Test + fun `pages are saved as they arrive and only a complete fetch prunes`() = runBlocking { + val cache = mutableListOf("stale", "kept") + val savedPages = mutableListOf>() + val complete = runLibraryItemSyncPass( + libraryId = "lib", + fetchPages = { onPage -> + onPage(listOf(AudioBook(id = "kept"), AudioBook(id = "new1"))) + onPage(listOf(AudioBook(id = "new2"))) + RemoteResult.Ok(3) + }, + mergeItems = { it }, + upsertAll = { books -> books.forEach { if (it.id !in cache) cache += it.id } }, + cachedNonDownloadedIds = { cache.toList() }, + deleteByIds = { _, ids -> cache.removeAll(ids) }, + deleteAllServerBooks = { cache.clear() }, + onPageSaved = { page -> savedPages += page.map { it.id } }, + ) + assertEquals(RemoteResult.Ok(3), complete) + assertEquals(listOf(listOf("kept", "new1"), listOf("new2")), savedPages) + assertEquals(setOf("kept", "new1", "new2"), cache.toSet()) + + val partialCache = mutableListOf("stale") + runLibraryItemSyncPass( + libraryId = "lib", + fetchPages = { onPage -> + onPage(listOf(AudioBook(id = "new1"))) + RemoteResult.Partial(1, "page 1: HTTP 500") + }, + mergeItems = { it }, + upsertAll = { books -> books.forEach { partialCache += it.id } }, + cachedNonDownloadedIds = { partialCache.toList() }, + deleteByIds = { _, ids -> partialCache.removeAll(ids) }, + deleteAllServerBooks = { partialCache.clear() }, + ) + assertEquals(listOf("stale", "new1"), partialCache) + } + + @Test + fun `a stale response still cannot resurrect a pruned book once flights serialize behind the lock`() = runBlocking { + // A full refresh that could not join a running check queues behind it + // on the library lock, so its newer prune lands after the older save. + val scope = flightScope() + val flights = LibrarySyncSingleFlight>(scope) + val lock = Mutex() + val cache = mutableListOf("book-a", "book-b") + val oldStarted = CompletableDeferred() + val releaseOld = CompletableDeferred() + fun pass(books: List, gate: suspend () -> Unit): suspend () -> RemoteResult = { + runSerializedLibraryItemSync( + mutex = lock, + libraryId = "lib", + fetchPages = { onPage -> + gate() + onPage(books.map { AudioBook(id = it) }) + RemoteResult.Ok(books.size) + }, + mergeItems = { it }, + upsertAll = { list -> list.forEach { if (it.id !in cache) cache += it.id } }, + cachedNonDownloadedIds = { cache.toList() }, + deleteByIds = { _, ids -> cache.removeAll(ids) }, + deleteAllServerBooks = { cache.clear() }, + ) + } + val old = async { + flights.run("lib", LibrarySyncKind.CHECK, pass(listOf("book-a", "book-b")) { + oldStarted.complete(Unit) + releaseOld.await() + }) + } + oldStarted.await() + val newer = async { flights.run("lib", LibrarySyncKind.FULL, pass(listOf("book-a")) {}) } + yield() + releaseOld.complete(Unit) + old.await() + newer.await() + assertEquals(listOf("book-a"), cache) + scope.cancel() + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/SyncMergeTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/SyncMergeTest.kt index 4bedfc01..a2124680 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/repository/SyncMergeTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/SyncMergeTest.kt @@ -132,6 +132,43 @@ class SyncMergeTest { assertEquals(remote.chapters, merged.chapters) } + @Test + fun `the lean merge reads whole rows only for downloaded books missing tracks`() = kotlinx.coroutines.runBlocking { + val downloaded = downloadedDetailedBook().toEntity() + val plain = localEntity(isDownloaded = 0, localPath = null, localCoverPath = null, currentTimeSeconds = 50.0, progress = 0.5) + .copy(id = "2") + val fullRowLookups = mutableListOf>() + + val merged = mergeSyncedBooksLean( + remote = listOf( + AudioBook(id = "1", title = "Server title"), + AudioBook(id = "2", title = "Other"), + AudioBook(id = "3", title = "New"), + ), + getMergeStates = { ids -> listOf(downloaded, plain).filter { it.id in ids }.map { it.toSyncMergeState() } }, + getFullRows = { ids -> fullRowLookups += ids; listOf(downloaded).filter { it.id in ids } }, + ) + + assertEquals(listOf(listOf("1")), fullRowLookups) + assertEquals(downloadedDetailedBook().audioFiles, merged[0].audioFiles) + assertEquals(downloadedDetailedBook().chapters, merged[0].chapters) + assertTrue(merged[0].isDownloaded) + assertEquals(50.0, merged[1].currentTime.inWholeMilliseconds / 1000.0, 0.0001) + assertEquals(AudioBook(id = "3", title = "New"), merged[2]) + } + + @Test + fun `the lean merge matches the whole-row merge`() { + val local = localEntity(currentTimeSeconds = 90.0, progress = 0.4, isFinished = 0, archivedAt = 7L) + val remote = AudioBook( + id = "1", + title = "Server", + audioFiles = listOf(AudioFile(id = "a", index = 0, filename = "a.mp3")), + chapters = listOf(Chapter(id = 1, start = 0.0, end = 5.0, title = "One")), + ) + assertEquals(mergeSyncedBook(remote, local), mergeSyncedBook(remote, local.toSyncMergeState(), detail = null)) + } + private fun downloadedDetailedBook() = AudioBook( id = "1", title = "Downloaded title", diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ForegroundSyncPolicyTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundSyncPolicyTest.kt new file mode 100644 index 00000000..6c5b92bb --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundSyncPolicyTest.kt @@ -0,0 +1,98 @@ +package com.ninelivesaudio.app.service + +import com.ninelivesaudio.app.domain.model.Library +import com.ninelivesaudio.app.domain.model.SyncResult +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The sync timer runs only in the foreground: a check on every entry (unless + * one just ran) and every 15 minutes after. A check that found nothing writes + * nothing, and one that failed never claims success. + */ +class ForegroundSyncPolicyTest { + + private val minute = 60_000L + + // ─── Foreground timer ───────────────────────────────────────────────── + + @Test + fun `the first foreground entry checks right away`() { + assertEquals(0L, delayBeforeEntryCheck(lastCheckAtMs = null, nowMs = 5_000L)) + } + + @Test + fun `an entry within two minutes of the last check waits out the interval`() { + assertEquals(14 * minute, delayBeforeEntryCheck(lastCheckAtMs = 0L, nowMs = 1 * minute)) + assertEquals(13 * minute + 1, delayBeforeEntryCheck(lastCheckAtMs = 0L, nowMs = 2 * minute - 1)) + } + + @Test + fun `an entry two minutes or more after the last check checks right away`() { + assertEquals(0L, delayBeforeEntryCheck(lastCheckAtMs = 0L, nowMs = 2 * minute)) + assertEquals(0L, delayBeforeEntryCheck(lastCheckAtMs = 0L, nowMs = 90 * minute)) + } + + @Test + fun `a clock reading before the last check checks right away`() { + assertEquals(0L, delayBeforeEntryCheck(lastCheckAtMs = 10 * minute, nowMs = 5 * minute)) + assertTrue(isCheckDue(lastCheckAtMs = 10 * minute, nowMs = 5 * minute)) + } + + @Test + fun `a tick or a server return skips a check when a sync just ran`() { + assertTrue(isCheckDue(lastCheckAtMs = null, nowMs = 0L)) + assertFalse(isCheckDue(lastCheckAtMs = 0L, nowMs = 2 * minute - 1)) + assertTrue(isCheckDue(lastCheckAtMs = 0L, nowMs = 2 * minute)) + } + + // ─── Check records ──────────────────────────────────────────────────── + + @Test + fun `a check that found nothing writes no record over a success`() { + assertFalse(shouldPersistCheckReport(SyncResult.SUCCESS, CheckTally(), previousResult = SyncResult.SUCCESS)) + } + + @Test + fun `a clean check heals a failure banner or a missing record`() { + assertTrue(shouldPersistCheckReport(SyncResult.SUCCESS, CheckTally(), previousResult = SyncResult.FAILED)) + assertTrue(shouldPersistCheckReport(SyncResult.SUCCESS, CheckTally(), previousResult = null)) + } + + @Test + fun `a failed check is written as the failure it is`() { + assertTrue(shouldPersistCheckReport(SyncResult.FAILED, CheckTally(), previousResult = SyncResult.SUCCESS)) + assertTrue(shouldPersistCheckReport(SyncResult.PARTIAL, CheckTally(anyDeferred = true), previousResult = SyncResult.SUCCESS)) + } + + @Test + fun `a deferred full download never claims success`() { + assertFalse(shouldPersistCheckReport(SyncResult.SUCCESS, CheckTally(anyDeferred = true), previousResult = SyncResult.FAILED)) + assertFalse( + shouldPersistCheckReport(SyncResult.SUCCESS, CheckTally(anyDeferred = true, wroteBooks = true), SyncResult.SUCCESS), + ) + } + + @Test + fun `saved books or a changed library list are written so the Library re-reads`() { + assertTrue(shouldPersistCheckReport(SyncResult.SUCCESS, CheckTally(wroteBooks = true), SyncResult.SUCCESS)) + assertTrue(shouldPersistCheckReport(SyncResult.SUCCESS, CheckTally(libraryListChanged = true), SyncResult.SUCCESS)) + } + + @Test + fun `a library list change means an added, removed, or renamed library`() { + val books = Library(id = "a", name = "Books") + assertFalse(libraryListChanged(listOf(books), listOf(books.copy(displayOrder = 9)))) + assertTrue(libraryListChanged(listOf(books), listOf(books.copy(name = "Audiobooks")))) + assertTrue(libraryListChanged(listOf(books), listOf(books, Library(id = "b", name = "More")))) + assertTrue(libraryListChanged(listOf(books), emptyList())) + } + + @Test + fun `background syncs skip podcast libraries`() { + assertTrue(isSyncedLibrary(Library(id = "a", mediaType = "book"))) + assertFalse(isSyncedLibrary(Library(id = "p", mediaType = "podcast"))) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/PeriodicItemSyncTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/PeriodicItemSyncTest.kt deleted file mode 100644 index cb06042d..00000000 --- a/app/src/test/java/com/ninelivesaudio/app/service/PeriodicItemSyncTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.ninelivesaudio.app.service - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * The 5-minute timer used to re-download every library's book list on every - * tick. A big library on a slow home server paid tens of megabytes each - * time. The timer now pulls progress every tick and the full list at most - * hourly. - */ -class PeriodicItemSyncTest { - - private val server = "https://abs.example.net" - private val minute = 60_000L - - @Test - fun `no full sync yet means the tick runs one`() { - assertTrue(shouldRefreshItemList(lastFullSync = null, serverUrl = server, nowElapsedMs = 0L)) - } - - @Test - fun `a full sync inside the hour leaves the tick to progress only`() { - val stamp = FullItemSyncStamp(server, atElapsedMs = 1_000L) - assertFalse(shouldRefreshItemList(stamp, server, nowElapsedMs = 1_000L + 5 * minute)) - assertFalse(shouldRefreshItemList(stamp, server, nowElapsedMs = 1_000L + 59 * minute)) - assertFalse(shouldRefreshItemList(stamp, server, nowElapsedMs = 1_000L + 60 * minute - 1)) - } - - @Test - fun `an hour after the last full sync the tick runs another`() { - val stamp = FullItemSyncStamp(server, atElapsedMs = 1_000L) - assertTrue(shouldRefreshItemList(stamp, server, nowElapsedMs = 1_000L + 60 * minute)) - assertTrue(shouldRefreshItemList(stamp, server, nowElapsedMs = 1_000L + 65 * minute)) - } - - @Test - fun `a full sync of another server does not count for this one`() { - val stamp = FullItemSyncStamp("https://old.example.net", atElapsedMs = 1_000L) - assertTrue(shouldRefreshItemList(stamp, server, nowElapsedMs = 1_000L + 5 * minute)) - } - - @Test - fun `a clock that went backward counts as due`() { - val stamp = FullItemSyncStamp(server, atElapsedMs = 10 * minute) - assertTrue(shouldRefreshItemList(stamp, server, nowElapsedMs = 5 * minute)) - } -} From 6cc53334453b4a4ebf15c47be0de3b971d820d1c Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 22:53:02 -0400 Subject: [PATCH 16/69] A progress pull skips what already matches and stops fetching books one by one Every pull read each book's whole row, took the queue lock, opened a transaction, and rewrote the whole row (audio file and chapter lists included) for every progress record the server returned, even when nothing moved. On a fresh install each book with progress that the cache had not seen yet also cost its own request, one after another, before the library sync could start. Local positions and shelf progress are now read in batches of 500 up front, a record that already matches both is skipped, and an import updates only the book's three progress columns. Unknown books are looked up one by one only for the 12 most recently listened, enough for Home's recently played row. The library sync brings the rest, and their progress rows still import. Co-Authored-By: Claude Opus 5.5 --- .../app/data/local/dao/AudioBookDao.kt | 5 ++ .../app/data/local/dao/PlaybackProgressDao.kt | 4 ++ .../app/data/local/entity/SyncRowStates.kt | 9 +++ .../app/service/ForegroundSyncPolicy.kt | 60 ++++++++++++++++- .../ninelivesaudio/app/service/SyncManager.kt | 66 +++++++++++++++---- .../app/service/ForegroundSyncPolicyTest.kt | 43 ++++++++++++ 6 files changed, 171 insertions(+), 16 deletions(-) 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 0af48ded..73b145ef 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 @@ -7,6 +7,7 @@ import androidx.room.Query import androidx.room.RawQuery import androidx.sqlite.db.SupportSQLiteQuery import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.data.local.entity.BookProgressState import com.ninelivesaudio.app.data.local.entity.SyncMergeState import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity @@ -51,6 +52,10 @@ interface AudioBookDao { ) suspend fun getSyncMergeStates(ids: List): List + /** Shelf progress for up to 500 IDs, for the progress pull's "anything changed?" check. */ + @Query("SELECT Id, CurrentTimeSeconds, Progress, IsFinished, DurationSeconds FROM AudioBooks WHERE Id IN (:ids)") + suspend fun getProgressStates(ids: List): List + /** How many SERVER books one library has cached, downloads included. */ @Query("SELECT COUNT(*) FROM AudioBooks WHERE LibraryId = :libraryId AND IsLocal = 0") suspend fun countServerBooksByLibrary(libraryId: String): Int diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/PlaybackProgressDao.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/PlaybackProgressDao.kt index 450841d0..b44e30ba 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/PlaybackProgressDao.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/PlaybackProgressDao.kt @@ -15,6 +15,10 @@ interface PlaybackProgressDao { @Query("SELECT * FROM PlaybackProgress WHERE AudioBookId = :audioBookId") suspend fun getByAudioBookId(audioBookId: String): PlaybackProgressEntity? + /** Saved positions for up to 500 books at once. */ + @Query("SELECT * FROM PlaybackProgress WHERE AudioBookId IN (:audioBookIds)") + suspend fun getByAudioBookIds(audioBookIds: List): List + @Query("SELECT PositionSeconds, IsFinished FROM PlaybackProgress WHERE AudioBookId = :audioBookId") suspend fun getPositionAndFinished(audioBookId: String): PositionResult? diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/SyncRowStates.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/SyncRowStates.kt index 980b2f77..531cfd74 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/SyncRowStates.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/SyncRowStates.kt @@ -18,3 +18,12 @@ data class SyncMergeState( @ColumnInfo(name = "IsFinished") val isFinished: Int = 0, @ColumnInfo(name = "ArchivedAt") val archivedAt: Long? = null, ) + +/** A cached book's shelf progress, for deciding whether a server progress pull changes anything. */ +data class BookProgressState( + @ColumnInfo(name = "Id") val id: String, + @ColumnInfo(name = "CurrentTimeSeconds") val currentTimeSeconds: Double = 0.0, + @ColumnInfo(name = "Progress") val progress: Double = 0.0, + @ColumnInfo(name = "IsFinished") val isFinished: Int = 0, + @ColumnInfo(name = "DurationSeconds") val durationSeconds: Double = 0.0, +) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/ForegroundSyncPolicy.kt b/app/src/main/java/com/ninelivesaudio/app/service/ForegroundSyncPolicy.kt index 9076b403..1926dc21 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/ForegroundSyncPolicy.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/ForegroundSyncPolicy.kt @@ -1,10 +1,14 @@ package com.ninelivesaudio.app.service +import com.ninelivesaudio.app.data.local.entity.BookProgressState +import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.domain.model.Library import com.ninelivesaudio.app.domain.model.SyncResult +import com.ninelivesaudio.app.domain.model.UserProgress +import kotlin.math.abs -// The pure rules behind SyncManager's foreground timer and its check -// records, pinned by ForegroundSyncPolicyTest. +// The pure rules behind SyncManager's foreground timer, its check records, +// and its progress pull, pinned by ForegroundSyncPolicyTest. /** A foreground entry inside this long after the last check does not check again. */ internal const val FOREGROUND_CHECK_DEBOUNCE_MS = 2 * 60_000L @@ -81,3 +85,55 @@ internal fun libraryListChanged(cached: List, fetched: List): * loads it on demand. */ internal fun isSyncedLibrary(library: Library): Boolean = library.mediaType == "book" + +/** How many books unknown to the cache a progress pull fetches one by one. */ +internal const val MAX_UNKNOWN_BOOK_FETCHES = 12 + +/** + * Which books a progress pull looks up one at a time because the cache has + * no row for them yet: the most recently listened first, at most [cap]. + * A fresh install's first pull used to send one request per book with + * progress, thousands in a row for a long-time listener, before the library + * sync could start. The handful kept here is enough for Home's recently + * played row, and the library sync brings the rest. + */ +internal fun unknownBooksToFetch( + progress: List, + knownIds: Set, + cap: Int = MAX_UNKNOWN_BOOK_FETCHES, +): Set = progress + .asSequence() + .filter { it.libraryItemId.isNotBlank() && it.libraryItemId !in knownIds } + .sortedByDescending { it.lastUpdate ?: Long.MIN_VALUE } + .map { it.libraryItemId } + .distinct() + .take(cap) + .toSet() + +private const val POSITION_EPSILON_SECONDS = 0.5 +private const val PROGRESS_EPSILON = 1e-6 + +/** + * Whether importing a server progress record would change nothing: the saved + * position row and the book's shelf fields already say the same thing. Every + * pull used to rewrite every book's whole row (audio file and chapter lists + * included) inside a transaction, even when nothing moved. + */ +internal fun serverProgressIsAlreadyLocal( + positionSeconds: Double, + progress: Double, + isFinished: Boolean, + updatedAt: String?, + localRow: PlaybackProgressEntity?, + localBook: BookProgressState?, +): Boolean { + if (localRow == null) return false + val rowMatches = abs(localRow.positionSeconds - positionSeconds) < POSITION_EPSILON_SECONDS && + (localRow.isFinished == 1) == isFinished && + localRow.updatedAt == updatedAt + if (!rowMatches) return false + if (localBook == null) return true + return abs(localBook.currentTimeSeconds - positionSeconds) < POSITION_EPSILON_SECONDS && + abs(localBook.progress - progress) < PROGRESS_EPSILON && + (localBook.isFinished == 1) == isFinished +} diff --git a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt index 140770ba..3537592b 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt @@ -7,6 +7,8 @@ import android.util.Log import com.ninelivesaudio.app.data.local.converter.toDomain import com.ninelivesaudio.app.data.local.converter.toEntity import com.ninelivesaudio.app.data.local.dao.AudioBookDao +import com.ninelivesaudio.app.data.local.dao.PlaybackProgressDao +import com.ninelivesaudio.app.data.local.entity.BookProgressState import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.data.repository.AudioBookRepository import com.ninelivesaudio.app.data.repository.LibraryRepository @@ -32,6 +34,7 @@ private const val TAG = "SyncManager" private const val MIN_POSITION_SYNC_INTERVAL_MS = 30_000L private const val MIN_POSITION_DELTA = 2.0 private const val MIN_PROGRESS_DELTA = 0.01 +private const val PROGRESS_LOOKUP_CHUNK = 500 /** A full sync of every library, or the change check that does the least work needed. */ internal enum class SyncMode { FULL, CHECK } @@ -62,6 +65,7 @@ class SyncManager @Inject constructor( private val audioBookDao: AudioBookDao, private val connectivityMonitor: ConnectivityMonitor, private val settingsManager: SettingsManager, + private val playbackProgressDao: PlaybackProgressDao, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -401,6 +405,12 @@ class SyncManager @Inject constructor( /** * Sync progress from the server. * Server is source of truth (except for actively playing items). + * + * Local state is read in batches up front, a record that already matches + * it is skipped, and an import updates only the book's progress columns. + * Books the cache has never seen are looked up one by one only for the + * most recently listened few (see [unknownBooksToFetch]). The library + * sync brings the rest, and their progress rows import either way. */ private suspend fun syncProgress() { try { @@ -408,17 +418,33 @@ class SyncManager @Inject constructor( val serverProgressList = progressRepository.fetchAllProgressFromServer() if (serverProgressList.isEmpty()) return + val ids = serverProgressList.map { it.libraryItemId }.distinct() + val books = HashMap() + val rows = HashMap() + for (chunk in ids.chunked(PROGRESS_LOOKUP_CHUNK)) { + audioBookDao.getProgressStates(chunk).associateByTo(books) { it.id } + playbackProgressDao.getByAudioBookIds(chunk).associateByTo(rows) { it.audioBookId } + } + val fetchIndividually = unknownBooksToFetch(serverProgressList, books.keys) + for (progress in serverProgressList) { // Active playback and queued local writes own progress. In // particular, never compare server and phone wall clocks to // decide whether an unsent offline write survives. try { - var book = audioBookDao.getById(progress.libraryItemId) - if (book == null) { - val remoteBook = audioBookRepository.fetchFromServer(progress.libraryItemId) + val itemId = progress.libraryItemId + var book = books[itemId] + if (book == null && itemId in fetchIndividually) { + val remoteBook = audioBookRepository.fetchFromServer(itemId) if (remoteBook != null) { audioBookDao.upsert(remoteBook.toEntity()) - book = audioBookDao.getById(progress.libraryItemId) + book = BookProgressState( + id = itemId, + currentTimeSeconds = remoteBook.currentTime.inWholeMilliseconds / 1000.0, + progress = remoteBook.progress, + isFinished = if (remoteBook.isFinished) 1 else 0, + durationSeconds = remoteBook.duration.inWholeMilliseconds / 1000.0, + ).also { books[itemId] = it } } } @@ -433,35 +459,47 @@ class SyncManager @Inject constructor( } val updatedAtStr = progress.lastUpdate?.toIso8601() + if (serverProgressIsAlreadyLocal( + positionSeconds = positionSeconds, + progress = progress.progress, + isFinished = progress.isFinished, + updatedAt = updatedAtStr, + localRow = rows[itemId], + localBook = book, + ) + ) continue // The repository checks the queue while holding the same // per-book ownership used by enqueue and flush. A pending // local write wins regardless of server clock skew. - val imported = progressRepository.importServerProgressIfNoPending( + val bookKnown = book != null + progressRepository.importServerProgressIfNoPending( PlaybackProgressEntity( - audioBookId = progress.libraryItemId, + audioBookId = itemId, positionSeconds = positionSeconds, isFinished = if (progress.isFinished) 1 else 0, updatedAt = updatedAtStr, ), importToken = importToken, onImported = { - if (book != null) { - audioBookDao.upsert( - book.copy( - currentTimeSeconds = positionSeconds, - progress = progress.progress, - isFinished = if (progress.isFinished) 1 else 0, - ) + if (bookKnown) { + audioBookDao.updateProgress( + id = itemId, + currentTimeSeconds = positionSeconds, + progress = progress.progress, + isFinished = if (progress.isFinished) 1 else 0, ) } }, ) - if (!imported) continue + } catch (e: CancellationException) { + throw e } catch (_: Exception) { // Continue with next item } } + } catch (e: CancellationException) { + throw e } catch (_: Exception) { // Non-fatal } diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ForegroundSyncPolicyTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundSyncPolicyTest.kt index 6c5b92bb..522daa58 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/ForegroundSyncPolicyTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundSyncPolicyTest.kt @@ -1,11 +1,15 @@ package com.ninelivesaudio.app.service +import com.ninelivesaudio.app.data.local.entity.BookProgressState +import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.domain.model.Library import com.ninelivesaudio.app.domain.model.SyncResult +import com.ninelivesaudio.app.domain.model.UserProgress import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import kotlin.time.Duration.Companion.seconds /** * The sync timer runs only in the foreground: a check on every entry (unless @@ -95,4 +99,43 @@ class ForegroundSyncPolicyTest { assertTrue(isSyncedLibrary(Library(id = "a", mediaType = "book"))) assertFalse(isSyncedLibrary(Library(id = "p", mediaType = "podcast"))) } + + // ─── Progress pull ──────────────────────────────────────────────────── + + @Test + fun `only the most recently listened unknown books are fetched one by one`() { + val progress = (1..40).map { i -> UserProgress(libraryItemId = "li_$i", lastUpdate = i.toLong()) } + + UserProgress(libraryItemId = "li_known", lastUpdate = 1_000L) + val picked = unknownBooksToFetch(progress, knownIds = setOf("li_known"), cap = 3) + assertEquals(setOf("li_40", "li_39", "li_38"), picked) + } + + @Test + fun `a progress record that matches the cache is skipped`() { + val row = PlaybackProgressEntity(audioBookId = "a", positionSeconds = 120.0, isFinished = 0, updatedAt = "t1") + val book = BookProgressState(id = "a", currentTimeSeconds = 120.2, progress = 0.25, isFinished = 0) + assertTrue(serverProgressIsAlreadyLocal(120.0, 0.25, false, "t1", row, book)) + assertTrue(serverProgressIsAlreadyLocal(120.0, 0.25, false, "t1", row, localBook = null)) + } + + @Test + fun `a progress record that moved anything is imported`() { + val row = PlaybackProgressEntity(audioBookId = "a", positionSeconds = 120.0, isFinished = 0, updatedAt = "t1") + val book = BookProgressState(id = "a", currentTimeSeconds = 120.0, progress = 0.25, isFinished = 0) + assertFalse(serverProgressIsAlreadyLocal(300.0, 0.25, false, "t1", row, book)) + assertFalse(serverProgressIsAlreadyLocal(120.0, 0.25, true, "t1", row, book)) + assertFalse(serverProgressIsAlreadyLocal(120.0, 0.25, false, "t2", row, book)) + assertFalse(serverProgressIsAlreadyLocal(120.0, 0.30, false, "t1", row, book)) + assertFalse(serverProgressIsAlreadyLocal(120.0, 0.25, false, "t1", localRow = null, localBook = book)) + } + + @Test + fun `unknown ids exclude blanks and repeats`() { + val progress = listOf( + UserProgress(libraryItemId = "", currentTime = 1.seconds), + UserProgress(libraryItemId = "x", lastUpdate = 2L), + UserProgress(libraryItemId = "x", lastUpdate = 1L), + ) + assertEquals(setOf("x"), unknownBooksToFetch(progress, knownIds = emptySet())) + } } From 7e16565ec636bfe3596e9bb0b487d7b1c4de98ab Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 22:54:02 -0400 Subject: [PATCH 17/69] The added-since fetch reaches back one minute, not five The overlap before the stored newest book was five minutes. A bulk import adds hundreds of books a few seconds apart, and every book inside the window is downloaded again on each check that finds something new, which on a test shelf of books one second apart meant 13 pages for 3 new books. The overlap only saves a full download in a rare commit-order race, and the count check catches any book it misses, so one minute is plenty. Co-Authored-By: Claude Opus 5.5 --- .../app/data/repository/LibrarySyncPlanner.kt | 12 ++++++++---- .../app/data/repository/LibrarySyncPlannerTest.kt | 4 ++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt index 56354edf..4b1cba8b 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt @@ -20,11 +20,15 @@ internal const val FULL_REFRESH_MAX_AGE_MS = 24 * 60 * 60_000L /** * How far before the stored newest book an "added since" fetch reaches back. - * Books that land in the same moment, or a server clock that ticks coarsely, - * can put a book we never saw just under the newest one we did. Re-fetching - * that sliver is cheap and idempotent. + * A server scan can stamp a book's added time a moment before another book + * that commits first, so a sync can see the later one and not the earlier. + * Re-fetching that sliver is idempotent. It is only an optimisation: a book + * missed anyway shows up in the count check and triggers a full download. + * Kept short because a bulk import adds hundreds of books a few seconds + * apart, and every one inside the window is downloaded again on each check + * that finds something new. */ -internal const val INCREMENTAL_OVERLAP_MS = 5 * 60_000L +internal const val INCREMENTAL_OVERLAP_MS = 60_000L /** * Page size for the "added since" fetch. Smaller than the full download's 100 diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt index eb09f320..1967236d 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt @@ -138,8 +138,8 @@ class LibrarySyncPlannerTest { // ─── The added-since fetch and its deletion check ───────────────────── @Test - fun `the cutoff reaches five minutes before the stored newest book`() { - assertEquals(5_000L - INCREMENTAL_OVERLAP_MS, incrementalCutoff(watermark(newestAddedAt = 5_000L))) + fun `the cutoff reaches one minute before the stored newest book`() { + assertEquals(5_000L - 60_000L, incrementalCutoff(watermark(newestAddedAt = 5_000L))) assertEquals(Long.MIN_VALUE, incrementalCutoff(watermark(newestAddedAt = null))) } From 13ec5666c95b261a83ae5ab7e3e1ba511dfcbbeb Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 22:54:52 -0400 Subject: [PATCH 18/69] Finished downloads keep their track list and chapters A library sync stores books with no track list and no chapters, and a finished download copied that empty row back with only the downloaded flag and folder set. Offline playback then sorted the files by name, so "Chapter 10.mp3" played before "Chapter 2.mp3", and the player had no chapters. The details fetch before a download now keeps the server's chapters as well as its tracks, and completion saves both into the book row. The track list that was downloaded always wins because it names the files on disk. An empty chapter list never wipes chapters the row had. Co-Authored-By: Claude Opus 5.5 --- .../app/service/download/DownloadEngine.kt | 50 ++++++- .../DownloadCompletionManifestTest.kt | 125 ++++++++++++++++++ 2 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/download/DownloadCompletionManifestTest.kt 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 4f93dca3..df9d413a 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 @@ -10,8 +10,10 @@ import com.ninelivesaudio.app.data.local.dao.AudioBookDao import com.ninelivesaudio.app.data.local.dao.DownloadItemDao import com.ninelivesaudio.app.data.remote.AudiobookshelfApi import com.ninelivesaudio.app.data.remote.dto.ApiAudioFile +import com.ninelivesaudio.app.data.remote.dto.ApiChapter import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.AudioFile +import com.ninelivesaudio.app.domain.model.Chapter import com.ninelivesaudio.app.domain.model.DownloadItem import com.ninelivesaudio.app.domain.model.DownloadStatus import com.ninelivesaudio.app.service.SettingsManager @@ -228,15 +230,18 @@ class DownloadEngine @Inject constructor( // a cover failure must never fail an otherwise-complete download. val localCoverUri = persistCover(book, downloadDir) - // Update audiobook as downloaded with local path + // 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( - bookEntity.copy( - isDownloaded = 1, + completedDownloadBook( + row = bookEntity.toDomain(), + downloaded = book, localPath = downloadDir.absolutePath, - localCoverPath = localCoverUri ?: bookEntity.localCoverPath, - ) + localCoverUri = localCoverUri, + ).toEntity() ) } @@ -269,15 +274,18 @@ class DownloadEngine @Inject constructor( } } - /** Fetch full book details (audio file metadata) from the server. */ + /** Fetch full book details (audio files and chapters) from the server. */ suspend fun fetchFullBookDetails(audioBookId: String): AudioBook? { return try { val response = api.getItem(audioBookId, expanded = 1) if (response.isSuccessful) { response.body()?.let { apiItem -> val audioFiles = toDomainAudioFiles(apiItem.media?.audioFiles.orEmpty()) + val chapters = toDomainChapters(apiItem.media?.chapters.orEmpty()) - audioBookDao.getById(audioBookId)?.toDomain()?.copy(audioFiles = audioFiles) + audioBookDao.getById(audioBookId)?.toDomain()?.let { row -> + row.copy(audioFiles = audioFiles, chapters = chapters.ifEmpty { row.chapters }) + } } } else null } catch (cancellation: CancellationException) { @@ -357,3 +365,31 @@ internal fun toDomainAudioFiles(files: List): List = size = af.metadata?.size ?: 0, ) } + +/** + * Map the expanded item response's chapters onto the domain model, with the same + * filtering and blank-title fallback as the item mapping in ApiService. + */ +internal fun toDomainChapters(chapters: List): List = + chapters + .filter { c -> c.start >= 0.0 && c.end > c.start } + .map { c -> Chapter(id = c.id, start = c.start, end = c.end, title = c.title.ifBlank { "Chapter ${c.id}" }) } + +/** + * The book row to save when a download finishes. The track list that was just + * downloaded names the files on disk, so it replaces whatever the row held + * (usually the empty list a library sync leaves). Chapters come along when the + * download has them. An empty list never wipes what the row already had. + */ +internal fun completedDownloadBook( + row: AudioBook, + downloaded: AudioBook, + localPath: String, + localCoverUri: String?, +): AudioBook = row.copy( + isDownloaded = true, + localPath = localPath, + localCoverPath = localCoverUri ?: row.localCoverPath, + audioFiles = downloaded.audioFiles.ifEmpty { row.audioFiles }, + chapters = downloaded.chapters.ifEmpty { row.chapters }, +) diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadCompletionManifestTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadCompletionManifestTest.kt new file mode 100644 index 00000000..4acb8892 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadCompletionManifestTest.kt @@ -0,0 +1,125 @@ +package com.ninelivesaudio.app.service.download + +import com.ninelivesaudio.app.data.local.converter.toDomain +import com.ninelivesaudio.app.data.local.converter.toEntity +import com.ninelivesaudio.app.data.remote.dto.ApiChapter +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.AudioFile +import com.ninelivesaudio.app.domain.model.Chapter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +/** + * A finished download has to leave its track list and chapters in the book row. + * Library sync stores an empty manifest, and without one offline playback sorts + * the files by name, so "Chapter 10.mp3" plays before "Chapter 2.mp3". + */ +class DownloadCompletionManifestTest { + + private val tracks = listOf( + AudioFile(id = "a", ino = "a", index = 0, duration = 60.seconds, filename = "Chapter 2.mp3"), + AudioFile(id = "b", ino = "b", index = 1, duration = 60.seconds, filename = "Chapter 10.mp3"), + ) + private val chapters = listOf( + Chapter(id = 0, start = 0.0, end = 60.0, title = "Two"), + Chapter(id = 1, start = 60.0, end = 120.0, title = "Ten"), + ) + + /** What a library sync leaves behind: no tracks, no chapters. */ + private val syncedRow = AudioBook(id = "book", title = "Book", author = "Author", currentTime = 42.seconds) + + @Test + fun `completion saves the downloaded tracks and chapters over an empty row`() { + val saved = completedDownloadBook( + row = syncedRow, + downloaded = syncedRow.copy(audioFiles = tracks, chapters = chapters), + localPath = "/downloads/Author - Book", + localCoverUri = "file:///downloads/Author - Book/cover.jpg", + ) + + assertEquals(tracks, saved.audioFiles) + assertEquals(chapters, saved.chapters) + assertTrue(saved.isDownloaded) + assertEquals("/downloads/Author - Book", saved.localPath) + assertEquals("file:///downloads/Author - Book/cover.jpg", saved.localCoverPath) + } + + @Test + fun `completion keeps the row's own progress`() { + val saved = completedDownloadBook( + row = syncedRow, + downloaded = syncedRow.copy(currentTime = 0.seconds, audioFiles = tracks), + localPath = "/d", + localCoverUri = null, + ) + + assertEquals(42.seconds, saved.currentTime) + } + + @Test + fun `a download with no chapters leaves the row's chapters alone`() { + val saved = completedDownloadBook( + row = syncedRow.copy(chapters = chapters), + downloaded = syncedRow.copy(audioFiles = tracks), + localPath = "/d", + localCoverUri = null, + ) + + assertEquals(chapters, saved.chapters) + } + + @Test + fun `a failed cover save keeps the cover the row already had`() { + val saved = completedDownloadBook( + row = syncedRow.copy(localCoverPath = "file:///old/cover.jpg"), + downloaded = syncedRow.copy(audioFiles = tracks), + localPath = "/d", + localCoverUri = null, + ) + + assertEquals("file:///old/cover.jpg", saved.localCoverPath) + } + + @Test + fun `the saved manifest plays in server order offline, not name order`() { + val saved = completedDownloadBook( + row = syncedRow, + downloaded = syncedRow.copy(audioFiles = tracks.reversed()), + localPath = "/d", + localCoverUri = null, + ) + // Through the Room column and back, the way PlaybackManager reads it. + val reloaded = saved.toEntity().toDomain() + val ordered = reloaded.audioFiles.sortedBy { it.index } + + assertEquals(listOf("Chapter 2.mp3", "Chapter 10.mp3"), resolveDownloadFileNames(ordered)) + } + + @Test + fun `saved chapters survive the Room column`() { + val saved = completedDownloadBook( + row = syncedRow, + downloaded = syncedRow.copy(audioFiles = tracks, chapters = chapters), + localPath = "/d", + localCoverUri = null, + ) + + assertEquals(chapters, saved.toEntity().toDomain().chapters) + } + + @Test + fun `server chapters map with the same filter and fallback title as the item mapping`() { + val mapped = toDomainChapters( + listOf( + ApiChapter(id = 0, start = 0.0, end = 10.0, title = "Opening"), + ApiChapter(id = 1, start = 10.0, end = 10.0, title = "Empty"), + ApiChapter(id = 2, start = -1.0, end = 5.0, title = "Negative"), + ApiChapter(id = 3, start = 10.0, end = 20.0, title = " "), + ), + ) + + assertEquals(listOf("Opening", "Chapter 3"), mapped.map { it.title }) + } +} From 1d0653401cff4ec026b6794aecdfa6892674db22 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 22:57:42 -0400 Subject: [PATCH 19/69] Downloads reads its rows and covers in one query The Downloads screen looked up each row's book one at a time, for both lists, every time any download row changed. A running download writes its progress about once a second, so 500 downloads meant 500 lookups per tick. One joined query now brings every row with its book's cover, the lists are split in Kotlin, and an unchanged result never reaches the screen. Rows whose book left the database still drop out. Co-Authored-By: Claude Opus 5.5 --- .../app/data/local/dao/DownloadItemDao.kt | 27 ++++--- .../data/local/entity/DownloadRowWithBook.kt | 21 +++++ .../app/ui/downloads/DownloadLists.kt | 40 ++++++++++ .../app/ui/downloads/DownloadsViewModel.kt | 79 ++++++++----------- .../app/ui/downloads/DownloadListsTest.kt | 57 +++++++++++++ 5 files changed, 166 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/data/local/entity/DownloadRowWithBook.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadLists.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/downloads/DownloadListsTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/DownloadItemDao.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/DownloadItemDao.kt index f8ef5f21..fb67cc0a 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/DownloadItemDao.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/DownloadItemDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.ninelivesaudio.app.data.local.entity.DownloadItemEntity +import com.ninelivesaudio.app.data.local.entity.DownloadRowWithBook import kotlinx.coroutines.flow.Flow @Dao @@ -32,15 +33,21 @@ interface DownloadItemDao { suspend fun deleteAll() /** - * Active downloads. Status 0=Queued, 1=Downloading, 2=Paused, 6=Preparing. - * - * Preparing is included so a provisional slot claim stays VISIBLE while its - * metadata fetch is in flight. Leaving it out would make a book the user - * just asked for vanish from the Downloads screen for the length of a - * network round trip, which reads as the tap having done nothing. + * Every download row whose book is still cached, with that book's cover and + * downloaded flag, for the Downloads screen. Rows for a book that left the + * database drop out, as they always have. Which rows land in the active and + * completed lists is decided in Kotlin by `splitDownloadRows`. */ - @Query("SELECT * FROM DownloadItems WHERE Status IN (0, 1, 2, 6) ORDER BY StartedAt DESC") - fun observeActive(): Flow> + @Query( + """ + SELECT d.*, + COALESCE(ab.LocalCoverPath, ab.CoverPath) AS BookCoverPath, + ab.IsDownloaded AS BookIsDownloaded + FROM DownloadItems d + INNER JOIN AudioBooks ab ON ab.Id = d.AudioBookId + """ + ) + fun observeAllWithBooks(): Flow> /** * Downloadable items for the drain worker: Queued (0) or interrupted @@ -53,8 +60,4 @@ interface DownloadItemDao { */ @Query("SELECT * FROM DownloadItems WHERE Status IN (0, 1) ORDER BY StartedAt ASC") suspend fun getDownloadable(): List - - /** Get completed downloads. Status 3=Completed */ - @Query("SELECT * FROM DownloadItems WHERE Status = 3 ORDER BY CompletedAt DESC") - fun observeCompleted(): Flow> } diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/DownloadRowWithBook.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/DownloadRowWithBook.kt new file mode 100644 index 00000000..c15bad8c --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/DownloadRowWithBook.kt @@ -0,0 +1,21 @@ +package com.ninelivesaudio.app.data.local.entity + +import androidx.room.ColumnInfo +import androidx.room.Embedded + +/** + * A download row joined with the two things the Downloads screen needs from its + * book: the cover to show and whether the book is already on the device. One + * query instead of a book lookup per row on every progress tick. + */ +data class DownloadRowWithBook( + @Embedded + val download: DownloadItemEntity, + + /** The book's saved cover when it has one, otherwise its server cover. */ + @ColumnInfo(name = "BookCoverPath") + val coverPath: String?, + + @ColumnInfo(name = "BookIsDownloaded") + val bookIsDownloaded: Int, +) diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadLists.kt b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadLists.kt new file mode 100644 index 00000000..56b38926 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadLists.kt @@ -0,0 +1,40 @@ +package com.ninelivesaudio.app.ui.downloads + +import com.ninelivesaudio.app.domain.model.DownloadItem +import com.ninelivesaudio.app.domain.model.DownloadStatus + +/** One download row with what the screen needs from its book. */ +internal data class DownloadRow( + val download: DownloadItem, + val coverPath: String?, + val bookIsDownloaded: Boolean, +) + +/** The Downloads screen's two lists, newest first. */ +internal data class DownloadLists( + val active: List, + val completed: List, +) + +private val IN_PROGRESS = setOf( + DownloadStatus.Queued, + DownloadStatus.Downloading, + DownloadStatus.Paused, + DownloadStatus.Preparing, +) + +/** + * Sort every download row into the active and completed lists. + * + * Active holds Queued, Downloading, Paused and Preparing (a slot claim waiting + * on its details fetch, kept visible so the tap never looks ignored). + */ +internal fun splitDownloadRows(rows: List): DownloadLists { + val active = rows + .filter { it.download.status in IN_PROGRESS } + .sortedByDescending { it.download.startedAt ?: 0L } + val completed = rows + .filter { it.download.status == DownloadStatus.Completed } + .sortedByDescending { it.download.completedAt ?: 0L } + return DownloadLists(active = active, completed = completed) +} 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 1e224c11..be140b4d 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 @@ -2,14 +2,11 @@ package com.ninelivesaudio.app.ui.downloads import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ninelivesaudio.app.data.local.converter.effectiveCoverPath import com.ninelivesaudio.app.data.local.converter.toDomain -import com.ninelivesaudio.app.data.local.dao.AudioBookDao import com.ninelivesaudio.app.data.local.dao.DownloadItemDao import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.DownloadItem -import com.ninelivesaudio.app.domain.model.DownloadStatus import com.ninelivesaudio.app.service.ConnectivityMonitor import com.ninelivesaudio.app.service.ConnectivityMonitor.ConnectionStatus import com.ninelivesaudio.app.service.DownloadManager @@ -23,7 +20,6 @@ import javax.inject.Inject class DownloadsViewModel @Inject constructor( private val downloadManager: DownloadManager, private val downloadItemDao: DownloadItemDao, - private val audioBookDao: AudioBookDao, private val connectivityMonitor: ConnectivityMonitor, private val settingsManager: SettingsManager, ) : ViewModel() { @@ -63,52 +59,43 @@ class DownloadsViewModel @Inject constructor( } init { - // Observe active downloads + // One joined query feeds both lists. It re-runs on every download + // progress write and every book row write (playback saves position + // twice a second), so unchanged lists are dropped before they reach + // the screen. viewModelScope.launch { - downloadItemDao.observeActive().collect { entities -> - val items = entities.mapNotNull { entity -> - val item = entity.toDomain() - val book = audioBookDao.getById(item.audioBookId) - // Skip items where book was deleted from DB - if (book == null) return@mapNotNull null - DownloadUiItem( - download = item, - coverPath = book.effectiveCoverPath, - ).withLiveProgress() - } - // Drop live entries for downloads that are no longer active so the - // overlay map cannot grow without bound. - val activeIds = items.mapTo(HashSet()) { it.download.id } - liveProgress = liveProgress.filterKeys { it in activeIds } - _uiState.update { - it.copy( - activeDownloads = items, - showEmptyState = items.isEmpty() && it.completedDownloads.isEmpty(), - ) - } - } - } - - // Observe completed downloads - viewModelScope.launch { - downloadItemDao.observeCompleted().collect { entities -> - val items = entities.mapNotNull { entity -> - val item = entity.toDomain() - val book = audioBookDao.getById(item.audioBookId) - // Skip items where book was deleted from DB - if (book == null) return@mapNotNull null - DownloadUiItem( - download = item, - coverPath = book.effectiveCoverPath, + downloadItemDao.observeAllWithBooks() + .map { rows -> + splitDownloadRows( + rows.map { row -> + DownloadRow( + download = row.download.toDomain(), + coverPath = row.coverPath, + bookIsDownloaded = row.bookIsDownloaded == 1, + ) + } ) } - _uiState.update { - it.copy( - completedDownloads = items, - showEmptyState = items.isEmpty() && it.activeDownloads.isEmpty(), - ) + .distinctUntilChanged() + .collect { lists -> + val active = lists.active.map { + DownloadUiItem(download = it.download, coverPath = it.coverPath).withLiveProgress() + } + val completed = lists.completed.map { + DownloadUiItem(download = it.download, coverPath = it.coverPath) + } + // Drop live entries for downloads that are no longer active so the + // overlay map cannot grow without bound. + val activeIds = active.mapTo(HashSet()) { it.download.id } + liveProgress = liveProgress.filterKeys { it in activeIds } + _uiState.update { + it.copy( + activeDownloads = active, + completedDownloads = completed, + showEmptyState = active.isEmpty() && completed.isEmpty(), + ) + } } - } } // Observe progress updates diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/downloads/DownloadListsTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/downloads/DownloadListsTest.kt new file mode 100644 index 00000000..25abe622 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/downloads/DownloadListsTest.kt @@ -0,0 +1,57 @@ +package com.ninelivesaudio.app.ui.downloads + +import com.ninelivesaudio.app.domain.model.DownloadItem +import com.ninelivesaudio.app.domain.model.DownloadStatus +import org.junit.Assert.assertEquals +import org.junit.Test + +/** The Downloads screen's lists come from one joined query, split here. */ +class DownloadListsTest { + + private fun row( + id: String, + status: DownloadStatus, + bookId: String = "book-$id", + startedAt: Long = 0L, + completedAt: Long? = null, + bookIsDownloaded: Boolean = false, + ) = DownloadRow( + download = DownloadItem( + id = id, + audioBookId = bookId, + title = id, + status = status, + startedAt = startedAt, + completedAt = completedAt, + ), + coverPath = "cover-$id", + bookIsDownloaded = bookIsDownloaded, + ) + + private fun DownloadLists.activeIds() = active.map { it.download.id } + private fun DownloadLists.completedIds() = completed.map { it.download.id } + + @Test + fun `in progress statuses stay active and completed goes to completed`() { + val lists = splitDownloadRows( + listOf( + row("q", DownloadStatus.Queued, startedAt = 4), + row("d", DownloadStatus.Downloading, startedAt = 3), + row("p", DownloadStatus.Paused, startedAt = 2), + row("prep", DownloadStatus.Preparing, startedAt = 1), + row("c", DownloadStatus.Completed, completedAt = 9), + row("x", DownloadStatus.Cancelled), + ), + ) + + assertEquals(listOf("q", "d", "p", "prep"), lists.activeIds()) + assertEquals(listOf("c"), lists.completedIds()) + } + + @Test + fun `the row's cover comes along`() { + val lists = splitDownloadRows(listOf(row("q", DownloadStatus.Queued))) + + assertEquals("cover-q", lists.active.single().coverPath) + } +} From 92cbe9b3b023af5696dfb887e240e6c663ae9835 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 22:57:49 -0400 Subject: [PATCH 20/69] Failed downloads stay on the Downloads screen with Retry A download that ran out of retries, a slow server timing out for example, was in neither the active nor the completed list, so the book just vanished and the Failed badge and Retry button never showed. Failed rows now sit in the active list with their error and a Retry button. An old failure stays hidden when the book got downloaded anyway or a later attempt for the same book exists, so a stale red row never sits next to a finished book. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/downloads/DownloadLists.kt | 26 +++++-- .../app/ui/downloads/DownloadsScreen.kt | 2 +- .../app/ui/downloads/DownloadListsTest.kt | 72 ++++++++++++++++++- 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadLists.kt b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadLists.kt index 56b38926..c5f46828 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadLists.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadLists.kt @@ -27,14 +27,32 @@ private val IN_PROGRESS = setOf( * Sort every download row into the active and completed lists. * * Active holds Queued, Downloading, Paused and Preparing (a slot claim waiting - * on its details fetch, kept visible so the tap never looks ignored). + * on its details fetch, kept visible so the tap never looks ignored), plus + * Failed. A failed download used to sit in neither list, so a server timeout + * made the book vanish with no way to retry. A Failed row stays out only when + * it is stale: the book got downloaded anyway, or a later attempt for the same + * book exists (that row speaks for the book instead). */ internal fun splitDownloadRows(rows: List): DownloadLists { - val active = rows - .filter { it.download.status in IN_PROGRESS } - .sortedByDescending { it.download.startedAt ?: 0L } + val byBook = rows.groupBy { it.download.audioBookId } + val active = rows.filter { row -> + when (row.download.status) { + in IN_PROGRESS -> true + DownloadStatus.Failed -> !row.isStaleFailure(byBook[row.download.audioBookId].orEmpty()) + else -> false + } + }.sortedByDescending { it.download.startedAt ?: 0L } val completed = rows .filter { it.download.status == DownloadStatus.Completed } .sortedByDescending { it.download.completedAt ?: 0L } return DownloadLists(active = active, completed = completed) } + +private fun DownloadRow.isStaleFailure(sameBook: List): Boolean { + if (bookIsDownloaded) return true + val startedAt = download.startedAt ?: 0L + return sameBook.any { other -> + other.download.id != download.id && + (other.download.status != DownloadStatus.Failed || (other.download.startedAt ?: 0L) > startedAt) + } +} 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 310a11be..58fcd174 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 @@ -252,7 +252,7 @@ private fun ActiveDownloadCard( ) { Icon( Icons.Outlined.PlayArrow, - contentDescription = "Resume", + contentDescription = if (download.status == DownloadStatus.Failed) "Retry" else "Resume", tint = NineLivesTheme.colors.goldFilament, modifier = Modifier.size(20.dp), ) diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/downloads/DownloadListsTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/downloads/DownloadListsTest.kt index 25abe622..e3a6fba0 100644 --- a/app/src/test/java/com/ninelivesaudio/app/ui/downloads/DownloadListsTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/ui/downloads/DownloadListsTest.kt @@ -5,7 +5,11 @@ import com.ninelivesaudio.app.domain.model.DownloadStatus import org.junit.Assert.assertEquals import org.junit.Test -/** The Downloads screen's lists come from one joined query, split here. */ +/** + * A failed download used to fall out of both lists, so a server timeout made the + * book vanish from Downloads with no Retry. Failed rows now show in the active + * list unless a later attempt or a finished download speaks for the book. + */ class DownloadListsTest { private fun row( @@ -31,6 +35,14 @@ class DownloadListsTest { private fun DownloadLists.activeIds() = active.map { it.download.id } private fun DownloadLists.completedIds() = completed.map { it.download.id } + @Test + fun `a failed download shows in the active list`() { + val lists = splitDownloadRows(listOf(row("f", DownloadStatus.Failed))) + + assertEquals(listOf("f"), lists.activeIds()) + assertEquals(emptyList(), lists.completedIds()) + } + @Test fun `in progress statuses stay active and completed goes to completed`() { val lists = splitDownloadRows( @@ -48,6 +60,64 @@ class DownloadListsTest { assertEquals(listOf("c"), lists.completedIds()) } + @Test + fun `a failure for a book that got downloaded anyway stays hidden`() { + val lists = splitDownloadRows(listOf(row("f", DownloadStatus.Failed, bookIsDownloaded = true))) + + assertEquals(emptyList(), lists.activeIds()) + } + + @Test + fun `a later attempt for the same book replaces the old failure`() { + val lists = splitDownloadRows( + listOf( + row("old", DownloadStatus.Failed, bookId = "b", startedAt = 1), + row("new", DownloadStatus.Downloading, bookId = "b", startedAt = 2), + ), + ) + + assertEquals(listOf("new"), lists.activeIds()) + } + + @Test + fun `only the newest of two failures for one book shows`() { + val lists = splitDownloadRows( + listOf( + row("old", DownloadStatus.Failed, bookId = "b", startedAt = 1), + row("new", DownloadStatus.Failed, bookId = "b", startedAt = 2), + ), + ) + + assertEquals(listOf("new"), lists.activeIds()) + } + + @Test + fun `failures for other books do not hide each other`() { + val lists = splitDownloadRows( + listOf( + row("a", DownloadStatus.Failed, bookId = "a", startedAt = 1), + row("b", DownloadStatus.Completed, bookId = "b", completedAt = 5), + ), + ) + + assertEquals(listOf("a"), lists.activeIds()) + } + + @Test + fun `lists are newest first`() { + val lists = splitDownloadRows( + listOf( + row("older", DownloadStatus.Queued, startedAt = 1), + row("newer", DownloadStatus.Failed, startedAt = 2), + row("c1", DownloadStatus.Completed, completedAt = 10), + row("c2", DownloadStatus.Completed, completedAt = 20), + ), + ) + + assertEquals(listOf("newer", "older"), lists.activeIds()) + assertEquals(listOf("c2", "c1"), lists.completedIds()) + } + @Test fun `the row's cover comes along`() { val lists = splitDownloadRows(listOf(row("q", DownloadStatus.Queued))) From 3119965d9eb233c72ff38f628018bf0932414125 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 22:58:37 -0400 Subject: [PATCH 21/69] Cancel cleanup looks up download folders in chunks Before deleting a cancelled download's files, cleanup looks up the book behind every download row in one query. Android 11's SQLite refuses more than 999 values in one statement, so with 1,000 or more download rows the lookup failed and cleanup quietly kept the partial files. The lookup now goes 500 ids at a time. Co-Authored-By: Claude Opus 5.5 --- .../app/service/DownloadManager.kt | 4 +- .../app/service/download/CancelCleanup.kt | 20 +++++++++ .../download/CleanupLookupChunkTest.kt | 42 +++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/download/CleanupLookupChunkTest.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 3426a9ef..64296c87 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt @@ -24,6 +24,7 @@ import com.ninelivesaudio.app.service.download.DownloadNotifications import com.ninelivesaudio.app.service.download.DownloadQueueWorker import com.ninelivesaudio.app.service.download.estimateTotalBytes import com.ninelivesaudio.app.service.download.finishInOwnerScope +import com.ninelivesaudio.app.service.download.lookUpInChunks import com.ninelivesaudio.app.service.download.pauseKeepsCompletedRow import com.ninelivesaudio.app.service.download.DownloadSlotStore import com.ninelivesaudio.app.service.download.selectNextDownload @@ -610,7 +611,8 @@ class DownloadManager @Inject constructor( // queued again, mapped to the folder it would write to. suspend fun liveRows() = downloadItemDao.getAll().map { it.audioBookId }.distinct() suspend fun foldersOf(bookIds: List) = - audioBookDao.getByIds(bookIds).map { engine.downloadLocationFor(it.toDomain()).folder } + lookUpInChunks(bookIds) { audioBookDao.getByIds(it) } + .map { engine.downloadLocationFor(it.toDomain()).folder } val otherFolders = foldersOf(liveRows()) val bookIsDownloaded = bookEntity.isDownloaded == 1 || !bookEntity.localPath.isNullOrEmpty() 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 bdb96215..bc124848 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 @@ -225,6 +225,26 @@ internal fun applyCancelCleanup(decision: CancelCleanupDecision.Delete): Int { return deleted } +/** + * Most ids one book lookup binds. Android 11 ships SQLite 3.28, which refuses a + * statement with more than 999 bound values, so a single IN over every download + * row failed (and the cleanup quietly kept the files) past that many rows. + */ +internal const val CLEANUP_LOOKUP_CHUNK = 500 + +/** Run [lookup] over [ids] in chunks of at most [chunkSize] and join the results. */ +internal suspend fun lookUpInChunks( + ids: List, + chunkSize: Int = CLEANUP_LOOKUP_CHUNK, + lookup: suspend (List) -> List, +): List { + val results = mutableListOf() + for (chunk in ids.chunked(chunkSize)) { + results += lookup(chunk) + } + return results +} + private fun canonicalOrNull(file: File): File? = try { file.canonicalFile } catch (_: IOException) { diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/CleanupLookupChunkTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/CleanupLookupChunkTest.kt new file mode 100644 index 00000000..05938cd7 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/CleanupLookupChunkTest.kt @@ -0,0 +1,42 @@ +package com.ninelivesaudio.app.service.download + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Cancel cleanup looks up the book behind every download row. Android 11's + * SQLite refuses more than 999 bound values, so the lookup has to go in chunks + * or cleanup silently keeps the files once a user has 1,000 download rows. + */ +class CleanupLookupChunkTest { + + @Test + fun `1,200 ids never bind more than 500 at once and all come back`() = runBlocking { + val ids = (1..1_200).map { "book-$it" } + val batchSizes = mutableListOf() + + val found = lookUpInChunks(ids) { chunk -> + batchSizes += chunk.size + chunk.map { "row-$it" } + } + + assertTrue("largest batch was ${batchSizes.max()}", batchSizes.all { it <= 500 }) + assertTrue(batchSizes.max() < 999) + assertEquals(ids.map { "row-$it" }, found) + } + + @Test + fun `no ids means no lookup at all`() = runBlocking { + var calls = 0 + + val found = lookUpInChunks(emptyList()) { chunk -> + calls++ + chunk + } + + assertEquals(0, calls) + assertEquals(emptyList(), found) + } +} From d48ade7bc162805e23b197ede90f27096e0b6b29 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:00:09 -0400 Subject: [PATCH 22/69] Settings keeps spaces in the username while typing The server URL and username fields trimmed on every keystroke, so a space vanished the moment it was typed and a username like "john smith" could not be entered. Both fields now keep what was typed and Connect trims the ends before it validates and logs in. The trusted certificate host is read from the trimmed URL, so spaces around it never hide a saved fingerprint. The password is still sent exactly as typed. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/settings/SettingsViewModel.kt | 47 ++++++++++------ .../app/ui/settings/ConnectFieldTrimTest.kt | 56 +++++++++++++++++++ 2 files changed, 87 insertions(+), 16 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/settings/ConnectFieldTrimTest.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 ffe95b42..fc05c69b 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 @@ -399,7 +399,7 @@ class SettingsViewModel @Inject constructor( val settings = settingsManager.currentSettings _uiState.update { state -> - val configuredHost = extractHost(settings.serverUrl) + val configuredHost = extractServerHost(settings.serverUrl) state.copy( appMode = settings.appMode, serverUrl = settings.serverUrl, @@ -738,12 +738,14 @@ class SettingsViewModel @Inject constructor( // ─── User Actions ───────────────────────────────────────────────────── + // The server URL and username fields keep exactly what was typed. Trimming + // here ate every space as it was typed, so "john smith" could not be + // entered. Connect trims both ends instead (see [trimmedForConnect]). fun onServerUrlChanged(value: String) { - val serverUrl = value.trim() - val host = extractHost(serverUrl) + val host = extractServerHost(value) _uiState.update { it.copy( - serverUrl = serverUrl, + serverUrl = value, errorMessage = null, trustedFingerprintHost = host, hasTrustedFingerprint = host?.let { configuredHost -> @@ -754,7 +756,7 @@ class SettingsViewModel @Inject constructor( } fun onUsernameChanged(value: String) { - _uiState.update { it.copy(username = value.trim(), errorMessage = null) } + _uiState.update { it.copy(username = value, errorMessage = null) } } fun onPasswordChanged(value: String) { @@ -870,7 +872,7 @@ class SettingsViewModel @Inject constructor( } fun connect() { - val state = _uiState.value + val state = _uiState.value.trimmedForConnect() if (state.serverUrl.isBlank()) { _uiState.update { it.copy(errorMessage = "Please enter a server URL") } @@ -917,7 +919,7 @@ class SettingsViewModel @Inject constructor( // captured before launch can be stale if the user edited the URL, // username, or token between tapping Connect and this point, which // would otherwise log in to (and persist) the wrong server. - val s = _uiState.value + val s = _uiState.value.trimmedForConnect() try { val passwordOutcome = if (s.useApiToken) { @@ -1454,15 +1456,6 @@ class SettingsViewModel @Inject constructor( } } - private fun extractHost(serverUrl: String): String? { - if (serverUrl.isBlank()) return null - return try { - URI(serverUrl).host?.lowercase() - } catch (_: Exception) { - null - } - } - // ─── Library Selection ────────────────────────────────────────────── private suspend fun loadLibraries() { @@ -1747,6 +1740,28 @@ internal fun sessionIdentityForOutcome( typedServerUrl to typedUsername } +/** + * The fields Connect sends: the typed server URL and username with stray spaces + * cut from both ends. Spaces inside a username stay. The password is sent as + * typed, since a space can be part of it. + */ +internal fun SettingsViewModel.UiState.trimmedForConnect(): SettingsViewModel.UiState = + copy(serverUrl = serverUrl.trim(), username = username.trim()) + +/** + * The host a trusted certificate fingerprint belongs to, from the server URL as + * typed. Leading or trailing spaces in the field do not change the host. + */ +internal fun extractServerHost(serverUrl: String): String? { + val trimmed = serverUrl.trim() + if (trimmed.isEmpty()) return null + return try { + URI(trimmed).host?.lowercase() + } catch (_: Exception) { + null + } +} + internal enum class PasswordLoginOutcome { NEW_SESSION, RETAINED_SESSION, diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/settings/ConnectFieldTrimTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/settings/ConnectFieldTrimTest.kt new file mode 100644 index 00000000..e963d41e --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/settings/ConnectFieldTrimTest.kt @@ -0,0 +1,56 @@ +package com.ninelivesaudio.app.ui.settings + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The server URL and username fields used to trim on every keystroke, which ate + * the space in "john smith" as it was typed. The fields now keep the raw text + * and Connect trims the ends. + */ +class ConnectFieldTrimTest { + + @Test + fun `connect trims the ends of the server URL and username`() { + val state = SettingsViewModel.UiState( + serverUrl = " https://abs.example.com ", + username = " john smith ", + ).trimmedForConnect() + + assertEquals("https://abs.example.com", state.serverUrl) + assertEquals("john smith", state.username) + } + + @Test + fun `the space inside a username survives connect`() { + val state = SettingsViewModel.UiState(username = "john smith").trimmedForConnect() + + assertEquals("john smith", state.username) + } + + @Test + fun `connect leaves the password exactly as typed`() { + val state = SettingsViewModel.UiState(password = " pass word ").trimmedForConnect() + + assertEquals(" pass word ", state.password) + } + + @Test + fun `a whitespace only server URL counts as blank at connect`() { + val state = SettingsViewModel.UiState(serverUrl = " ").trimmedForConnect() + + assertEquals("", state.serverUrl) + } + + @Test + fun `the fingerprint host ignores spaces around the typed URL`() { + assertEquals("abs.example.com", extractServerHost(" https://ABS.example.com:13378 ")) + } + + @Test + fun `a blank or broken URL has no fingerprint host`() { + assertNull(extractServerHost(" ")) + assertNull(extractServerHost("https://bad host")) + } +} From 9ff04fd2d87fac5ee768fd8a6ce57b5a09706387 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:03:17 -0400 Subject: [PATCH 23/69] Library and Downloads rows ask the server for smaller covers Audiobookshelf resizes covers to 400px wide when no width is given, and the Library and Downloads rows draw them at about 64dp and 48dp, which is under 240px even on the densest screens. Those rows now ask for width=240, so a long scroll through a big library pulls about a third of the pixels. Only server item cover URLs change. Saved covers on the device, book detail, the player and Home's tiles keep what they had. Covers cached at the old size miss once while offline right after the update, since the address changed. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/components/BookCoverImage.kt | 50 +++++++++++++- .../app/ui/downloads/DownloadsScreen.kt | 3 + .../app/ui/library/LibraryScreen.kt | 2 + .../ui/components/ThumbnailCoverUrlTest.kt | 68 +++++++++++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/components/ThumbnailCoverUrlTest.kt 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 90e0ba86..89cbf65e 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 @@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import coil.compose.SubcomposeAsyncImage import coil.compose.SubcomposeAsyncImageContent +import java.net.URI /** * Displays an audiobook cover image, falling back to a vintage worn-book @@ -13,6 +14,10 @@ import coil.compose.SubcomposeAsyncImageContent * * Uses SubcomposeAsyncImage so that broken/missing server URLs also * show the vintage placeholder instead of a blank rectangle. + * + * [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 + * server's default size. Saved covers on the device are never touched. */ @Composable fun BookCoverImage( @@ -24,12 +29,15 @@ fun BookCoverImage( contentScale: ContentScale = ContentScale.Crop, alignment: Alignment = Alignment.TopCenter, imageModel: Any? = null, + thumbnailWidthPx: Int? = null, ) { val seed = bookId?.hashCode() ?: title?.hashCode() ?: 0 if (!coverUrl.isNullOrEmpty()) { SubcomposeAsyncImage( - model = imageModel ?: coverUrl, + model = imageModel + ?: thumbnailWidthPx?.let { thumbnailCoverUrl(coverUrl, it) } + ?: coverUrl, contentDescription = contentDescription, modifier = modifier, contentScale = contentScale, @@ -58,3 +66,43 @@ fun BookCoverImage( ) } } + +/** + * Cover width for small list rows, in pixels. A 64dp row at the densest screens + * draws about 230px, and Audiobookshelf hands back 400px when no width is asked + * for, so lists of thousands of books fetch roughly a third of the pixels. + */ +const val THUMBNAIL_COVER_WIDTH_PX = 240 + +private val SERVER_COVER_PATH = Regex(".*/api/items/[^/]+/cover") + +/** + * [url] with `width=[widthPx]` added when it is an Audiobookshelf item cover + * (`.../api/items/{id}/cover` over http or https). Audiobookshelf resizes to + * that width and keeps the aspect ratio. Anything else comes back unchanged: + * file and content URIs for saved covers, other hosts' images, URLs that will + * not parse, and URLs that already name a width. + */ +internal fun thumbnailCoverUrl(url: String, widthPx: Int): String { + if (widthPx <= 0) return url + val uri = try { + URI(url) + } catch (_: Exception) { + return url + } + val scheme = uri.scheme?.lowercase() + if (scheme != "http" && scheme != "https") return url + if (!SERVER_COVER_PATH.matches(uri.rawPath.orEmpty())) return url + val query = uri.rawQuery.orEmpty() + if (query.split('&').any { it.substringBefore('=') == "width" }) return url + + val fragmentAt = url.indexOf('#') + val base = if (fragmentAt >= 0) url.substring(0, fragmentAt) else url + val fragment = if (fragmentAt >= 0) url.substring(fragmentAt) else "" + val separator = when { + !base.contains('?') -> "?" + base.endsWith("?") || base.endsWith("&") -> "" + else -> "&" + } + return "$base${separator}width=$widthPx$fragment" +} 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 58fcd174..ae737c3c 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 @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 @@ -174,6 +175,7 @@ private fun ActiveDownloadCard( modifier = Modifier.fillMaxSize(), title = download.title, bookId = download.audioBookId, + thumbnailWidthPx = THUMBNAIL_COVER_WIDTH_PX, ) } @@ -346,6 +348,7 @@ private fun CompletedDownloadCard( modifier = Modifier.fillMaxSize(), title = download.title, bookId = download.audioBookId, + thumbnailWidthPx = THUMBNAIL_COVER_WIDTH_PX, ) } 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 4238fa58..4cf5a5d6 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 @@ -45,6 +45,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ninelivesaudio.app.ui.components.ArchiveScreenHeader import com.ninelivesaudio.app.ui.components.BookCoverImage +import com.ninelivesaudio.app.ui.components.THUMBNAIL_COVER_WIDTH_PX import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.SyncResult import com.ninelivesaudio.app.ui.components.ContainmentFrame @@ -794,6 +795,7 @@ private fun ArchiveBookListItem( modifier = Modifier.fillMaxSize(), title = book.title, bookId = book.id, + thumbnailWidthPx = THUMBNAIL_COVER_WIDTH_PX, ) } diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/components/ThumbnailCoverUrlTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/components/ThumbnailCoverUrlTest.kt new file mode 100644 index 00000000..32868525 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/components/ThumbnailCoverUrlTest.kt @@ -0,0 +1,68 @@ +package com.ninelivesaudio.app.ui.components + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * List rows ask Audiobookshelf for a smaller cover with `width=`. Only server + * item cover URLs change. Saved covers and anything unfamiliar pass through. + */ +class ThumbnailCoverUrlTest { + + @Test + fun `a server cover gets a width`() { + assertEquals( + "https://abs.example.com/api/items/li_1/cover?width=240", + thumbnailCoverUrl("https://abs.example.com/api/items/li_1/cover", 240), + ) + } + + @Test + fun `a server under a subpath still gets a width`() { + assertEquals( + "http://nas.local:8080/abs/api/items/li_1/cover?width=240", + thumbnailCoverUrl("http://nas.local:8080/abs/api/items/li_1/cover", 240), + ) + } + + @Test + fun `an existing query string keeps its values`() { + assertEquals( + "https://abs.example.com/api/items/li_1/cover?ts=123&width=240", + thumbnailCoverUrl("https://abs.example.com/api/items/li_1/cover?ts=123", 240), + ) + } + + @Test + fun `a URL that already names a width is left alone`() { + val url = "https://abs.example.com/api/items/li_1/cover?width=800" + + assertEquals(url, thumbnailCoverUrl(url, 240)) + } + + @Test + fun `saved covers on the device are untouched`() { + val file = "file:///storage/emulated/0/Android/data/x/files/Music/Book/cover.jpg" + val content = "content://com.android.externalstorage.documents/document/primary%3ABook%2Fcover.jpg" + + assertEquals(file, thumbnailCoverUrl(file, 240)) + assertEquals(content, thumbnailCoverUrl(content, 240)) + } + + @Test + fun `other images and broken URLs are untouched`() { + val other = "https://images.example.com/covers/li_1.jpg" + val broken = "https://abs example.com/api/items/li_1/cover" + + assertEquals(other, thumbnailCoverUrl(other, 240)) + assertEquals(broken, thumbnailCoverUrl(broken, 240)) + } + + @Test + fun `a dangling question mark does not double up`() { + assertEquals( + "https://abs.example.com/api/items/li_1/cover?width=240", + thumbnailCoverUrl("https://abs.example.com/api/items/li_1/cover?", 240), + ) + } +} From 406e5273dc86d3d4d4dea8f96e5aad71e6a71630 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:04:13 -0400 Subject: [PATCH 24/69] Covers load in their own line so API calls never wait on them Coil, Retrofit and downloads shared one OkHttp client, which lets 5 requests per host wait on a server answer at once. A screen of covers the server had not resized yet could fill all 5, and the play, session and progress calls queued behind them on a slow server. The image loader now builds on the same client, with the same auth, base URL and certificate setup and the same connection pool, but with its own request dispatcher. Co-Authored-By: Claude Opus 5.5 --- .../com/ninelivesaudio/app/NineLivesApp.kt | 11 ++-- .../app/data/remote/ImageHttpClient.kt | 19 +++++++ .../app/data/remote/ImageHttpClientTest.kt | 56 +++++++++++++++++++ 3 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/data/remote/ImageHttpClient.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/data/remote/ImageHttpClientTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt b/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt index 7f582891..68e318e4 100644 --- a/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt +++ b/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt @@ -9,6 +9,7 @@ import coil.ImageLoader import coil.ImageLoaderFactory import coil.disk.DiskCache import com.ninelivesaudio.app.data.remote.ApiService +import com.ninelivesaudio.app.data.remote.imageLoaderHttpClient import com.ninelivesaudio.app.data.repository.LibraryRepository import com.ninelivesaudio.app.entitlement.BillingManager import com.ninelivesaudio.app.entitlement.EntitlementRepository @@ -207,13 +208,15 @@ class NineLivesApp : Application(), ImageLoaderFactory { } /** - * Coil image loader for the whole app. Reuses the authenticated OkHttpClient - * so server covers carry the auth token, and adds a persistent disk cache so - * covers fetched once survive offline instead of relying on Coil's defaults. + * Coil image loader for the whole app. Builds on the authenticated + * OkHttpClient so server covers carry the auth token, with its own request + * dispatcher so a burst of covers never queues API calls (see + * [imageLoaderHttpClient]). Adds a persistent disk cache so covers fetched + * once survive offline instead of relying on Coil's defaults. */ override fun newImageLoader(): ImageLoader { return ImageLoader.Builder(this) - .okHttpClient { okHttpClient } + .okHttpClient { imageLoaderHttpClient(okHttpClient) } .crossfade(true) .diskCache { DiskCache.Builder() diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/ImageHttpClient.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/ImageHttpClient.kt new file mode 100644 index 00000000..fd2b910f --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/ImageHttpClient.kt @@ -0,0 +1,19 @@ +package com.ninelivesaudio.app.data.remote + +import okhttp3.Dispatcher +import okhttp3.OkHttpClient + +/** + * The HTTP client cover images load through: the app client with the same auth, + * base URL and certificate setup, but its own request [Dispatcher]. + * + * OkHttp's dispatcher lets 5 requests per host wait on a response at once. With + * one shared client, a screen full of covers on a slow server (a cover the + * server has not resized yet takes a while to answer) filled those slots, and + * the play, session and progress calls queued behind them. Covers now wait in + * their own line. The connection pool is still shared. + */ +fun imageLoaderHttpClient(appClient: OkHttpClient): OkHttpClient = + appClient.newBuilder() + .dispatcher(Dispatcher()) + .build() diff --git a/app/src/test/java/com/ninelivesaudio/app/data/remote/ImageHttpClientTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/remote/ImageHttpClientTest.kt new file mode 100644 index 00000000..121c9612 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/remote/ImageHttpClientTest.kt @@ -0,0 +1,56 @@ +package com.ninelivesaudio.app.data.remote + +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Test +import java.util.concurrent.TimeUnit + +/** + * Covers load through their own request dispatcher so a burst of them never + * holds the per-host slots that play, session and progress calls need. Auth, + * base URL rewriting, timeouts and the connection pool all carry over. + */ +class ImageHttpClientTest { + + private val auth = Interceptor { chain -> chain.proceed(chain.request()) } + private val baseUrl = Interceptor { chain -> chain.proceed(chain.request()) } + private val app = OkHttpClient.Builder() + .addInterceptor(baseUrl) + .addNetworkInterceptor(auth) + .readTimeout(60, TimeUnit.SECONDS) + .build() + + @Test + fun `covers get their own dispatcher`() { + val images = imageLoaderHttpClient(app) + + assertNotSame(app.dispatcher, images.dispatcher) + } + + @Test + fun `cover slots do not count against API calls`() { + val images = imageLoaderHttpClient(app) + images.dispatcher.maxRequestsPerHost = 1 + + assertEquals(5, app.dispatcher.maxRequestsPerHost) + } + + @Test + fun `auth and base URL interceptors carry over`() { + val images = imageLoaderHttpClient(app) + + assertEquals(listOf(baseUrl), images.interceptors) + assertEquals(listOf(auth), images.networkInterceptors) + } + + @Test + fun `timeouts and the connection pool carry over`() { + val images = imageLoaderHttpClient(app) + + assertEquals(app.readTimeoutMillis, images.readTimeoutMillis) + assertSame(app.connectionPool, images.connectionPool) + } +} From 2277457c2ca9067c71cb0e28ea1e715164b86e61 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:10:07 -0400 Subject: [PATCH 25/69] A retry of the change check can no longer call a shelf with a removed book current The check counted a fetched book as new only when the cache lacked it. If the server removed one book and added another, the first check saved the added book, saw 100 + 1 against 100, and put the full download off until Wi-Fi. The next check found the added book already cached, read 100 + 0 = 100, and moved the watermark forward with the removed book still on the shelf. A progress pull or a full download that stopped short could save a new book early the same way. A book added after the last good sync's newest book now always counts as new, whatever the cache holds. And when the counts disagree the watermark is forgotten, so every later check goes to the full download (which is the only thing that prunes) until one completes. Co-Authored-By: Claude Opus 5.5 --- .../data/repository/AudioBookRepository.kt | 6 ++++- .../app/data/repository/LibrarySyncPlanner.kt | 23 +++++++++++++++++++ .../data/repository/LibrarySyncPlannerTest.kt | 17 ++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) 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 76452806..1d0932e5 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 @@ -330,7 +330,7 @@ class AudioBookRepository @Inject constructor( val alreadyCached = fetchByIdChunks(books.map { it.id }) { ids -> audioBookDao.getServerIdsInLibrary(libraryId, ids) }.toHashSet() - val newBookCount = books.count { it.id !in alreadyCached } + val newBookCount = newBooksSinceWatermark(watermark, books, alreadyCached) if (books.isNotEmpty()) { val merged = mergeForSave(books) // Added books are saved even when the counts disagree below: they @@ -339,6 +339,10 @@ class AudioBookRepository @Inject constructor( _booksSaved.tryEmit(libraryId) } if (!incrementalCountsAgree(watermark.itemCount, newBookCount, value.total)) { + // The books just saved are not in the watermark's count, so it no + // longer describes the cache. Forgetting it keeps every later + // check on the full download until one completes. + watermarkStore.remove(key, libraryId) return fallBackToFullLocked( libraryId, "server has ${value.total} books, expected ${watermark.itemCount} + $newBookCount new", diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt index 4b1cba8b..cb30c44d 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt @@ -191,6 +191,29 @@ internal fun incrementalCountsAgree( serverTotal: Int, ): Boolean = previousCount + newBookCount == serverTotal +/** + * How many of the fetched books the last good sync's count left out. A book + * added after the stored newest one cannot be in that count, even when the + * cache already holds it: an earlier check whose counts disagreed saved it, + * or a progress pull, or a full download that stopped short. Counting by + * cache presence alone let the retry after "one removed, one added" read + * 100 + 0 = 100 and call the shelf current with the removed book still on + * it. A book inside the overlap window counts only when the cache lacks it. + */ +internal fun newBooksSinceWatermark( + watermark: LibrarySyncWatermark, + fetched: List, + alreadyCached: Set, +): Int { + val storedNewest = watermark.newestAddedAt + return fetched.distinctBy { it.id }.count { book -> + val added = book.addedAt + book.id !in alreadyCached || + storedNewest == null || + (added != null && added > storedNewest && book.id != watermark.newestItemId) + } +} + /** * Running totals of a full download, kept page by page so the books * themselves never have to be held: how many distinct books, the newest by diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt index 1967236d..3670b91d 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt @@ -150,6 +150,23 @@ class LibrarySyncPlannerTest { assertFalse(incrementalCountsAgree(previousCount = 100, newBookCount = 1, serverTotal = 100)) } + @Test + fun `a retry after one removed and one added still disagrees when the added book is already cached`() { + // Server deleted one book and added li_new, keeping 100. The first + // check saved li_new and deferred. The retry finds it cached. + val fetched = listOf(AudioBook(id = "li_new", addedAt = 9_000L), AudioBook(id = "li_newest", addedAt = 5_000L)) + val newCount = newBooksSinceWatermark(watermark(), fetched, alreadyCached = setOf("li_new", "li_newest")) + assertEquals(1, newCount) + assertFalse(incrementalCountsAgree(previousCount = 100, newBookCount = newCount, serverTotal = 100)) + } + + @Test + fun `a book inside the overlap window counts as new only when the cache lacks it`() { + val overlap = listOf(AudioBook(id = "li_late", addedAt = 4_990L)) + assertEquals(0, newBooksSinceWatermark(watermark(), overlap, alreadyCached = setOf("li_late"))) + assertEquals(1, newBooksSinceWatermark(watermark(), overlap, alreadyCached = emptySet())) + } + @Test fun `an incremental sync moves the watermark to the server's newest book`() { val fetched = listOf(AudioBook(id = "li_new", addedAt = 9_000L), AudioBook(id = "li_newest", addedAt = 5_000L)) From ac9abab2a33950ac6a8d9103c65103946751b2b8 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:10:56 -0400 Subject: [PATCH 26/69] A full refresh that fails is retried instead of being reported as recovered A full download that stopped short left the library's watermark from the last good sync in place. The next background check then saw the same newest book and the same count, fetched nothing, and replaced the failed or partial record with a success. Any edit on a server page the download never reached stayed unapplied, and the full download was never tried again after its backoff. Only a complete full download now leaves a watermark. One that stops short forgets it, so the next check goes back to the full download (waiting out the failure backoff, or for Wi-Fi on a cell network) and a success is only recorded once that download actually completes. Co-Authored-By: Claude Opus 5.5 --- .../app/data/repository/AudioBookRepository.kt | 4 ++-- .../app/data/repository/LibrarySyncPlanner.kt | 13 ++++++++++--- .../app/data/repository/LibrarySyncPlannerTest.kt | 14 ++++++++++++-- 3 files changed, 24 insertions(+), 7 deletions(-) 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 1d0932e5..4619778c 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 @@ -397,11 +397,11 @@ class AudioBookRepository @Inject constructor( _booksSaved.tryEmit(libraryId) }, ) + val watermark = watermarkAfterFullSync(key, libraryId, tally, result, System.currentTimeMillis()) + if (watermark != null) watermarkStore.put(watermark) else watermarkStore.remove(key, libraryId) if (result is RemoteResult.Ok) { fullSyncFailures.remove(libraryId) _deferredFullSyncs.update { it - libraryId } - val watermark = watermarkAfterFullSync(key, libraryId, tally, System.currentTimeMillis()) - if (watermark != null) watermarkStore.put(watermark) else watermarkStore.remove(key, libraryId) } else { fullSyncFailures.compute(libraryId) { _, previous -> FullSyncFailures(count = (previous?.count ?: 0) + 1, lastFailureAtMs = monotonicNowMs()) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt index cb30c44d..579a20bd 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt @@ -246,16 +246,23 @@ internal class FullSyncTally { } /** - * The watermark a complete full download leaves. Null when any book lacks an - * added date: without it the next check could not compare, so the library - * stays on full downloads instead of trusting a guess. + * The watermark a full download leaves, null meaning the stored one must be + * forgotten. Only a complete ([RemoteResult.Ok]) download earns one. One that + * stopped short may have saved new books the old watermark does not count + * and left server edits unapplied, so keeping the old watermark would let the + * next check find "nothing new" and report the failed refresh as recovered. + * Also null when any book lacks an added date: without it the next check + * could not compare, so the library stays on full downloads instead of + * trusting a guess. */ internal fun watermarkAfterFullSync( key: SyncAccountKey, libraryId: String, tally: FullSyncTally, + result: RemoteResult<*>, nowMs: Long, ): LibrarySyncWatermark? { + if (result !is RemoteResult.Ok) return null if (tally.missingAddedAt) return null return LibrarySyncWatermark( serverUrl = key.serverUrl, diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt index 3670b91d..cc0e4b35 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt @@ -1,6 +1,7 @@ package com.ninelivesaudio.app.data.repository import com.ninelivesaudio.app.data.remote.LibraryHead +import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.LibrarySyncWatermark import org.junit.Assert.assertEquals @@ -182,14 +183,23 @@ class LibrarySyncPlannerTest { val tally = FullSyncTally() tally.add(listOf(AudioBook(id = "a", addedAt = 3L), AudioBook(id = "b", addedAt = 9L))) tally.add(listOf(AudioBook(id = "b", addedAt = 9L), AudioBook(id = "c", addedAt = 5L))) - val w = watermarkAfterFullSync(key, "lib", tally, nowMs = 42L)!! + val w = watermarkAfterFullSync(key, "lib", tally, RemoteResult.Ok(3), nowMs = 42L)!! assertEquals(3, w.itemCount) assertEquals("b", w.newestItemId) assertEquals(9L, w.newestAddedAt) assertEquals(42L, w.lastFullSyncAtMs) val missing = FullSyncTally().apply { add(listOf(AudioBook(id = "x", addedAt = null))) } - assertNull(watermarkAfterFullSync(key, "lib", missing, nowMs = 42L)) + assertNull(watermarkAfterFullSync(key, "lib", missing, RemoteResult.Ok(1), nowMs = 42L)) + } + + @Test + fun `a full download that stops short forgets the watermark so the next check downloads again`() { + val tally = FullSyncTally().apply { add(listOf(AudioBook(id = "a", addedAt = 3L))) } + assertNull(watermarkAfterFullSync(key, "lib", tally, RemoteResult.Partial(1, "page 2: HTTP 500"), nowMs = 42L)) + assertNull(watermarkAfterFullSync(key, "lib", tally, RemoteResult.Failed("timeout"), nowMs = 42L)) + // With no watermark the next check cannot answer "nothing new". + assertTrue(decideLibraryChange(null, head(), localServerRowCount = 100) is LibraryChange.Full) } @Test From 7baddf018b71fdaf73eae2b1c2ac9eeb26fac1ba Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:13:32 -0400 Subject: [PATCH 27/69] A sync started for one account is never shared with or written for the next Library syncs were shared and serialized by library id alone, and they run in the repository's own scope so they outlive whoever asked. Start a slow full download as one account, sign in as another on the same server, and refresh the same library: the new account joined the old download, and its pages and prune landed in the new account's cache, including removing books the new account can see. Flights and per-library locks are now keyed by server, account, and sign-in (a hash of the token, so two token sign-ins with no username still differ). A sync also checks before every save and before its prune that the same sign-in is still current. Once it is not, the sync stops, writes nothing more, and fails with "the signed-in account changed during the sync", leaving the cache to the new account's own sync. Co-Authored-By: Claude Opus 5.5 --- .../data/repository/AudioBookRepository.kt | 136 +++++++++++++----- .../app/data/repository/LibrarySyncPlanner.kt | 12 ++ .../repository/LibrarySyncWatermarkStore.kt | 15 ++ .../repository/LibrarySyncSingleFlightTest.kt | 62 ++++++++ 4 files changed, 187 insertions(+), 38 deletions(-) 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 4619778c..1bb5d7e0 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 @@ -60,11 +60,17 @@ class AudioBookRepository @Inject constructor( private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val syncFlights = LibrarySyncSingleFlight(syncScope) - // One lock per library. Fetch, save, and prune for a library happen under - // its lock, so an older response can never land after a newer complete - // one pruned. Other libraries no longer wait behind a big one's download. + // One lock per library and signed-in account (the same key as the + // flights). Fetch, save, and prune for a library happen under its lock, + // so an older response can never land after a newer complete one pruned. + // Other libraries no longer wait behind a big one's download, and a new + // account never waits behind (or shares) the previous account's sync, + // whose writes stop once it notices the switch. private val libraryLocks = ConcurrentHashMap() - private fun lockFor(libraryId: String): Mutex = libraryLocks.computeIfAbsent(libraryId) { Mutex() } + private fun lockFor(flightKey: String): Mutex = libraryLocks.computeIfAbsent(flightKey) { Mutex() } + + private suspend fun isCurrent(identity: LibrarySyncIdentity): Boolean = + watermarkStore.currentSyncIdentity() == identity // Background full downloads that keep failing wait before trying again. private val fullSyncFailures = ConcurrentHashMap() @@ -262,10 +268,13 @@ class AudioBookRepository @Inject constructor( * * Returns how many books the fetch saved. */ - suspend fun syncLibraryItems(libraryId: String): RemoteResult = - syncFlights.run(libraryId, LibrarySyncKind.FULL) { - lockFor(libraryId).withLock { runFullSyncLocked(libraryId) } + suspend fun syncLibraryItems(libraryId: String): RemoteResult { + val identity = watermarkStore.currentSyncIdentity() + val flightKey = identity.flightKey(libraryId) + return syncFlights.run(flightKey, LibrarySyncKind.FULL) { + lockFor(flightKey).withLock { runFullSyncLocked(libraryId, identity) } }.itemCountResult() ?: RemoteResult.Failed("sync did not run") + } /** * The background check for one library: ask the server whether anything @@ -276,13 +285,21 @@ class AudioBookRepository @Inject constructor( * fall back to the full download, which waits for an unmetered network * when [isMetered] and a cached shelf exists. See [planLibrarySync]. */ - internal suspend fun refreshLibraryItemsIfChanged(libraryId: String, isMetered: Boolean): LibraryRefreshOutcome = - syncFlights.run(libraryId, LibrarySyncKind.CHECK) { - lockFor(libraryId).withLock { runCheckLocked(libraryId, isMetered) } + internal suspend fun refreshLibraryItemsIfChanged(libraryId: String, isMetered: Boolean): LibraryRefreshOutcome { + val identity = watermarkStore.currentSyncIdentity() + val flightKey = identity.flightKey(libraryId) + return syncFlights.run(flightKey, LibrarySyncKind.CHECK) { + lockFor(flightKey).withLock { runCheckLocked(libraryId, identity, isMetered) } } + } - private suspend fun runCheckLocked(libraryId: String, isMetered: Boolean): LibraryRefreshOutcome { - val key = watermarkStore.currentAccount() + private suspend fun runCheckLocked( + libraryId: String, + identity: LibrarySyncIdentity, + isMetered: Boolean, + ): LibraryRefreshOutcome { + if (!isCurrent(identity)) return LibraryRefreshOutcome.CheckFailed(SYNC_ACCOUNT_CHANGED) + val key = identity.account val watermark = watermarkStore.get(key, libraryId) val localCount = audioBookDao.countServerBooksByLibrary(libraryId) val hasCachedRows = localCount > 0 @@ -305,15 +322,15 @@ class AudioBookRepository @Inject constructor( return when (plan) { LibrarySyncPlan.SKIP -> LibraryRefreshOutcome.Unchanged(watermark?.itemCount ?: localCount) LibrarySyncPlan.DEFER_FULL -> deferUntilUnmetered(libraryId, reason) - LibrarySyncPlan.FULL -> runBackgroundFullLocked(libraryId, reason) + LibrarySyncPlan.FULL -> runBackgroundFullLocked(libraryId, identity, reason) LibrarySyncPlan.INCREMENTAL -> - runIncrementalLocked(libraryId, key, watermark!!, isMetered, hasCachedRows) + runIncrementalLocked(libraryId, identity, watermark!!, isMetered, hasCachedRows) } } private suspend fun runIncrementalLocked( libraryId: String, - key: SyncAccountKey, + identity: LibrarySyncIdentity, watermark: com.ninelivesaudio.app.domain.model.LibrarySyncWatermark, isMetered: Boolean, hasCachedRows: Boolean, @@ -324,8 +341,9 @@ class AudioBookRepository @Inject constructor( pageSize = INCREMENTAL_PAGE_SIZE, maxPages = INCREMENTAL_MAX_PAGES, ) + val key = identity.account val value = (fetched as? RemoteResult.Ok)?.value - ?: return fallBackToFullLocked(libraryId, (fetched as? RemoteResult.Failed)?.reason ?: "added-since fetch failed", isMetered, hasCachedRows) + ?: return fallBackToFullLocked(libraryId, identity, (fetched as? RemoteResult.Failed)?.reason ?: "added-since fetch failed", isMetered, hasCachedRows) val books = value.items val alreadyCached = fetchByIdChunks(books.map { it.id }) { ids -> audioBookDao.getServerIdsInLibrary(libraryId, ids) @@ -335,7 +353,11 @@ class AudioBookRepository @Inject constructor( val merged = mergeForSave(books) // Added books are saved even when the counts disagree below: they // are real, and the full download that follows prunes, not this. - withContext(NonCancellable) { saveMerged(merged) } + // Never into a cache another account has taken over meanwhile. + val saved = withContext(NonCancellable) { + isCurrent(identity).also { current -> if (current) saveMerged(merged) } + } + if (!saved) return LibraryRefreshOutcome.CheckFailed(SYNC_ACCOUNT_CHANGED) _booksSaved.tryEmit(libraryId) } if (!incrementalCountsAgree(watermark.itemCount, newBookCount, value.total)) { @@ -345,6 +367,7 @@ class AudioBookRepository @Inject constructor( watermarkStore.remove(key, libraryId) return fallBackToFullLocked( libraryId, + identity, "server has ${value.total} books, expected ${watermark.itemCount} + $newBookCount new", isMetered, hasCachedRows, @@ -356,6 +379,7 @@ class AudioBookRepository @Inject constructor( private suspend fun fallBackToFullLocked( libraryId: String, + identity: LibrarySyncIdentity, reason: String, isMetered: Boolean, hasCachedRows: Boolean, @@ -363,14 +387,18 @@ class AudioBookRepository @Inject constructor( if (shouldDeferFullSync(isMetered, explicit = false, hasCachedRows)) { deferUntilUnmetered(libraryId, reason) } else { - runBackgroundFullLocked(libraryId, reason) + runBackgroundFullLocked(libraryId, identity, reason) } - private suspend fun runBackgroundFullLocked(libraryId: String, reason: String): LibraryRefreshOutcome { + private suspend fun runBackgroundFullLocked( + libraryId: String, + identity: LibrarySyncIdentity, + reason: String, + ): LibraryRefreshOutcome { if (isFullSyncBackedOff(fullSyncFailures[libraryId], monotonicNowMs())) { return LibraryRefreshOutcome.Deferred("$reason (waiting after failed downloads)", untilUnmetered = false) } - return runFullSyncLocked(libraryId) + return runFullSyncLocked(libraryId, identity) } private fun deferUntilUnmetered(libraryId: String, reason: String): LibraryRefreshOutcome { @@ -378,11 +406,11 @@ class AudioBookRepository @Inject constructor( return LibraryRefreshOutcome.Deferred(reason, untilUnmetered = true) } - private suspend fun runFullSyncLocked(libraryId: String): LibraryRefreshOutcome { + private suspend fun runFullSyncLocked(libraryId: String, identity: LibrarySyncIdentity): LibraryRefreshOutcome { // A check that got here is now a full download: let a full refresh // asked for meanwhile share it rather than queue a second one. - syncFlights.upgradeToFull(libraryId) - val key = watermarkStore.currentAccount() + syncFlights.upgradeToFull(identity.flightKey(libraryId)) + val key = identity.account val tally = FullSyncTally() val result = runLibraryItemSyncPass( libraryId = libraryId, @@ -396,7 +424,13 @@ class AudioBookRepository @Inject constructor( tally.add(books) _booksSaved.tryEmit(libraryId) }, + isCurrent = { isCurrent(identity) }, ) + // Another account signed in mid-download. Nothing more was written, + // and this account's watermark and failure count stay as they were. + if (result is RemoteResult.Failed && result.reason == SYNC_ACCOUNT_CHANGED) { + return LibraryRefreshOutcome.Full(result) + } val watermark = watermarkAfterFullSync(key, libraryId, tally, result, System.currentTimeMillis()) if (watermark != null) watermarkStore.put(watermark) else watermarkStore.remove(key, libraryId) if (result is RemoteResult.Ok) { @@ -430,7 +464,7 @@ class AudioBookRepository @Inject constructor( * stale books. */ internal suspend fun pruneServerBooksForRemovedLibrary(libraryId: String): Boolean = - runSerializedLibraryItemPrune(lockFor(libraryId)) { + runSerializedLibraryItemPrune(lockFor(watermarkStore.currentSyncIdentity().flightKey(libraryId))) { audioBookDao.deleteServerBooksByLibrary(libraryId) audioBookDao.hasDownloadedServerBooks(libraryId) } @@ -739,6 +773,11 @@ internal suspend fun pruneServerLibraryTo( * proved what the server holds, the cache is brought in line even if the * caller is cancelled in between. A Partial or Failed fetch keeps the pages * it saved and prunes nothing. + * + * [isCurrent] says whether the account the pass runs for is still signed in. + * Each save and the prune ask it first, and once it says no the pass stops, + * writes nothing more, and fails with [SYNC_ACCOUNT_CHANGED]: the cache now + * belongs to someone else, whose own sync fills and prunes it. */ internal suspend fun runLibraryItemSyncPass( libraryId: String, @@ -749,30 +788,51 @@ internal suspend fun runLibraryItemSyncPass( deleteByIds: suspend (libraryId: String, ids: List) -> Unit, deleteAllServerBooks: suspend (libraryId: String) -> Unit, onPageSaved: suspend (List) -> Unit = {}, + isCurrent: suspend () -> Boolean = { true }, ): RemoteResult { val keptIds = HashSet() - val result = fetchPages { page -> - val merged = mergeItems(page) - withContext(NonCancellable) { - if (merged.isNotEmpty()) upsertAll(merged) + val result = try { + fetchPages { page -> + val merged = mergeItems(page) + val saved = withContext(NonCancellable) { + isCurrent().also { current -> if (current && merged.isNotEmpty()) upsertAll(merged) } + } + // Thrown to stop the download: the rest is for an account that + // is no longer signed in. + if (!saved) throw SyncAccountChangedException() + merged.mapTo(keptIds) { it.id } + onPageSaved(merged) } - merged.mapTo(keptIds) { it.id } - onPageSaved(merged) + } catch (_: SyncAccountChangedException) { + return RemoteResult.Failed(SYNC_ACCOUNT_CHANGED) } + // A streaming fetch turns a throw in onPage into a short result, so + // look again rather than trusting the catch above alone. + if (!isCurrent()) return RemoteResult.Failed(SYNC_ACCOUNT_CHANGED) if (result is RemoteResult.Ok) { - withContext(NonCancellable) { - pruneServerLibraryTo( - keptIds = keptIds, - libraryId = libraryId, - cachedNonDownloadedIds = cachedNonDownloadedIds, - deleteByIds = deleteByIds, - deleteAllServerBooks = deleteAllServerBooks, - ) + val pruned = withContext(NonCancellable) { + isCurrent().also { current -> + if (current) { + pruneServerLibraryTo( + keptIds = keptIds, + libraryId = libraryId, + cachedNonDownloadedIds = cachedNonDownloadedIds, + deleteByIds = deleteByIds, + deleteAllServerBooks = deleteAllServerBooks, + ) + } + } } + if (!pruned) return RemoteResult.Failed(SYNC_ACCOUNT_CHANGED) } return result } +/** Why a sync stopped: the account it ran for is no longer the signed-in one. */ +internal const val SYNC_ACCOUNT_CHANGED = "the signed-in account changed during the sync" + +private class SyncAccountChangedException : Exception(SYNC_ACCOUNT_CHANGED) + /** * [runLibraryItemSyncPass] under [mutex]. Holding the lock across the whole * fetch makes a later caller fetch only after every earlier response has diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt index 579a20bd..f5d92322 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt @@ -46,6 +46,18 @@ internal const val MAX_STORED_WATERMARKS = 64 /** A server and the account signed in to it. Watermarks never cross accounts. */ internal data class SyncAccountKey(val serverUrl: String, val username: String) +/** + * Who a library sync runs for: the server, the account, and which sign-in + * ([sessionHash], a hash of its token, so two token sign-ins that both keep + * an empty username still differ). Flights and per-library locks are keyed + * by this, so a sync started for one account is never shared with, queued + * in front of, or written into the cache for the next one. + */ +internal data class LibrarySyncIdentity(val account: SyncAccountKey, val sessionHash: Int) { + fun flightKey(libraryId: String): String = + listOf(account.serverUrl, account.username, sessionHash.toString(), libraryId).joinToString("\u0000") +} + internal fun List.watermarkFor( key: SyncAccountKey, libraryId: String, diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncWatermarkStore.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncWatermarkStore.kt index 3375d8ed..9b78a224 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncWatermarkStore.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncWatermarkStore.kt @@ -24,6 +24,21 @@ class LibrarySyncWatermarkStore @Inject constructor( return SyncAccountKey(serverUrl = settings.serverUrl, username = settings.username) } + /** + * The server, account, and sign-in a sync runs for. Read again before + * each write, so a sync that outlives its sign-in stops writing. + */ + internal suspend fun currentSyncIdentity(): LibrarySyncIdentity { + val token = try { + settingsManager.getAuthToken() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + return LibrarySyncIdentity(currentAccount(), token?.hashCode() ?: 0) + } + internal fun get(key: SyncAccountKey, libraryId: String): LibrarySyncWatermark? = settingsManager.currentSettings.librarySyncWatermarks.watermarkFor(key, libraryId) diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncSingleFlightTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncSingleFlightTest.kt index 04382ae1..7efadaa5 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncSingleFlightTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncSingleFlightTest.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.yield import org.junit.Assert.assertEquals @@ -132,6 +133,67 @@ class LibrarySyncSingleFlightTest { scope.cancel() } + @Test + fun `a new account never joins the previous account's sync of the same library`() = runBlocking { + val scope = flightScope() + val flights = LibrarySyncSingleFlight(scope) + val server = "https://abs.example.net" + val accountA = LibrarySyncIdentity(SyncAccountKey(server, "alice"), sessionHash = 11) + val accountB = LibrarySyncIdentity(SyncAccountKey(server, "bob"), sessionHash = 22) + // Two token sign-ins keep an empty username and differ only by session. + val tokenA = LibrarySyncIdentity(SyncAccountKey(server, ""), sessionHash = 33) + val tokenB = LibrarySyncIdentity(SyncAccountKey(server, ""), sessionHash = 44) + val started = CompletableDeferred() + val release = CompletableDeferred() + + val slowA = async { + flights.run(accountA.flightKey("lib"), LibrarySyncKind.FULL) { + started.complete(Unit) + release.await() + "alice's catalog" + } + } + started.await() + try { + // Joining alice's flight would wait on her download, so time out. + val bob = withTimeout(5_000) { + flights.run(accountB.flightKey("lib"), LibrarySyncKind.FULL) { "bob's catalog" } + } + assertEquals("bob's catalog", bob) + assertFalse(slowA.isCompleted) + } finally { + release.complete(Unit) + } + assertEquals("alice's catalog", slowA.await()) + assertTrue(tokenA.flightKey("lib") != tokenB.flightKey("lib")) + scope.cancel() + } + + @Test + fun `a download that outlives its account stops writing and prunes nothing`() = runBlocking { + val cache = mutableListOf("bob-book") + var signedIn = "alice" + val result = runLibraryItemSyncPass( + libraryId = "lib", + fetchPages = { onPage -> + onPage(listOf(AudioBook(id = "alice-1"))) + signedIn = "bob" + onPage(listOf(AudioBook(id = "alice-2"))) + RemoteResult.Ok(2) + }, + mergeItems = { it }, + upsertAll = { books -> books.forEach { cache += it.id } }, + cachedNonDownloadedIds = { cache.toList() }, + deleteByIds = { _, ids -> cache.removeAll(ids) }, + deleteAllServerBooks = { cache.clear() }, + isCurrent = { signedIn == "alice" }, + ) + assertEquals(RemoteResult.Failed(SYNC_ACCOUNT_CHANGED), result) + // Page one landed before the switch. Nothing after it, and bob's + // book was not pruned by alice's catalog. + assertEquals(listOf("bob-book", "alice-1"), cache) + } + @Test fun `a caller that leaves does not cancel the sync others are waiting on`() = runBlocking { val scope = flightScope() From f57d2f454ef1ccab9a559354a30b19377bf1a130 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:14:47 -0400 Subject: [PATCH 28/69] Books new to the shelf show the progress the server already has for them A progress pull looks up only the 12 most recently listened books the cache has never seen. For every other book with progress it saved the position row but could not touch the book's shelf fields, and the library list the sync downloads next carries no listening progress. So on a fresh install most books someone had started or finished landed at zero and unfinished, wrong in In Progress, Completed, and hide finished until a later pull. When a sync saves a book that is new to the cache, it now reads the position rows the pull already saved (one batched lookup, no extra requests) and shelves the book with that position, its progress, and its finished flag. The saved row wins only when it is ahead of what the server list sent, the same rule local progress already follows. Co-Authored-By: Claude Opus 5.5 --- .../data/repository/AudioBookRepository.kt | 47 +++++++++++++++++-- .../ninelivesaudio/app/service/SyncManager.kt | 4 +- .../app/data/repository/SyncMergeTest.kt | 33 +++++++++++++ 3 files changed, 80 insertions(+), 4 deletions(-) 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 1bb5d7e0..ab23bc9b 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 @@ -12,11 +12,13 @@ import com.ninelivesaudio.app.data.local.dao.LocalListeningSessionDao import com.ninelivesaudio.app.data.local.dao.PlaybackProgressDao import com.ninelivesaudio.app.data.local.entity.AudioBookEntity import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry +import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.data.local.entity.SyncMergeState import com.ninelivesaudio.app.data.remote.ApiService import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.util.toEpochMillis +import com.ninelivesaudio.app.service.shelfProgress import com.ninelivesaudio.app.service.local.LocalBookFingerprint import com.ninelivesaudio.app.service.local.folderNameOfTrackUri import com.ninelivesaudio.app.service.local.matchMovedLocalBooks @@ -448,7 +450,12 @@ class AudioBookRepository @Inject constructor( // main thread the Library calls from. private suspend fun mergeForSave(remote: List): List = withContext(Dispatchers.Default) { - mergeSyncedBooksLean(remote, audioBookDao::getSyncMergeStates, audioBookDao::getByIds) + mergeSyncedBooksLean( + remote, + audioBookDao::getSyncMergeStates, + audioBookDao::getByIds, + playbackProgressDao::getByAudioBookIds, + ) } private suspend fun saveMerged(books: List) { @@ -870,12 +877,15 @@ internal suspend fun runSerializedLibraryItemPrune( * [mergeSyncedBooks] reading only the columns the merge keeps. The whole row * (audio file and chapter lists) is read only for a downloaded book whose * server copy came back without tracks or chapters, the one case the merge - * borrows them from the local row. + * borrows them from the local row. A book new to the cache takes the + * position the progress pull saved for it ([getProgressRows]), see + * [withPulledProgress]. */ internal suspend fun mergeSyncedBooksLean( remote: List, getMergeStates: suspend (List) -> List, getFullRows: suspend (List) -> List, + getProgressRows: suspend (List) -> List, ): List { if (remote.isEmpty()) return emptyList() val states = fetchByIdChunks(remote.map { it.id }, getMergeStates).associateBy { it.id } @@ -883,7 +893,38 @@ internal suspend fun mergeSyncedBooksLean( states[book.id]?.isDownloaded == 1 && (book.audioFiles.isEmpty() || book.chapters.isEmpty()) }.map { it.id } val details = if (needDetail.isEmpty()) emptyMap() else fetchByIdChunks(needDetail, getFullRows).associateBy { it.id } - return remote.map { book -> mergeSyncedBook(book, states[book.id], details[book.id]) } + val newIds = remote.filter { states[it.id] == null }.map { it.id } + val pulled = if (newIds.isEmpty()) emptyMap() else fetchByIdChunks(newIds, getProgressRows).associateBy { it.audioBookId } + return remote.map { book -> + val state = states[book.id] + if (state == null) withPulledProgress(book, pulled[book.id]) else mergeSyncedBook(book, state, details[book.id]) + } +} + +/** + * A book new to the cache, with the position the progress pull already saved + * for it. The library list carries no listening progress, and a pull fetches + * only the few most recently listened unknown books one by one, so every + * other book with progress used to land on the shelf at zero and unfinished + * (wrong in In Progress, Completed, and hide finished) until a later pull. + * The saved row wins only when it is ahead, as local progress does in + * [mergeSyncedBook]. + */ +internal fun withPulledProgress(remote: AudioBook, row: PlaybackProgressEntity?): AudioBook { + if (row == null) return remote + val finished = row.isFinished == 1 + val remoteTime = remote.currentTime.inWholeMilliseconds / 1000.0 + if (row.positionSeconds <= remoteTime && (!finished || remote.isFinished)) return remote + return remote.copy( + currentTime = row.positionSeconds.seconds, + progress = shelfProgress( + currentTime = row.positionSeconds, + duration = remote.duration.inWholeMilliseconds / 1000.0, + isFinished = finished, + existingProgress = remote.progress, + ), + isFinished = finished || remote.isFinished, + ) } internal fun AudioBookEntity.toSyncMergeState(): SyncMergeState = SyncMergeState( diff --git a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt index 3537592b..7ac20e4c 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt @@ -410,7 +410,9 @@ class SyncManager @Inject constructor( * it is skipped, and an import updates only the book's progress columns. * Books the cache has never seen are looked up one by one only for the * most recently listened few (see [unknownBooksToFetch]). The library - * sync brings the rest, and their progress rows import either way. + * sync brings the rest, and their progress rows import either way, so + * the sync shelves those books with that progress (see + * [com.ninelivesaudio.app.data.repository.withPulledProgress]). */ private suspend fun syncProgress() { try { diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/SyncMergeTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/SyncMergeTest.kt index a2124680..eea2755f 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/repository/SyncMergeTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/SyncMergeTest.kt @@ -1,6 +1,7 @@ package com.ninelivesaudio.app.data.repository import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.data.local.converter.toEntity import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.AudioFile @@ -147,6 +148,7 @@ class SyncMergeTest { ), getMergeStates = { ids -> listOf(downloaded, plain).filter { it.id in ids }.map { it.toSyncMergeState() } }, getFullRows = { ids -> fullRowLookups += ids; listOf(downloaded).filter { it.id in ids } }, + getProgressRows = { emptyList() }, ) assertEquals(listOf(listOf("1")), fullRowLookups) @@ -157,6 +159,37 @@ class SyncMergeTest { assertEquals(AudioBook(id = "3", title = "New"), merged[2]) } + @Test + fun `a book new to the cache takes the progress the pull already saved for it`() = kotlinx.coroutines.runBlocking { + // The library list carries no progress. The pull fetched only 12 + // unknown books one by one, and saved rows for these two anyway. + val progressLookups = mutableListOf>() + val merged = mergeSyncedBooksLean( + remote = listOf( + AudioBook(id = "listening", duration = 1000.seconds), + AudioBook(id = "done", duration = 500.seconds), + AudioBook(id = "untouched", duration = 300.seconds), + ), + getMergeStates = { emptyList() }, + getFullRows = { emptyList() }, + getProgressRows = { ids -> + progressLookups += ids + listOf( + PlaybackProgressEntity(audioBookId = "listening", positionSeconds = 250.0), + PlaybackProgressEntity(audioBookId = "done", positionSeconds = 0.0, isFinished = 1), + ).filter { it.audioBookId in ids } + }, + ) + + assertEquals(listOf(listOf("listening", "done", "untouched")), progressLookups) + assertEquals(250.0, merged[0].currentTime.inWholeMilliseconds / 1000.0, 0.0001) + assertEquals(0.25, merged[0].progress, 0.0001) + assertFalse(merged[0].isFinished) + assertTrue(merged[1].isFinished) + assertEquals(1.0, merged[1].progress, 0.0001) + assertEquals(AudioBook(id = "untouched", duration = 300.seconds), merged[2]) + } + @Test fun `the lean merge matches the whole-row merge`() { val local = localEntity(currentTimeSeconds = 90.0, progress = 0.4, isFinished = 0, archivedAt = 7L) From f2947e82ce26d2d8933121a38496ea3f121e1882 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:15:55 -0400 Subject: [PATCH 29/69] A library list change reaches the Library selector even while a download waits for Wi-Fi A check that put one library's full download off until Wi-Fi wrote no sync record, even when the same check had just saved a changed library list (a library removed on the server, say). The Library reloads its libraries only when a record lands, and its progress listener only re-read the shelf, so the selector kept showing the removed library. Later checks compare against the list already saved and never report the change again. When a check pulls progress, the Library now also compares the libraries it shows with the saved list and reloads them if they differ, the same test a new record already runs. This is the only change to LibraryViewModel. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/library/LibraryViewModel.kt | 41 +++++++++++++++++-- .../ui/library/LibraryReconnectReloadTest.kt | 15 +++++++ 2 files changed, 52 insertions(+), 4 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 84f508a0..8139f4cb 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 @@ -122,6 +122,27 @@ internal fun shouldReloadLibrariesAfterSync( ): Boolean = (selectedLibrary == null && cachedLibraryIds.isNotEmpty()) || cachedLibraryIds.toSet() != shownLibraryIds.toSet() +internal enum class AfterProgressPull { REFILTER, RELOAD_LIBRARIES } + +/** + * What the Library does when a check pulled progress. A check that put one + * library's full download off writes no sync record, yet it already saved + * the library list it fetched (one library removed, say), and later checks + * compare against that saved list and never report the change again. So the + * shown list is compared with the saved one here as well, not only when a + * record lands. + */ +internal fun afterProgressPull( + selectedLibrary: Library?, + shownLibraryIds: List, + cachedLibraryIds: List, +): AfterProgressPull = + if (shouldReloadLibrariesAfterSync(selectedLibrary, shownLibraryIds, cachedLibraryIds)) { + AfterProgressPull.RELOAD_LIBRARIES + } else { + AfterProgressPull.REFILTER + } + /** * Which local folders exist, which books each holds (live or archived), and * what the shelf shows for each. Progress stays out, or every position save @@ -449,13 +470,25 @@ class LibraryViewModel @Inject constructor( } } - // A check that found the book list unchanged writes no sync record, - // so the collector above never sees it. Re-read the saved shelf so - // progress pulled from the server shows while the tab is open. + // A check that found the book list unchanged, or put a download off, + // writes no sync record, so the collector above never sees it. + // Re-read the saved shelf so progress pulled from the server shows + // while the tab is open, and reload the libraries if the check saved + // a changed list (see afterProgressPull). viewModelScope.launch { syncManager.progressPulled.collect { val state = _uiState.value - if (shelfLoadedFor != null && !state.isLoading && !state.isLocalMode) applyFilter() + if (shelfLoadedFor != null && !state.isLoading && !state.isLocalMode) { + val cached = visibleCachedLibraries( + settings = settingsManager.currentSettings, + cached = libraryRepository.getAudiobookshelf(), + ) + when (afterProgressPull(state.selectedLibrary, state.libraries.map { it.id }, cached.map { it.id })) { + AfterProgressPull.RELOAD_LIBRARIES -> + libraryLoadLaunch.launch(viewModelScope) { loadLibrariesOwningRefresh() } + AfterProgressPull.REFILTER -> applyFilter() + } + } } } diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryReconnectReloadTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryReconnectReloadTest.kt index 9ca85d48..4ad7897a 100644 --- a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryReconnectReloadTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryReconnectReloadTest.kt @@ -1,6 +1,7 @@ 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 @@ -28,6 +29,20 @@ class LibraryReconnectReloadTest { assertTrue(shouldReloadLibrariesAfterSync(books, listOf("books", "podcasts"), listOf("books"))) } + @Test + fun `a progress pull from a check that held its record back still reloads a changed library list`() { + // The check pruned "books" from the saved list but wrote no record, + // because another library's full download waits for Wi-Fi. + assertEquals( + AfterProgressPull.RELOAD_LIBRARIES, + afterProgressPull(books, shownLibraryIds = listOf("books", "podcasts"), cachedLibraryIds = listOf("podcasts")), + ) + assertEquals( + AfterProgressPull.REFILTER, + afterProgressPull(books, shownLibraryIds = listOf("books", "podcasts"), cachedLibraryIds = listOf("podcasts", "books")), + ) + } + @Test fun `same libraries with one selected only refilters`() { assertFalse(shouldReloadLibrariesAfterSync(podcasts, listOf("books", "podcasts"), listOf("books", "podcasts"))) From 54b04b11aa1fc709227b7657fa0e87a6ce2f0140 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:12:27 -0400 Subject: [PATCH 30/69] The Dossier reads only the books it has sessions for, off the main thread Each period chip loaded and decoded every book in the active library on the main thread, then used it only to look up the books the listening sessions named. At 50k books that froze every chip tap for a second or more and held about 100 MB. The Dossier now filters the sessions to the period first, asks for just those books by id (500 at a time, still kept to the active library and source), and builds the stats on a background thread. The numbers on screen do not change. Co-Authored-By: Claude Opus 5.5 --- .../DossierBookQueryInstrumentedTest.kt | 33 +++ .../app/data/local/dao/AudioBookDao.kt | 10 + .../data/repository/AudioBookRepository.kt | 15 ++ .../ui/dossier/NightwatchDossierViewModel.kt | 253 ++++++++++-------- .../app/ui/dossier/DossierBookLoadTest.kt | 142 ++++++++++ 5 files changed, 342 insertions(+), 111 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/dossier/DossierBookLoadTest.kt diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/dossier/DossierBookQueryInstrumentedTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/dossier/DossierBookQueryInstrumentedTest.kt index 65076102..05110f50 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/ui/dossier/DossierBookQueryInstrumentedTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/dossier/DossierBookQueryInstrumentedTest.kt @@ -135,6 +135,39 @@ class DossierBookQueryInstrumentedTest { assertEquals("Rich Chapter", queryScope.single().chapters.single().title) } + @Test + fun listenedBookLookupMatchesFormerScopeForThoseIdsOnly() = runBlocking { + val listened = richBook(id = "listened", title = "A Listened") + val listenedArchived = richBook( + id = "listened-archived", + title = "B Archived", + archivedAt = 1_725_769_200_000L, + ) + val notListened = richBook(id = "not-listened", title = "C Shelf Only") + val wrongSource = richBook(id = "wrong-source", title = "D Server", isLocal = 0) + val wrongLibrary = richBook(id = "wrong-library", title = "E Other", libraryId = "other-library") + database.audioBookDao().upsertAll( + listOf(listened, listenedArchived, notListened, wrongSource, wrongLibrary), + ) + val settings = AppSettings( + appMode = AppMode.LOCAL, + selectedLocalLibraryId = ACTIVE_LIBRARY_ID, + ) + val sessionIds = listOf("listened", "listened-archived", "wrong-source", "wrong-library", "gone") + + val former = dossierBooksInActiveScope( + database.audioBookDao().getAll().map { it.toDomain() }, + settings, + ).filter { it.id in sessionIds } + val lookedUp = database.audioBookDao() + .getByIdsInLibraryAndSource(ACTIVE_LIBRARY_ID, isLocal = 1, ids = sessionIds) + .map { it.toDomain() } + + assertEquals(former.map { it.id }.toSet(), lookedUp.map { it.id }.toSet()) + assertEquals(former.sortedBy { it.id }, lookedUp.sortedBy { it.id }) + assertEquals("Rich Chapter", lookedUp.first().chapters.single().title) + } + private fun richBook( id: String, title: String, 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 73b145ef..dfb0cf05 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 @@ -164,6 +164,16 @@ interface AudioBookDao { @Query("UPDATE AudioBooks SET CoverPath = :coverPath WHERE Id = :id") suspend fun updateCoverPath(id: String, coverPath: String) + // ─── Readers outside the Library shelf ─────────────────────────────── + // + // Screens and services that only need a handful of books ask for those + // books, not the whole library. Each query here is bounded by an id list, + // a LIMIT, or a small projection. + + /** Up to 500 books by id, kept to one library and source (the Dossier's listened books). */ + @Query("SELECT * FROM AudioBooks WHERE LibraryId = :libraryId AND IsLocal = :isLocal AND Id IN (:ids)") + suspend fun getByIdsInLibraryAndSource(libraryId: String, isLocal: Int, ids: List): List + @Query("DELETE FROM AudioBooks") suspend fun deleteAll() 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 ab23bc9b..716c01a1 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 @@ -677,6 +677,21 @@ class AudioBookRepository @Inject constructor( suspend fun deleteAll() { audioBookDao.deleteAll() } + + // ─── Readers outside the Library shelf ─────────────────────────────── + + /** + * The books among [ids] that belong to one library and source, looked up + * 500 ids at a time. Ids that are missing or belong elsewhere are left out. + */ + suspend fun getByIdsInLibraryAndSource( + libraryId: String, + isLocal: Boolean, + ids: Collection, + ): List = + fetchByIdChunks(ids.distinct()) { chunk -> + audioBookDao.getByIdsInLibraryAndSource(libraryId, if (isLocal) 1 else 0, chunk) + }.map { it.toDomain() } } private const val MAXIMUM_AUDIOBOOK_LOOKUP_BIND_COUNT = 500 diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierViewModel.kt index fe55b860..d3bae8f1 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierViewModel.kt @@ -21,9 +21,12 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale @@ -332,11 +335,6 @@ class NightwatchDossierViewModel @Inject constructor( return@launch } val allSessions = history.sessions - val scopedBooks = settings.activeLibraryId?.let { libraryId -> - audioBookRepository.getByLibraryAndSource(libraryId, isLocalMode) - }.orEmpty() - val allBooks = dossierBooksInActiveScope(scopedBooks, settings) - val bookMap = allBooks.associateBy { it.id } // Time period window, clamped by entitlement. // @@ -356,115 +354,130 @@ class NightwatchDossierViewModel @Inject constructor( } val cutoffMillis = period.cutoffMillis() val endMillis = period.endMillis() - val recentSessions = allSessions - .filter { it.startedAt in cutoffMillis..endMillis } - .let { dossierSessionsInActiveScope(it, bookMap.keys) } - - // Sanitize session durations: cap timeListening at wall-clock span - val sanitizedSessions = recentSessions.map { session -> - session.copy(timeListening = sanitizeListeningTime(session)) - } - - // Honor the "include archived in stats" toggle: when off, drop - // sessions for archived (LOCAL soft-deleted) books from every - // total below. When on, excludedBookIds is empty (a no-op). - val archivedBookIds = allBooks.filter { it.isArchived }.map { it.id }.toSet() - val includeArchived = settingsManager.currentSettings.includeArchivedInStats - val allowedBookIds = statsBookIds(bookMap.keys, archivedBookIds, includeArchived) - val excludedBookIds = bookMap.keys - allowedBookIds - - // Filter noise: sessions under 60 seconds - val minThreshold = 60.seconds - val aboveThreshold = sanitizedSessions.filter { it.timeListening >= minThreshold } - val noiseSessions = sanitizedSessions.size - aboveThreshold.size - val validSessions = aboveThreshold.filterNot { it.libraryItemId in excludedBookIds } - - // Aggregate - val totalTime = validSessions.fold(Duration.ZERO) { acc, s -> acc + s.timeListening } - val sessionsByBook = validSessions.groupBy { it.libraryItemId } - - // Per-book stats - val bookStats = sessionsByBook.mapNotNull { (bookId, sessions) -> - val book = bookMap[bookId] - val bookTime = sessions.fold(Duration.ZERO) { acc, s -> acc + s.timeListening } - // Skip books with less than 2 minutes total - if (bookTime < 120.seconds) return@mapNotNull null - - val title = book?.title - ?: sessions.firstOrNull()?.displayTitle - ?: "Unknown Title" - - BookStat( - bookId = bookId, - title = title, - author = book?.author ?: "Unknown", - narrator = book?.narrator, - listeningTime = bookTime, - sessionCount = sessions.size, - progress = book?.progressPercent ?: 0.0, - chaptersTotal = book?.chapters?.size ?: 0, - currentChapter = (book?.getCurrentChapterIndex() ?: -1) + 1, - isFinished = book?.isFinished ?: false, - coverUrl = book?.effectiveCoverPath, - whisper = book?.let { generateBookWhisper(it, bookTime) }, - ) - }.sortedByDescending { it.listeningTime } - - // Narrator stats - val narratorStats = buildNarratorStats(bookStats, totalTime) - - // Genre stats - val genreStats = buildGenreStats(bookStats, bookMap, totalTime) - - // Author stats - val authorStats = buildAuthorStats(bookStats) - - // Derived stats - val booksFinished = bookStats.count { it.isFinished } - val dailyAverage = if (totalTime > Duration.ZERO) - (totalTime.inWholeSeconds / period.dayCount()).seconds else Duration.ZERO - val bestDayResult = findBestSpecificDay(validSessions) + val windowSessions = allSessions.filter { it.startedAt in cutoffMillis..endMillis } + + // Only the books this window listened to, looked up by id and + // decoded off the main thread. Everything below is a lookup by + // a session's book id, so the rest of the library never loads. + val bookMap = loadDossierBooks( + sessions = windowSessions, + settings = settings, + fetchByIds = audioBookRepository::getByIdsInLibraryAndSource, + ) + val allBooks = bookMap.values - // Temporal patterns - val (hourly, peakHour) = buildTemporalStats(validSessions) - val peakDay = findPeakDay(validSessions) + // The aggregation walks every session and book, so it runs on + // Default too. Publishing from there is safe, StateFlow updates + // are thread safe. + withContext(Dispatchers.Default) { + val recentSessions = dossierSessionsInActiveScope(windowSessions, bookMap.keys) - // Generate contextual whispers - val headerWhisper = generateHeaderWhisper(totalTime, validSessions.size) - val overviewWhisper = generateOverviewWhisper(totalTime, bookStats.size) - val narratorWhisper = generateNarratorWhisper(narratorStats) - val authorWhisper = generateAuthorWhisper(authorStats) - val genreWhisper = generateGenreWhisper(genreStats) - val temporalWhisper = generateTemporalWhisper(peakHour, peakDay) + // Sanitize session durations: cap timeListening at wall-clock span + val sanitizedSessions = recentSessions.map { session -> + session.copy(timeListening = sanitizeListeningTime(session)) + } - _uiState.update { - it.copy( - isLoading = false, - isConnected = true, - error = null, - historyWarning = history.warning, - totalListeningTime = totalTime, - totalSessions = validSessions.size, - filteredNoiseSessions = noiseSessions, - uniqueBooks = bookStats.size, - bookStats = bookStats, - narratorStats = narratorStats, - authorStats = authorStats, - genreStats = genreStats, - booksFinished = booksFinished, - dailyAverage = dailyAverage, - bestDay = bestDayResult?.first, - bestDayTime = bestDayResult?.second ?: Duration.ZERO, - hourlyDistribution = hourly, - peakHour = peakHour, - peakDayOfWeek = peakDay, - headerWhisper = headerWhisper, - overviewWhisper = overviewWhisper, - narratorWhisper = narratorWhisper, - authorWhisper = authorWhisper, - genreWhisper = genreWhisper, - temporalWhisper = temporalWhisper, - ) + // Honor the "include archived in stats" toggle: when off, drop + // sessions for archived (LOCAL soft-deleted) books from every + // total below. When on, excludedBookIds is empty (a no-op). + val archivedBookIds = allBooks.filter { it.isArchived }.map { it.id }.toSet() + val includeArchived = settingsManager.currentSettings.includeArchivedInStats + val allowedBookIds = statsBookIds(bookMap.keys, archivedBookIds, includeArchived) + val excludedBookIds = bookMap.keys - allowedBookIds + + // Filter noise: sessions under 60 seconds + val minThreshold = 60.seconds + val aboveThreshold = sanitizedSessions.filter { it.timeListening >= minThreshold } + val noiseSessions = sanitizedSessions.size - aboveThreshold.size + val validSessions = aboveThreshold.filterNot { it.libraryItemId in excludedBookIds } + + // Aggregate + val totalTime = validSessions.fold(Duration.ZERO) { acc, s -> acc + s.timeListening } + val sessionsByBook = validSessions.groupBy { it.libraryItemId } + + // Per-book stats + val bookStats = sessionsByBook.mapNotNull { (bookId, sessions) -> + val book = bookMap[bookId] + val bookTime = sessions.fold(Duration.ZERO) { acc, s -> acc + s.timeListening } + // Skip books with less than 2 minutes total + if (bookTime < 120.seconds) return@mapNotNull null + + val title = book?.title + ?: sessions.firstOrNull()?.displayTitle + ?: "Unknown Title" + + BookStat( + bookId = bookId, + title = title, + author = book?.author ?: "Unknown", + narrator = book?.narrator, + listeningTime = bookTime, + sessionCount = sessions.size, + progress = book?.progressPercent ?: 0.0, + chaptersTotal = book?.chapters?.size ?: 0, + currentChapter = (book?.getCurrentChapterIndex() ?: -1) + 1, + isFinished = book?.isFinished ?: false, + coverUrl = book?.effectiveCoverPath, + whisper = book?.let { generateBookWhisper(it, bookTime) }, + ) + }.sortedByDescending { it.listeningTime } + + // Narrator stats + val narratorStats = buildNarratorStats(bookStats, totalTime) + + // Genre stats + val genreStats = buildGenreStats(bookStats, bookMap, totalTime) + + // Author stats + val authorStats = buildAuthorStats(bookStats) + + // Derived stats + val booksFinished = bookStats.count { it.isFinished } + val dailyAverage = if (totalTime > Duration.ZERO) + (totalTime.inWholeSeconds / period.dayCount()).seconds else Duration.ZERO + val bestDayResult = findBestSpecificDay(validSessions) + + // Temporal patterns + val (hourly, peakHour) = buildTemporalStats(validSessions) + val peakDay = findPeakDay(validSessions) + + // Generate contextual whispers + val headerWhisper = generateHeaderWhisper(totalTime, validSessions.size) + val overviewWhisper = generateOverviewWhisper(totalTime, bookStats.size) + val narratorWhisper = generateNarratorWhisper(narratorStats) + val authorWhisper = generateAuthorWhisper(authorStats) + val genreWhisper = generateGenreWhisper(genreStats) + val temporalWhisper = generateTemporalWhisper(peakHour, peakDay) + + _uiState.update { + it.copy( + isLoading = false, + isConnected = true, + error = null, + historyWarning = history.warning, + totalListeningTime = totalTime, + totalSessions = validSessions.size, + filteredNoiseSessions = noiseSessions, + uniqueBooks = bookStats.size, + bookStats = bookStats, + narratorStats = narratorStats, + authorStats = authorStats, + genreStats = genreStats, + booksFinished = booksFinished, + dailyAverage = dailyAverage, + bestDay = bestDayResult?.first, + bestDayTime = bestDayResult?.second ?: Duration.ZERO, + hourlyDistribution = hourly, + peakHour = peakHour, + peakDayOfWeek = peakDay, + headerWhisper = headerWhisper, + overviewWhisper = overviewWhisper, + narratorWhisper = narratorWhisper, + authorWhisper = authorWhisper, + genreWhisper = genreWhisper, + temporalWhisper = temporalWhisper, + ) + } } } catch (e: CancellationException) { // A newer period switch cancelled this stale load. Rethrow so it @@ -826,6 +839,24 @@ internal fun dossierBooksInActiveScope( settings: AppSettings, ): List = books.filter { it.isInActiveLibrary(settings) } +/** + * The books a Dossier report needs: the ones [sessions] listened to, in the + * active library and source, keyed by id. Never reads the whole library, and + * runs on [dispatcher] so decoding the rows stays off the main thread. + */ +internal suspend fun loadDossierBooks( + sessions: List, + settings: AppSettings, + fetchByIds: suspend (libraryId: String, isLocal: Boolean, ids: List) -> List, + dispatcher: CoroutineDispatcher = Dispatchers.Default, +): Map = withContext(dispatcher) { + val libraryId = settings.activeLibraryId ?: return@withContext emptyMap() + val ids = sessions.map { it.libraryItemId }.distinct() + if (ids.isEmpty()) return@withContext emptyMap() + val isLocal = settings.appMode == AppMode.LOCAL + dossierBooksInActiveScope(fetchByIds(libraryId, isLocal, ids), settings).associateBy { it.id } +} + internal fun dossierSessionsInActiveScope( sessions: List, scopedBookIds: Set, diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/dossier/DossierBookLoadTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/dossier/DossierBookLoadTest.kt new file mode 100644 index 00000000..1655ce51 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/dossier/DossierBookLoadTest.kt @@ -0,0 +1,142 @@ +package com.ninelivesaudio.app.ui.dossier + +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AppSettings +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.ListeningSession +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.Executors +import kotlin.time.Duration + +/** + * The Dossier used to load and decode every book in the library on the main + * thread to build stats for the few it had sessions for. At 50k books that + * froze each chip tap for seconds. It now asks for the listened books only. + */ +class DossierBookLoadTest { + + private val serverSettings = AppSettings( + appMode = AppMode.AUDIOBOOKSHELF, + selectedLibraryId = "lib", + ) + + @Test + fun `asks only for the books the sessions listened to, once each`() = runBlocking { + val requests = mutableListOf>>() + val sessions = listOf(session("a"), session("b"), session("a"), session("c")) + + val books = loadDossierBooks(sessions, serverSettings, fetchByIds = { libraryId, isLocal, ids -> + requests += Triple(libraryId, isLocal, ids) + ids.map { book(it) } + }) + + assertEquals(listOf(Triple("lib", false, listOf("a", "b", "c"))), requests) + assertEquals(setOf("a", "b", "c"), books.keys) + } + + @Test + fun `no sessions means no lookup at all`() = runBlocking { + var calls = 0 + val books = loadDossierBooks(emptyList(), serverSettings, fetchByIds = { _, _, _ -> + calls++ + emptyList() + }) + + assertEquals(0, calls) + assertTrue(books.isEmpty()) + } + + @Test + fun `no active library means no lookup and no books`() = runBlocking { + var calls = 0 + val books = loadDossierBooks( + listOf(session("a")), + AppSettings(appMode = AppMode.AUDIOBOOKSHELF), + fetchByIds = { _, _, ids -> + calls++ + ids.map { book(it) } + }, + ) + + assertEquals(0, calls) + assertTrue(books.isEmpty()) + } + + @Test + fun `local mode asks the local library for local books`() = runBlocking { + val settings = AppSettings( + appMode = AppMode.LOCAL, + selectedLibraryId = "server-lib", + selectedLocalLibraryId = "local-lib", + ) + var asked: Pair? = null + + loadDossierBooks(listOf(session("a")), settings, fetchByIds = { libraryId, isLocal, ids -> + asked = libraryId to isLocal + ids.map { book(it, libraryId = libraryId, isLocal = true) } + }) + + assertEquals("local-lib" to true, asked) + } + + @Test + fun `a book from another library or source is still dropped`() = runBlocking { + val books = loadDossierBooks( + listOf(session("good"), session("wrong-lib"), session("wrong-source")), + serverSettings, + fetchByIds = { _, _, _ -> + listOf( + book("good"), + book("wrong-lib", libraryId = "other"), + book("wrong-source", isLocal = true), + ) + }, + ) + + assertEquals(setOf("good"), books.keys) + } + + @Test + fun `the lookup and decoding run off the calling thread`() { + var mainThread: Thread? = null + val fakeMain = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "fake-main").also { mainThread = it } + } + try { + val lookupThread = runBlocking { + withContext(fakeMain.asCoroutineDispatcher()) { + var seen: Thread? = null + loadDossierBooks(listOf(session("a")), serverSettings, fetchByIds = { _, _, ids -> + seen = Thread.currentThread() + ids.map { book(it) } + }) + seen + } + } + + assertTrue(mainThread != null && lookupThread != null) + assertNotSame(mainThread, lookupThread) + } finally { + fakeMain.shutdownNow() + } + } + + private fun book(id: String, libraryId: String = "lib", isLocal: Boolean = false) = + AudioBook(id = id, libraryId = libraryId, isLocal = isLocal) + + private fun session(bookId: String) = ListeningSession( + id = "s-$bookId-${System.nanoTime()}", + libraryItemId = bookId, + currentTime = Duration.ZERO, + timeListening = Duration.ZERO, + startedAt = 0L, + updatedAt = 0L, + displayTitle = null, + ) +} From b119ad5bf823f095b1a9d6f4a8eec711f387b6e0 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:15:12 -0400 Subject: [PATCH 31/69] Android Auto's Library and Downloaded lists read one page from the database Each Auto page loaded every book in the library with its description and other heavy fields, sorted them all in memory, and kept 50. That repeated on every cover refresh and cost about 2 seconds and 100 MB per page at 50k books. A page is now one A to Z query with LIMIT and OFFSET that reads only the columns a car row shows, with the book id breaking title ties so pages never repeat or skip a book. A browser that asks for everything at once gets the first 500 books instead of the whole library. Co-Authored-By: Claude Opus 5.5 --- .../AutoBrowseQueryInstrumentedTest.kt | 119 ++++++++++++++++++ .../app/data/local/dao/AudioBookDao.kt | 31 +++++ .../app/data/local/entity/AutoBrowseRow.kt | 16 +++ .../data/repository/AudioBookRepository.kt | 9 ++ .../app/service/AutoBookItem.kt | 73 +++++++++++ .../app/service/MediaBrowseTree.kt | 53 +++----- .../app/service/AutoBrowsePageTest.kt | 99 +++++++++++++++ .../app/service/MediaBrowseScopeTest.kt | 21 ++-- 8 files changed, 374 insertions(+), 47 deletions(-) create mode 100644 app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/data/local/entity/AutoBrowseRow.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/AutoBrowsePageTest.kt diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt new file mode 100644 index 00000000..5c183fe8 --- /dev/null +++ b/app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt @@ -0,0 +1,119 @@ +package com.ninelivesaudio.app.service + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.ninelivesaudio.app.data.local.AppDatabase +import com.ninelivesaudio.app.data.local.converter.toDomain +import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AppSettings +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** Android Auto's SQL pages return what the old load, sort and drop/take did. */ +@RunWith(AndroidJUnit4::class) +class AutoBrowseQueryInstrumentedTest { + + private lateinit var database: AppDatabase + + private val settings = AppSettings(appMode = AppMode.AUDIOBOOKSHELF, selectedLibraryId = LIBRARY) + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + ).allowMainThreadQueries().build() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun libraryPagesMatchTheFormerInMemoryPaging() = runBlocking { + seed() + val dao = database.audioBookDao() + val former = browseBooksForAuto(dao.getAll().map { it.toDomain() }, settings) + .sortedBy { it.title.lowercase() } + + for (page in 0..3) { + val expected = former.drop(page * PAGE).take(PAGE).map { it.id } + val actual = dao.getAutoBrowsePage(LIBRARY, isLocal = 0, limit = PAGE, offset = page * PAGE).map { it.id } + assertEquals("page $page", expected, actual) + } + } + + @Test + fun downloadedPagesMatchTheFormerInMemoryPaging() = runBlocking { + seed() + val dao = database.audioBookDao() + val former = browseBooksForAuto(dao.getAll().map { it.toDomain() }, settings) + .filter { it.isDownloaded } + .sortedBy { it.title.lowercase() } + + for (page in 0..3) { + val expected = former.drop(page * PAGE).take(PAGE).map { it.id } + val actual = dao.getAutoDownloadedPage(LIBRARY, isLocal = 0, limit = PAGE, offset = page * PAGE).map { it.id } + assertEquals("page $page", expected, actual) + } + } + + @Test + fun equalTitlesNeverRepeatOrSkipAcrossPages() = runBlocking { + val dao = database.audioBookDao() + dao.upsertAll((0 until 9).map { book(id = "same-$it", title = "Same Title") }) + + val paged = (0..2).flatMap { page -> + dao.getAutoBrowsePage(LIBRARY, isLocal = 0, limit = 3, offset = page * 3).map { it.id } + } + + assertEquals((0 until 9).map { "same-$it" }, paged) + } + + private suspend fun seed() { + // Mixed case and distinct titles, so NOCASE and lowercase() agree on order. + val titles = (0 until 40).map { index -> + val word = "Title %02d".format((index * 17) % 40) + if (index % 2 == 0) word.uppercase() else word.lowercase() + } + val books = titles.mapIndexed { index, title -> + book(id = "book-$index", title = title, isDownloaded = if (index % 3 == 0) 1 else 0) + } + listOf( + book(id = "archived", title = "A Archived", archivedAt = 1L, isDownloaded = 1), + book(id = "other-library", title = "A Other", libraryId = "other", isDownloaded = 1), + book(id = "local", title = "A Local", isLocal = 1, isDownloaded = 1), + ) + database.audioBookDao().upsertAll(books) + } + + private fun book( + id: String, + title: String, + libraryId: String = LIBRARY, + isLocal: Int = 0, + isDownloaded: Int = 0, + archivedAt: Long? = null, + ) = AudioBookEntity( + id = id, + libraryId = libraryId, + isLocal = isLocal, + title = title, + author = "Author", + description = "A long description that the light rows never read.", + isDownloaded = isDownloaded, + archivedAt = archivedAt, + genresJson = "[\"Mystery\"]", + ) + + private companion object { + const val LIBRARY = "lib" + const val PAGE = 7 + } +} 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 dfb0cf05..e9ba323b 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 @@ -7,6 +7,7 @@ import androidx.room.Query import androidx.room.RawQuery import androidx.sqlite.db.SupportSQLiteQuery import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.data.local.entity.AutoBrowseRow import com.ninelivesaudio.app.data.local.entity.BookProgressState import com.ninelivesaudio.app.data.local.entity.SyncMergeState import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry @@ -174,6 +175,36 @@ interface AudioBookDao { @Query("SELECT * FROM AudioBooks WHERE LibraryId = :libraryId AND IsLocal = :isLocal AND Id IN (:ids)") suspend fun getByIdsInLibraryAndSource(libraryId: String, isLocal: Int, ids: List): List + /** + * One page of Android Auto's Library list: live books in one library and + * source, A to Z, light columns only. Id breaks title ties so pages never + * overlap or skip. + */ + @Query( + """ + SELECT Id AS id, Title AS title, Author AS author, Narrator AS narrator, + CoverPath AS coverPath, LocalCoverPath AS localCoverPath, GenresJson AS genresJson + FROM AudioBooks + WHERE LibraryId = :libraryId AND IsLocal = :isLocal AND ArchivedAt IS NULL + ORDER BY Title COLLATE NOCASE, Id + LIMIT :limit OFFSET :offset + """ + ) + suspend fun getAutoBrowsePage(libraryId: String, isLocal: Int, limit: Int, offset: Int): List + + /** Same as [getAutoBrowsePage], downloaded books only. */ + @Query( + """ + SELECT Id AS id, Title AS title, Author AS author, Narrator AS narrator, + CoverPath AS coverPath, LocalCoverPath AS localCoverPath, GenresJson AS genresJson + FROM AudioBooks + WHERE LibraryId = :libraryId AND IsLocal = :isLocal AND ArchivedAt IS NULL AND IsDownloaded = 1 + ORDER BY Title COLLATE NOCASE, Id + LIMIT :limit OFFSET :offset + """ + ) + suspend fun getAutoDownloadedPage(libraryId: String, isLocal: Int, limit: Int, offset: Int): List + @Query("DELETE FROM AudioBooks") suspend fun deleteAll() diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/AutoBrowseRow.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/AutoBrowseRow.kt new file mode 100644 index 00000000..6ae701ab --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/AutoBrowseRow.kt @@ -0,0 +1,16 @@ +package com.ninelivesaudio.app.data.local.entity + +/** + * One Android Auto browse or search row: what a car list shows and where its + * cover lives. Description, track list, chapters and tags stay out, so a page + * of 50 reads 50 small rows instead of decoding whole books. + */ +data class AutoBrowseRow( + val id: String, + val title: String, + val author: String? = null, + val narrator: String? = null, + val coverPath: String? = null, + val localCoverPath: String? = null, + val genresJson: String? = null, +) 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 716c01a1..628161de 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 @@ -11,6 +11,7 @@ import com.ninelivesaudio.app.data.local.dao.LocalBookmarkDao import com.ninelivesaudio.app.data.local.dao.LocalListeningSessionDao import com.ninelivesaudio.app.data.local.dao.PlaybackProgressDao import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.data.local.entity.AutoBrowseRow import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.data.local.entity.SyncMergeState @@ -692,6 +693,14 @@ class AudioBookRepository @Inject constructor( fetchByIdChunks(ids.distinct()) { chunk -> audioBookDao.getByIdsInLibraryAndSource(libraryId, if (isLocal) 1 else 0, chunk) }.map { it.toDomain() } + + /** One page of Android Auto's Library list, A to Z, light rows. */ + suspend fun getAutoBrowsePage(libraryId: String, isLocal: Boolean, limit: Int, offset: Int): List = + audioBookDao.getAutoBrowsePage(libraryId, if (isLocal) 1 else 0, limit, offset) + + /** One page of Android Auto's Downloaded list, A to Z, light rows. */ + suspend fun getAutoDownloadedPage(libraryId: String, isLocal: Boolean, limit: Int, offset: Int): List = + audioBookDao.getAutoDownloadedPage(libraryId, if (isLocal) 1 else 0, limit, offset) } private const val MAXIMUM_AUDIOBOOK_LOOKUP_BIND_COUNT = 500 diff --git a/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt b/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt new file mode 100644 index 00000000..fe8aa35e --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt @@ -0,0 +1,73 @@ +package com.ninelivesaudio.app.service + +import com.ninelivesaudio.app.data.local.entity.AutoBrowseRow +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AppSettings +import com.ninelivesaudio.app.domain.model.AudioBook +import kotlinx.serialization.json.Json + +/** + * What an Android Auto row needs from a book: its text and where its cover + * lives. Kept apart from [AudioBook] so a light browse row can never be + * mistaken for a whole book and saved over one. + */ +internal data class AutoBookItem( + val id: String, + val title: String, + val author: String, + val narrator: String?, + val genre: String?, + val coverPath: String?, + val localCoverPath: String?, +) { + val effectiveCoverPath: String? get() = localCoverPath ?: coverPath +} + +internal fun AudioBook.toAutoBookItem(): AutoBookItem = AutoBookItem( + id = id, + title = title, + author = author, + narrator = narrator, + genre = genres.firstOrNull(), + coverPath = coverPath, + localCoverPath = localCoverPath, +) + +private val autoRowJson = Json { ignoreUnknownKeys = true } + +internal fun AutoBrowseRow.toAutoBookItem(): AutoBookItem = AutoBookItem( + id = id, + title = title, + author = author.orEmpty(), + narrator = narrator, + genre = genresJson + ?.let { runCatching { autoRowJson.decodeFromString>(it) }.getOrNull() } + ?.firstOrNull(), + coverPath = coverPath, + localCoverPath = localCoverPath, +) + +/** + * Largest page an Auto list reads in one go. Browsers that do not page (a + * legacy subscribe arrives as page 0 with Int.MAX_VALUE) would otherwise read + * and build a row for every book in the library. + */ +internal const val AUTO_BROWSE_MAX_PAGE_SIZE = 500 + +/** + * One page of an Auto list for the active library and source, read straight + * from SQL with [load] as LIMIT and OFFSET. Nothing outside the page is read. + */ +internal suspend fun autoBrowsePage( + settings: AppSettings, + page: Int, + pageSize: Int, + load: suspend (libraryId: String, isLocal: Boolean, limit: Int, offset: Int) -> List, +): List { + val libraryId = settings.activeLibraryId ?: return emptyList() + if (page < 0 || pageSize <= 0) return emptyList() + val offset = page.toLong() * pageSize.toLong() + if (offset > Int.MAX_VALUE) return emptyList() + val limit = minOf(pageSize, AUTO_BROWSE_MAX_PAGE_SIZE) + return load(libraryId, settings.appMode == AppMode.LOCAL, limit, offset.toInt()) +} diff --git a/app/src/main/java/com/ninelivesaudio/app/service/MediaBrowseTree.kt b/app/src/main/java/com/ninelivesaudio/app/service/MediaBrowseTree.kt index a9669321..1b56d855 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/MediaBrowseTree.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/MediaBrowseTree.kt @@ -147,32 +147,20 @@ class MediaBrowseTree @Inject constructor( .map { (book, _) -> book } }, ) - .map(::bookToMediaItem) + .map { bookToMediaItem(it.toAutoBookItem()) } } - parentId == DOWNLOADED_ID -> { - val libraryId = settings.activeLibraryId ?: return emptyList() - downloadedBooksForAuto( - audioBookRepository.getFilteredBooks(libraryId, downloadedOnly = true), - settings, - ) - .sortedBy { it.title.lowercase() } - .drop(page * pageSize) - .take(pageSize) - .map(::bookToMediaItem) - } + // Library and Downloaded read one page from SQL, A to Z, light + // rows only. Sorting and paging the whole library in memory cost + // seconds and about 100 MB per page at 50k books, and repeated on + // every artwork notify. + parentId == DOWNLOADED_ID -> + autoBrowsePage(settings, page, pageSize, audioBookRepository::getAutoDownloadedPage) + .map { bookToMediaItem(it.toAutoBookItem()) } - parentId == LIBRARY_ID -> { - val libraryId = settings.activeLibraryId ?: return emptyList() - browseBooksForAuto( - audioBookRepository.getFilteredBooks(libraryId), - settings, - ) - .sortedBy { it.title.lowercase() } - .drop(page * pageSize) - .take(pageSize) - .map(::bookToMediaItem) - } + parentId == LIBRARY_ID -> + autoBrowsePage(settings, page, pageSize, audioBookRepository::getAutoBrowsePage) + .map { bookToMediaItem(it.toAutoBookItem()) } else -> emptyList() } @@ -217,7 +205,7 @@ class MediaBrowseTree @Inject constructor( // playable Auto item (e.g. from a stale queued media id). audioBookRepository.getById(bookId) ?.takeIf { browseBooksForAuto(listOf(it), settings).isNotEmpty() } - ?.let(::bookToMediaItem) + ?.let { bookToMediaItem(it.toAutoBookItem()) } } else -> null @@ -230,7 +218,7 @@ class MediaBrowseTree @Inject constructor( val settings = resolveActiveScope() if (!canBrowseAuto(settings, apiService.isAuthenticated)) return emptyList() return browseBooksForAuto(audioBookRepository.search(query), settings) - .map(::bookToMediaItem) + .map { bookToMediaItem(it.toAutoBookItem()) } } /** Extract the original AudioBook ID from a media ID like "book_{id}". */ @@ -263,7 +251,7 @@ class MediaBrowseTree @Inject constructor( // ─── Builders ────────────────────────────────────────────────────── @OptIn(UnstableApi::class) - private fun bookToMediaItem(book: AudioBook): MediaItem { + private fun bookToMediaItem(book: AutoBookItem): MediaItem { val metadataBuilder = MediaMetadata.Builder() .setTitle(book.title) .setArtist(book.author) @@ -276,8 +264,8 @@ class MediaBrowseTree @Inject constructor( metadataBuilder.setComposer(book.narrator) } - if (book.genres.isNotEmpty()) { - metadataBuilder.setGenre(book.genres.first()) + if (!book.genre.isNullOrEmpty()) { + metadataBuilder.setGenre(book.genre) } // Never set artworkUri to the ABS server URL (or a local file:// @@ -323,7 +311,7 @@ class MediaBrowseTree @Inject constructor( * flicker. Never throws — a fetch failure just leaves the row without * art. */ - private fun scheduleArtworkFetch(book: AudioBook) { + private fun scheduleArtworkFetch(book: AutoBookItem) { val bookId = book.id // The epoch gate is checked and set synchronously, BEFORE ever // launching a coroutine, so a book that already had an attempt this @@ -385,7 +373,7 @@ class MediaBrowseTree @Inject constructor( * cover) source directly; falls back to the authenticated remote fetch * for a server cover. Returns null on any failure. */ - private fun fetchArtworkBytes(book: AudioBook): ByteArray? { + private fun fetchArtworkBytes(book: AutoBookItem): ByteArray? { val source = book.localCoverPath ?: book.coverPath ?: return null val uri = runCatching { Uri.parse(source) }.getOrNull() ?: return null // A factory, not an already-open stream: ArtworkCodec may need to @@ -662,11 +650,6 @@ internal fun browseBooksForAuto( !book.isArchived && book.isInActiveLibrary(settings) } -internal fun downloadedBooksForAuto( - books: List, - settings: AppSettings, -): List = browseBooksForAuto(books, settings).filter { it.isDownloaded } - internal suspend fun recentBooksForAuto( settings: AppSettings, maxItems: Int, diff --git a/app/src/test/java/com/ninelivesaudio/app/service/AutoBrowsePageTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/AutoBrowsePageTest.kt new file mode 100644 index 00000000..b4b62442 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/AutoBrowsePageTest.kt @@ -0,0 +1,99 @@ +package com.ninelivesaudio.app.service + +import com.ninelivesaudio.app.data.local.entity.AutoBrowseRow +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AppSettings +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Android Auto's Library and Downloaded lists used to load, decode and sort + * the whole library, then keep 50. They now hand SQL a LIMIT and OFFSET, so + * these pin what gets asked for. + */ +class AutoBrowsePageTest { + + private val server = AppSettings(appMode = AppMode.AUDIOBOOKSHELF, selectedLibraryId = "lib") + + private data class Ask(val libraryId: String, val isLocal: Boolean, val limit: Int, val offset: Int) + + private suspend fun ask(settings: AppSettings, page: Int, pageSize: Int): Ask? { + var asked: Ask? = null + autoBrowsePage(settings, page, pageSize) { libraryId, isLocal, limit, offset -> + asked = Ask(libraryId, isLocal, limit, offset) + emptyList() + } + return asked + } + + @Test + fun `a page asks SQL for exactly that page`() = runBlocking { + assertEquals(Ask("lib", false, 50, 0), ask(server, page = 0, pageSize = 50)) + assertEquals(Ask("lib", false, 50, 150), ask(server, page = 3, pageSize = 50)) + } + + @Test + fun `local mode pages the local library`() = runBlocking { + val local = AppSettings( + appMode = AppMode.LOCAL, + selectedLibraryId = "server-lib", + selectedLocalLibraryId = "local-lib", + ) + assertEquals(Ask("local-lib", true, 20, 20), ask(local, page = 1, pageSize = 20)) + } + + @Test + fun `a browser that does not page still reads a bounded page`() = runBlocking { + assertEquals( + Ask("lib", false, AUTO_BROWSE_MAX_PAGE_SIZE, 0), + ask(server, page = 0, pageSize = Int.MAX_VALUE), + ) + } + + @Test + fun `nonsense paging reads nothing`() = runBlocking { + assertNull(ask(server, page = -1, pageSize = 50)) + assertNull(ask(server, page = 0, pageSize = 0)) + assertNull(ask(server, page = Int.MAX_VALUE, pageSize = Int.MAX_VALUE)) + } + + @Test + fun `no active library reads nothing`() = runBlocking { + assertNull(ask(AppSettings(appMode = AppMode.AUDIOBOOKSHELF), page = 0, pageSize = 50)) + } + + @Test + fun `the page comes back as SQL returned it`() = runBlocking { + val rows = autoBrowsePage(server, page = 0, pageSize = 2) { _, _, limit, _ -> + listOf("b", "a", "c").take(limit) + } + assertEquals(listOf("b", "a"), rows) + } + + @Test + fun `a light row keeps the first genre and its covers`() { + val item = AutoBrowseRow( + id = "x", + title = "Title", + author = null, + narrator = "Reader", + coverPath = "https://abs.test/api/items/x/cover", + localCoverPath = "file:///covers/x.jpg", + genresJson = """["Mystery","Noir"]""", + ).toAutoBookItem() + + assertEquals("Mystery", item.genre) + assertEquals("", item.author) + assertEquals("file:///covers/x.jpg", item.effectiveCoverPath) + } + + @Test + fun `broken or empty genres read as none`() { + assertNull(AutoBrowseRow(id = "x", title = "T", genresJson = "not json").toAutoBookItem().genre) + assertNull(AutoBrowseRow(id = "x", title = "T", genresJson = "[]").toAutoBookItem().genre) + assertTrue(AutoBrowseRow(id = "x", title = "T").toAutoBookItem().genre == null) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/MediaBrowseScopeTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/MediaBrowseScopeTest.kt index 69005fd7..bb80d881 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/MediaBrowseScopeTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/MediaBrowseScopeTest.kt @@ -55,23 +55,20 @@ class MediaBrowseScopeTest { } @Test - fun `downloaded shelf stays inside the selected server library`() { + fun `downloaded shelf stays inside the selected server library`() = runBlocking { val settings = AppSettings( appMode = AppMode.AUDIOBOOKSHELF, selectedLibraryId = "server-active", + selectedLocalLibraryId = "local", ) - val books = listOf( - AudioBook(id = "active", libraryId = "server-active", isDownloaded = true), - AudioBook(id = "other", libraryId = "server-other", isDownloaded = true), - AudioBook(id = "stream-only", libraryId = "server-active", isDownloaded = false), - AudioBook(id = "local", libraryId = "local", isLocal = true, isDownloaded = true), - AudioBook(id = "archived", libraryId = "server-active", isDownloaded = true, archivedAt = 1L), - ) + var asked: Pair? = null - assertEquals( - listOf("active"), - downloadedBooksForAuto(books, settings).map { it.id }, - ) + autoBrowsePage(settings, page = 0, pageSize = 50) { libraryId, isLocal, _, _ -> + asked = libraryId to isLocal + emptyList() + } + + assertEquals("server-active" to false, asked) } @Test From 4f633b376a220b13210f3e01c21c8522acd475ae Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:17:53 -0400 Subject: [PATCH 32/69] Android Auto search stays in the active library and lists at most 100 hits Auto search looked through every library, returned the whole catalog for an empty query, and on every page request built a row and started a cover download for every match. Covers came at full size, often several MB each, over the car's data. Search now runs in SQL inside the active library and source, matches % and _ literally, stops at 100 hits, and builds rows and fetches covers only for the page Auto asks for. An empty query finds nothing. Auto's cover downloads ask the server for 256px wide images, the size they are shrunk to anyway. Co-Authored-By: Claude Opus 5.5 --- .../AutoBrowseQueryInstrumentedTest.kt | 28 ++++++ .../app/data/local/dao/AudioBookDao.kt | 30 ++++++ .../data/repository/AudioBookRepository.kt | 14 +++ .../app/service/AutoBookItem.kt | 54 ++++++++++ .../app/service/MediaBrowseTree.kt | 33 ++++++- .../app/service/PlaybackService.kt | 10 +- .../app/service/AutoSearchTest.kt | 99 +++++++++++++++++++ 7 files changed, 258 insertions(+), 10 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/AutoSearchTest.kt 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 5c183fe8..e64d18cd 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt @@ -77,6 +77,34 @@ class AutoBrowseQueryInstrumentedTest { assertEquals((0 until 9).map { "same-$it" }, paged) } + @Test + fun searchStaysInScopeMatchesWildcardsLiterallyAndCounts() = runBlocking { + val dao = database.audioBookDao() + dao.upsertAll( + listOf( + book(id = "pct", title = "100% Wolf"), + book(id = "plain", title = "100 Wolves"), + book(id = "under", title = "a_b"), + book(id = "underless", title = "axb"), + book(id = "by-author", title = "Other").copy(author = "Wolfgang"), + book(id = "archived", title = "Wolf Archived", archivedAt = 1L), + book(id = "other-library", title = "Wolf Elsewhere", libraryId = "other"), + book(id = "local", title = "Wolf Local", isLocal = 1), + ), + ) + + fun ids(rows: List) = rows.map { it.id } + + assertEquals(listOf("pct"), ids(dao.searchAutoPage(LIBRARY, 0, autoSearchPattern("100%")!!, 100, 0))) + assertEquals(listOf("under"), ids(dao.searchAutoPage(LIBRARY, 0, autoSearchPattern("a_b")!!, 100, 0))) + assertEquals( + listOf("pct", "by-author"), + ids(dao.searchAutoPage(LIBRARY, 0, autoSearchPattern("wolf")!!, 100, 0)), + ) + assertEquals(2, dao.countAutoSearch(LIBRARY, 0, autoSearchPattern("wolf")!!, 100)) + assertEquals(1, dao.countAutoSearch(LIBRARY, 0, autoSearchPattern("wolf")!!, 1)) + } + 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/dao/AudioBookDao.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt index e9ba323b..355b9106 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 @@ -205,6 +205,36 @@ 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). + */ + @Query( + """ + SELECT Id AS id, Title AS title, Author AS author, Narrator AS narrator, + 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 '\') + ORDER BY Title COLLATE NOCASE, Id + LIMIT :limit OFFSET :offset + """ + ) + suspend fun searchAutoPage(libraryId: String, isLocal: Int, pattern: String, limit: Int, offset: Int): List + + /** How many rows [searchAutoPage] can return, counting no further than [cap]. */ + @Query( + """ + 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 '\') + LIMIT :cap + ) + """ + ) + suspend fun countAutoSearch(libraryId: String, isLocal: Int, pattern: String, cap: Int): Int + @Query("DELETE FROM AudioBooks") suspend fun deleteAll() 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 628161de..12d4824b 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 @@ -701,6 +701,20 @@ class AudioBookRepository @Inject constructor( /** One page of Android Auto's Downloaded list, A to Z, light rows. */ suspend fun getAutoDownloadedPage(libraryId: String, isLocal: Boolean, limit: Int, offset: Int): List = audioBookDao.getAutoDownloadedPage(libraryId, if (isLocal) 1 else 0, limit, offset) + + /** One page of Android Auto search hits for an escaped LIKE [pattern], light rows. */ + suspend fun searchAutoPage( + libraryId: String, + isLocal: Boolean, + pattern: String, + limit: Int, + offset: Int, + ): List = + audioBookDao.searchAutoPage(libraryId, if (isLocal) 1 else 0, pattern, limit, offset) + + /** How many Android Auto search hits [pattern] has, counting no further than [cap]. */ + suspend fun countAutoSearch(libraryId: String, isLocal: Boolean, pattern: String, cap: Int): Int = + audioBookDao.countAutoSearch(libraryId, if (isLocal) 1 else 0, pattern, cap) } private const val MAXIMUM_AUDIOBOOK_LOOKUP_BIND_COUNT = 500 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 fe8aa35e..49f647ec 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt @@ -71,3 +71,57 @@ internal suspend fun autoBrowsePage( val limit = minOf(pageSize, AUTO_BROWSE_MAX_PAGE_SIZE) return load(libraryId, settings.appMode == AppMode.LOCAL, limit, offset.toInt()) } + +/** + * Most search hits Auto ever lists. A one letter query in a big library would + * otherwise build a row and fetch a cover for thousands of books. + */ +internal const val AUTO_SEARCH_RESULT_CAP = 100 + +/** + * [query] as a LIKE "contains" pattern with backslash escapes, so `%`, `_` + * and `\` match themselves. Null for a blank query, which searches nothing. + */ +internal fun autoSearchPattern(query: String): String? { + val trimmed = query.trim() + if (trimmed.isEmpty()) return null + val escaped = buildString(trimmed.length + 8) { + for (char in trimmed) { + if (char == '\\' || char == '%' || char == '_') append('\\') + append(char) + } + } + return "%$escaped%" +} + +/** + * One page of Auto search hits in the active library and source, never past + * [AUTO_SEARCH_RESULT_CAP]. A blank query or no active library reads nothing. + */ +internal suspend fun autoSearchPage( + settings: AppSettings, + query: String, + page: Int, + pageSize: Int, + load: suspend (libraryId: String, isLocal: Boolean, pattern: String, limit: Int, offset: Int) -> List, +): List { + val libraryId = settings.activeLibraryId ?: return emptyList() + val pattern = autoSearchPattern(query) ?: return emptyList() + if (page < 0 || pageSize <= 0) return emptyList() + val offset = page.toLong() * pageSize.toLong() + if (offset >= AUTO_SEARCH_RESULT_CAP) return emptyList() + val limit = minOf(pageSize.toLong(), AUTO_SEARCH_RESULT_CAP - offset).toInt() + return load(libraryId, settings.appMode == AppMode.LOCAL, pattern, limit, offset.toInt()) +} + +/** How many hits [autoSearchPage] can list for [query], at most [AUTO_SEARCH_RESULT_CAP]. */ +internal suspend fun autoSearchCount( + settings: AppSettings, + query: String, + count: suspend (libraryId: String, isLocal: Boolean, pattern: String, cap: Int) -> Int, +): Int { + val libraryId = settings.activeLibraryId ?: return 0 + val pattern = autoSearchPattern(query) ?: return 0 + return count(libraryId, settings.appMode == AppMode.LOCAL, pattern, AUTO_SEARCH_RESULT_CAP) + .coerceIn(0, AUTO_SEARCH_RESULT_CAP) +} diff --git a/app/src/main/java/com/ninelivesaudio/app/service/MediaBrowseTree.kt b/app/src/main/java/com/ninelivesaudio/app/service/MediaBrowseTree.kt index 1b56d855..30d35df9 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/MediaBrowseTree.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/MediaBrowseTree.kt @@ -14,6 +14,7 @@ import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.AppSettings import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.isInActiveLibrary +import com.ninelivesaudio.app.ui.components.thumbnailCoverUrl import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -212,12 +213,28 @@ class MediaBrowseTree @Inject constructor( } } - /** Search books by title/author. */ - suspend fun search(query: String): List { + /** + * How many books match [query] by title or author in the active library + * and source, at most [AUTO_SEARCH_RESULT_CAP]. Counts in SQL, so no rows + * are built and no covers are fetched. + */ + suspend fun searchCount(query: String): Int { + apiService.awaitAuthReady() + val settings = resolveActiveScope() + if (!canBrowseAuto(settings, apiService.isAuthenticated)) return 0 + return autoSearchCount(settings, query, audioBookRepository::countAutoSearch) + } + + /** + * One page of search hits, read from SQL. Rows and cover fetches happen + * for this page only. A blank query finds nothing rather than the whole + * library. + */ + suspend fun search(query: String, page: Int, pageSize: Int): List { apiService.awaitAuthReady() val settings = resolveActiveScope() if (!canBrowseAuto(settings, apiService.isAuthenticated)) return emptyList() - return browseBooksForAuto(audioBookRepository.search(query), settings) + return autoSearchPage(settings, query, page, pageSize, audioBookRepository::searchAutoPage) .map { bookToMediaItem(it.toAutoBookItem()) } } @@ -399,7 +416,7 @@ class MediaBrowseTree @Inject constructor( } } - val remoteUrl = book.coverPath?.takeIf { it.startsWith("http") } ?: return null + val remoteUrl = autoArtworkRemoteUrl(book.coverPath, ARTWORK_MAX_DIMENSION) ?: return null // A factory rather than a single execute(): ArtworkCodec may need to // re-issue the request on a rare bounds-mark overrun (see its kdoc). val remoteOpener = { @@ -643,6 +660,14 @@ internal class ArtworkFetchEpoch( } } +/** + * The server cover address an Auto row fetches, asking for [widthPx] wide. + * Without a width Audiobookshelf sends the original, often several MB, only + * for it to be shrunk to browse size here. Null for anything not http(s). + */ +internal fun autoArtworkRemoteUrl(coverPath: String?, widthPx: Int): String? = + coverPath?.takeIf { it.startsWith("http") }?.let { thumbnailCoverUrl(it, widthPx) } + internal fun browseBooksForAuto( books: List, settings: AppSettings, diff --git a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackService.kt b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackService.kt index 0be6b6d8..4b5daa57 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackService.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackService.kt @@ -427,9 +427,9 @@ class PlaybackService : MediaLibraryService() { // Trigger async search; results delivered via onGetSearchResult serviceScope.launch(Dispatchers.IO) { try { - val results = mediaBrowseTree.search(query) - Log.d(TAG, "onSearch: query='$query' → ${results.size} results") - session.notifySearchResultChanged(browser, query, results.size, params) + val count = mediaBrowseTree.searchCount(query) + Log.d(TAG, "onSearch: query='$query' → $count results") + session.notifySearchResultChanged(browser, query, count, params) } catch (e: Exception) { Log.e(TAG, "onSearch($query) failed: ${e.message}", e) } @@ -453,9 +453,7 @@ class PlaybackService : MediaLibraryService() { } return serviceScope.future(Dispatchers.IO) { try { - val results = mediaBrowseTree.search(query) - .drop(page * pageSize) - .take(pageSize) + val results = mediaBrowseTree.search(query, page, pageSize) Log.d(TAG, "onGetSearchResult: query='$query' → ${results.size} results") LibraryResult.ofItemList(ImmutableList.copyOf(results), params) } catch (e: Exception) { diff --git a/app/src/test/java/com/ninelivesaudio/app/service/AutoSearchTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/AutoSearchTest.kt new file mode 100644 index 00000000..ceb22688 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/AutoSearchTest.kt @@ -0,0 +1,99 @@ +package com.ninelivesaudio.app.service + +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AppSettings +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Auto search used to scan every library, return the whole catalog for an + * empty query, and build a row and fetch a full-size cover for every match on + * every page request. It now asks SQL for one capped page in one library. + */ +class AutoSearchTest { + + private val server = AppSettings(appMode = AppMode.AUDIOBOOKSHELF, selectedLibraryId = "lib") + + private data class Ask(val libraryId: String, val isLocal: Boolean, val pattern: String, val limit: Int, val offset: Int) + + private suspend fun ask(settings: AppSettings, query: String, page: Int, pageSize: Int): Ask? { + var asked: Ask? = null + autoSearchPage(settings, query, page, pageSize) { libraryId, isLocal, pattern, limit, offset -> + asked = Ask(libraryId, isLocal, pattern, limit, offset) + emptyList() + } + return asked + } + + @Test + fun `the query becomes a contains pattern that matches wildcards literally`() { + assertEquals("%dune%", autoSearchPattern(" dune ")) + assertEquals("%100\\%%", autoSearchPattern("100%")) + assertEquals("%a\\_b%", autoSearchPattern("a_b")) + assertEquals("%c:\\\\x%", autoSearchPattern("c:\\x")) + } + + @Test + fun `a blank query searches nothing`() = runBlocking { + assertNull(autoSearchPattern("")) + assertNull(autoSearchPattern(" ")) + assertNull(ask(server, "", page = 0, pageSize = 50)) + assertEquals(0, autoSearchCount(server, " ") { _, _, _, _ -> error("must not count") }) + } + + @Test + fun `search stays in the active library and source`() = runBlocking { + val local = AppSettings( + appMode = AppMode.LOCAL, + selectedLibraryId = "server-lib", + selectedLocalLibraryId = "local-lib", + ) + assertEquals(Ask("lib", false, "%x%", 20, 0), ask(server, "x", page = 0, pageSize = 20)) + assertEquals(Ask("local-lib", true, "%x%", 20, 0), ask(local, "x", page = 0, pageSize = 20)) + assertNull(ask(AppSettings(appMode = AppMode.AUDIOBOOKSHELF), "x", page = 0, pageSize = 20)) + } + + @Test + fun `pages stop at the result cap`() = runBlocking { + assertEquals(Ask("lib", false, "%a%", 50, 50), ask(server, "a", page = 1, pageSize = 50)) + assertEquals(Ask("lib", false, "%a%", 40, 60), ask(server, "a", page = 1, pageSize = 60)) + assertNull(ask(server, "a", page = 2, pageSize = 50)) + assertEquals( + Ask("lib", false, "%a%", AUTO_SEARCH_RESULT_CAP, 0), + ask(server, "a", page = 0, pageSize = Int.MAX_VALUE), + ) + assertNull(ask(server, "a", page = -1, pageSize = 50)) + assertNull(ask(server, "a", page = 0, pageSize = 0)) + } + + @Test + fun `the count never promises more than the cap`() = runBlocking { + var askedCap = -1 + val count = autoSearchCount(server, "a") { libraryId, isLocal, pattern, cap -> + assertEquals("lib", libraryId) + assertEquals(false, isLocal) + assertEquals("%a%", pattern) + askedCap = cap + 5_000 + } + assertEquals(AUTO_SEARCH_RESULT_CAP, askedCap) + assertEquals(AUTO_SEARCH_RESULT_CAP, count) + assertEquals(7, autoSearchCount(server, "a") { _, _, _, _ -> 7 }) + } + + @Test + fun `Auto asks the server for browse-size covers`() { + assertEquals( + "https://abs.test/api/items/x/cover?width=256", + autoArtworkRemoteUrl("https://abs.test/api/items/x/cover", 256), + ) + assertEquals( + "https://abs.test/api/items/x/cover?ts=1&width=256", + autoArtworkRemoteUrl("https://abs.test/api/items/x/cover?ts=1", 256), + ) + assertNull(autoArtworkRemoteUrl("file:///covers/x.jpg", 256)) + assertNull(autoArtworkRemoteUrl(null, 256)) + } +} From 6186a78d29585e93b0fea706e0e02ddc9d224817 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:19:33 -0400 Subject: [PATCH 33/69] The free download slot check reads only downloaded books Working out which book holds a free install's one download slot read and decoded every book in every library, at cold start and two or three times per download attempt. At 50k books that was about 115 MB each time. It now reads the downloaded books plus any book a download row names, 500 ids at a time, and only the four fields the slot rules use. The winner it picks does not change. The startup comment that said this costs one query now says what it does. Co-Authored-By: Claude Opus 5.5 --- .../com/ninelivesaudio/app/NineLivesApp.kt | 5 +- .../app/data/local/dao/AudioBookDao.kt | 9 ++ .../app/data/local/entity/SlotBookRow.kt | 13 ++ .../app/service/download/DownloadSlotStore.kt | 106 +++++++++---- .../download/DownloadSlotCandidatesTest.kt | 147 ++++++++++++++++++ 5 files changed, 248 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/data/local/entity/SlotBookRow.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/download/DownloadSlotCandidatesTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt b/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt index 68e318e4..d191d640 100644 --- a/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt +++ b/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt @@ -183,8 +183,9 @@ class NineLivesApp : Application(), ImageLoaderFactory { // Re-resolve the download slot whenever entitlement is or becomes free. // Deliberately includes the initial emission rather than transitions // only, so a downgrade that happened while the app was dead is still - // handled. Resolution early-returns when there is nothing over cap, so - // the common case costs one query and never touches the worker. + // handled. Resolution reads only download rows and downloaded books, + // a few small queries, and early-returns when there is nothing over + // cap, so the common case never touches the worker. appScope.launch { entitlementRepository.state .map { it.isUnlocked } 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 355b9106..81cec4fe 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 @@ -13,6 +13,7 @@ import com.ninelivesaudio.app.data.local.entity.SyncMergeState import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.data.local.entity.RecentlyPlayedResult +import com.ninelivesaudio.app.data.local.entity.SlotBookRow import kotlinx.coroutines.flow.Flow @Dao @@ -235,6 +236,14 @@ interface AudioBookDao { ) suspend fun countAutoSearch(libraryId: String, isLocal: Int, pattern: String, cap: Int): Int + /** Slot fields of every downloaded book, in any library. */ + @Query("SELECT Id AS id, IsLocal AS isLocal, IsDownloaded AS isDownloaded, LocalPath AS localPath FROM AudioBooks WHERE IsDownloaded = 1") + suspend fun getDownloadedSlotRows(): List + + /** Slot fields for up to 500 books by id. */ + @Query("SELECT Id AS id, IsLocal AS isLocal, IsDownloaded AS isDownloaded, LocalPath AS localPath FROM AudioBooks WHERE Id IN (:ids)") + suspend fun getSlotRowsByIds(ids: List): List + @Query("DELETE FROM AudioBooks") suspend fun deleteAll() diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/SlotBookRow.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/SlotBookRow.kt new file mode 100644 index 00000000..83771fc8 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/SlotBookRow.kt @@ -0,0 +1,13 @@ +package com.ninelivesaudio.app.data.local.entity + +/** + * The four AudioBooks fields the free tier's download slot reads. Kept small + * so building the slot candidates never decodes descriptions, track lists or + * chapters. + */ +data class SlotBookRow( + val id: String, + val isLocal: Int, + val isDownloaded: Int, + val localPath: String?, +) 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 8532643b..294c9bf3 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 @@ -4,6 +4,8 @@ import com.ninelivesaudio.app.data.local.converter.toDomain import com.ninelivesaudio.app.data.local.dao.AudioBookDao import com.ninelivesaudio.app.data.local.dao.DownloadItemDao import com.ninelivesaudio.app.data.local.dao.PlaybackProgressDao +import com.ninelivesaudio.app.data.local.entity.SlotBookRow +import com.ninelivesaudio.app.domain.model.DownloadItem import com.ninelivesaudio.app.domain.model.DownloadStatus import com.ninelivesaudio.app.entitlement.DownloadSlotResolver import com.ninelivesaudio.app.entitlement.EntitlementCachePrefs @@ -64,36 +66,20 @@ class DownloadSlotStore @Inject constructor( val rows = downloadItemDao.getAll().map { it.toDomain() } val rowsByBook = rows.groupBy { it.audioBookId } - val offlineBooks = audioBookDao.getAll() - .map { it.toDomain() } - .filter { it.isDownloaded || rowsByBook.containsKey(it.id) } - - // Keyed off AudioBooks rather than download rows, so a claim whose book - // record has gone is naturally reported as unusable rather than being - // silently dropped from the candidate set. - val bookIds = (offlineBooks.map { it.id } + rowsByBook.keys).distinct() - - bookIds.map { bookId -> - val book = offlineBooks.firstOrNull { it.id == bookId } - // The newest row wins when duplicates exist. The pre-existing queue - // race can already have left more than one row per book on upgraded - // installs, and no dedupe migration runs. - val row = rowsByBook[bookId] - ?.maxByOrNull { it.startedAt ?: Long.MIN_VALUE } - - SlotCandidate( - audioBookId = bookId, - isLocal = book?.isLocal ?: false, - hasAudioBookRecord = book != null, - downloadStatus = row?.status, - isDownloaded = book?.isDownloaded ?: false, - hasLocalPath = !book?.localPath.isNullOrBlank(), - filesExist = filesExist(book?.localPath), - progressUpdatedAt = progressMillis(bookId), - completedAt = row?.completedAt, - startedAt = row?.startedAt, - ) - } + // Only downloaded books and books with a download row can hold the + // slot, so only those rows are read, and only their slot fields. + val books = loadSlotBooks( + rowBookIds = rowsByBook.keys, + getDownloaded = audioBookDao::getDownloadedSlotRows, + getByIds = audioBookDao::getSlotRowsByIds, + ) + + assembleSlotCandidates( + rowsByBook = rowsByBook, + books = books, + filesExist = ::filesExist, + progressMillis = { progressMillis(it) }, + ) } /** Resolve, persist and return the winner. */ @@ -145,6 +131,66 @@ class DownloadSlotStore @Inject constructor( } } +/** Largest id list bound in one slot lookup, safely under SQLite's 999. */ +private const val SLOT_LOOKUP_CHUNK = 500 + +/** + * The AudioBooks rows that can matter to the slot: every downloaded book plus + * any book named by a download row. Keyed by id. Never reads the rest of the + * library. + */ +internal suspend fun loadSlotBooks( + rowBookIds: Collection, + getDownloaded: suspend () -> List, + getByIds: suspend (List) -> List, +): Map { + val books = LinkedHashMap() + getDownloaded().forEach { books[it.id] = it } + val missing = rowBookIds.filterNot { it in books }.distinct() + for (chunk in missing.chunked(SLOT_LOOKUP_CHUNK)) { + getByIds(chunk).forEach { books[it.id] = it } + } + return books +} + +/** + * One candidate per book that is downloaded or has a download row. + * + * Keyed off AudioBooks as well as download rows, so a claim whose book record + * has gone is reported as unusable rather than silently dropped. + */ +internal suspend fun assembleSlotCandidates( + rowsByBook: Map>, + books: Map, + filesExist: (String?) -> Boolean, + progressMillis: suspend (String) -> Long?, +): List { + val offlineBooks = books.values.filter { it.isDownloaded == 1 || rowsByBook.containsKey(it.id) } + val offlineById = offlineBooks.associateBy { it.id } + val bookIds = (offlineBooks.map { it.id } + rowsByBook.keys).distinct() + + return bookIds.map { bookId -> + val book = offlineById[bookId] + // The newest row wins when duplicates exist. The pre-existing queue + // race can already have left more than one row per book on upgraded + // installs, and no dedupe migration runs. + val row = rowsByBook[bookId]?.maxByOrNull { it.startedAt ?: Long.MIN_VALUE } + + SlotCandidate( + audioBookId = bookId, + isLocal = book?.isLocal == 1, + hasAudioBookRecord = book != null, + downloadStatus = row?.status, + isDownloaded = book?.isDownloaded == 1, + hasLocalPath = !book?.localPath.isNullOrBlank(), + filesExist = filesExist(book?.localPath), + progressUpdatedAt = progressMillis(bookId), + completedAt = row?.completedAt, + startedAt = row?.startedAt, + ) + } +} + /** Statuses that mean a row is a live or finished occupant of the slot. */ internal val SLOT_OCCUPYING_STATUSES = setOf( DownloadStatus.Preparing, diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadSlotCandidatesTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadSlotCandidatesTest.kt new file mode 100644 index 00000000..af4aad65 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadSlotCandidatesTest.kt @@ -0,0 +1,147 @@ +package com.ninelivesaudio.app.service.download + +import com.ninelivesaudio.app.data.local.entity.SlotBookRow +import com.ninelivesaudio.app.domain.model.DownloadItem +import com.ninelivesaudio.app.domain.model.DownloadStatus +import com.ninelivesaudio.app.entitlement.SlotCandidate +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The free-tier slot check used to read and decode every book in every + * library, at cold start and on every download claim. It now reads only the + * downloaded books and the books named by download rows, and only four + * columns of each. These pin that the candidates come out the same. + */ +class DownloadSlotCandidatesTest { + + @Test + fun `only downloaded books and books with rows are read`() = runBlocking { + val askedById = mutableListOf>() + var downloadedCalls = 0 + + val books = loadSlotBooks( + rowBookIds = listOf("queued", "done", "queued"), + getDownloaded = { + downloadedCalls++ + listOf(slot("done", downloaded = true), slot("kept-offline", downloaded = true)) + }, + getByIds = { ids -> + askedById += ids + ids.filter { it != "gone" }.map { slot(it) } + }, + ) + + assertEquals(1, downloadedCalls) + // "done" already came back as downloaded, so only "queued" is looked up. + assertEquals(listOf(listOf("queued")), askedById) + assertEquals(setOf("done", "kept-offline", "queued"), books.keys) + } + + @Test + fun `row lookups are chunked under the bind limit`() = runBlocking { + val chunks = mutableListOf() + loadSlotBooks( + rowBookIds = (0 until 1_201).map { "b$it" }, + getDownloaded = { emptyList() }, + getByIds = { ids -> + chunks += ids.size + emptyList() + }, + ) + + assertEquals(listOf(500, 500, 201), chunks) + } + + @Test + fun `no download rows means no id lookup`() = runBlocking { + var byIdCalls = 0 + loadSlotBooks( + rowBookIds = emptyList(), + getDownloaded = { emptyList() }, + getByIds = { + byIdCalls++ + emptyList() + }, + ) + assertEquals(0, byIdCalls) + } + + @Test + fun `candidates match the former whole-library builder`() = runBlocking { + // Every case the old builder distinguished: downloaded with and without + // a row, a row whose book is gone, a local book, a stream-only book + // with a row, duplicate rows, and a plain shelf book that must not show. + val library = listOf( + slot("downloaded-no-row", downloaded = true, path = "/b/1"), + slot("downloaded-with-row", downloaded = true, path = "/b/2"), + slot("local", local = true, downloaded = true, path = "/b/3"), + slot("queued-not-downloaded"), + slot("shelf-only"), + slot("blank-path", downloaded = true, path = " "), + ) + val rows = listOf( + row("downloaded-with-row", DownloadStatus.Completed, startedAt = 10, completedAt = 20), + row("queued-not-downloaded", DownloadStatus.Queued, startedAt = 30), + row("book-gone", DownloadStatus.Paused, startedAt = 5), + row("downloaded-with-row", DownloadStatus.Failed, startedAt = 40), + ) + val rowsByBook = rows.groupBy { it.audioBookId } + val filesExist: (String?) -> Boolean = { it == "/b/1" || it == "/b/3" } + val progress: suspend (String) -> Long? = { id -> if (id == "local") 99L else null } + + val narrowed = loadSlotBooks( + rowBookIds = rowsByBook.keys, + getDownloaded = { library.filter { it.isDownloaded == 1 } }, + getByIds = { ids -> library.filter { it.id in ids } }, + ) + val actual = assembleSlotCandidates(rowsByBook, narrowed, filesExist, progress) + val former = formerBuilder(library, rowsByBook, filesExist, progress) + + assertEquals(former.sortedBy { it.audioBookId }, actual.sortedBy { it.audioBookId }) + assertTrue(actual.none { it.audioBookId == "shelf-only" }) + assertEquals(DownloadStatus.Failed, actual.single { it.audioBookId == "downloaded-with-row" }.downloadStatus) + assertEquals(false, actual.single { it.audioBookId == "book-gone" }.hasAudioBookRecord) + } + + /** The pre-change builder, fed the whole library, kept here as the oracle. */ + private suspend fun formerBuilder( + allBooks: List, + rowsByBook: Map>, + filesExist: (String?) -> Boolean, + progressMillis: suspend (String) -> Long?, + ): List { + val offlineBooks = allBooks.filter { it.isDownloaded == 1 || rowsByBook.containsKey(it.id) } + val bookIds = (offlineBooks.map { it.id } + rowsByBook.keys).distinct() + return bookIds.map { bookId -> + val book = offlineBooks.firstOrNull { it.id == bookId } + val row = rowsByBook[bookId]?.maxByOrNull { it.startedAt ?: Long.MIN_VALUE } + SlotCandidate( + audioBookId = bookId, + isLocal = book?.isLocal == 1, + hasAudioBookRecord = book != null, + downloadStatus = row?.status, + isDownloaded = book?.isDownloaded == 1, + hasLocalPath = !book?.localPath.isNullOrBlank(), + filesExist = filesExist(book?.localPath), + progressUpdatedAt = progressMillis(bookId), + completedAt = row?.completedAt, + startedAt = row?.startedAt, + ) + } + } + + private fun slot(id: String, local: Boolean = false, downloaded: Boolean = false, path: String? = null) = + SlotBookRow(id = id, isLocal = if (local) 1 else 0, isDownloaded = if (downloaded) 1 else 0, localPath = path) + + private fun row(bookId: String, status: DownloadStatus, startedAt: Long? = null, completedAt: Long? = null) = + DownloadItem( + id = "row-$bookId-$startedAt", + audioBookId = bookId, + status = status, + startedAt = startedAt, + completedAt = completedAt, + ) +} From cd0b9deb171b52c10b67f948e5dce4ec1fdd37c0 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:22:43 -0400 Subject: [PATCH 34/69] Home's recently played list reads progress newest first instead of the whole library Home asks for the 9 most recently played books on every progress write, which is every half second while listening, screen off included. SQLite answered by walking every book in the library and sorting, about 40 ms per run on a desktop at 50k books and several times that on a phone. The recently played queries (Home, Android Auto, and the count) now start from the progress table, read it newest first through a new UpdatedAt index, and look each book up by id. Same rows, well under a millisecond. The database goes from version 8 to 9. The migration only creates the index, no data changes. Co-Authored-By: Claude Opus 5.5 --- .../9.json | 579 ++++++++++++++++++ .../RecentlyPlayedQueryInstrumentedTest.kt | 102 +++ .../app/data/local/AppDatabase.kt | 2 +- .../app/data/local/dao/AudioBookDao.kt | 32 +- .../local/entity/PlaybackProgressEntity.kt | 7 +- .../app/data/local/migration/Migrations.kt | 9 + .../data/local/migration/MigrationsTest.kt | 59 ++ 7 files changed, 776 insertions(+), 14 deletions(-) create mode 100644 app/schemas/com.ninelivesaudio.app.data.local.AppDatabase/9.json create mode 100644 app/src/androidTest/java/com/ninelivesaudio/app/data/local/RecentlyPlayedQueryInstrumentedTest.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/data/local/migration/MigrationsTest.kt diff --git a/app/schemas/com.ninelivesaudio.app.data.local.AppDatabase/9.json b/app/schemas/com.ninelivesaudio.app.data.local.AppDatabase/9.json new file mode 100644 index 00000000..35a360d1 --- /dev/null +++ b/app/schemas/com.ninelivesaudio.app.data.local.AppDatabase/9.json @@ -0,0 +1,579 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "880e0daacbbd5ba04b50b637847db08e", + "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, 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" + } + ], + "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, '880e0daacbbd5ba04b50b637847db08e')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/data/local/RecentlyPlayedQueryInstrumentedTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/data/local/RecentlyPlayedQueryInstrumentedTest.kt new file mode 100644 index 00000000..13b71513 --- /dev/null +++ b/app/src/androidTest/java/com/ninelivesaudio/app/data/local/RecentlyPlayedQueryInstrumentedTest.kt @@ -0,0 +1,102 @@ +package com.ninelivesaudio.app.data.local + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The recently played queries now start from PlaybackProgress and walk its + * UpdatedAt index. They must return the same rows as the old library-first + * join, which is kept here as the reference. + */ +@RunWith(AndroidJUnit4::class) +class RecentlyPlayedQueryInstrumentedTest { + + private lateinit var database: AppDatabase + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + ).allowMainThreadQueries().build() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun recentQueriesReturnTheSameRowsAsTheLibraryFirstJoin() = runBlocking { + seed() + val dao = database.audioBookDao() + + assertEquals(reference("lib", isLocal = null, limit = 9), dao.getRecentlyPlayedByLibrary("lib", 9).map { it.audioBook.id }) + assertEquals(reference("lib", isLocal = null, limit = 9), dao.observeRecentlyPlayedByLibrary("lib", 9).first().map { it.audioBook.id }) + assertEquals(reference("lib", isLocal = 0, limit = 20), dao.getRecentlyPlayedByLibraryAndSource("lib", 0, 20).map { it.audioBook.id }) + assertEquals(reference(null, isLocal = null, limit = 9), dao.getRecentlyPlayed(9).map { it.audioBook.id }) + assertEquals(reference(null, isLocal = null, limit = 9), dao.observeRecentlyPlayed(9).first().map { it.audioBook.id }) + assertEquals( + reference("lib", isLocal = 0, limit = 10_000).size, + dao.countRecentlyPlayedByLibrary("lib", 0), + ) + assertEquals( + "2026-01-30T00:00:00Z", + dao.getRecentlyPlayedByLibrary("lib", 1).single().lastPlayedAt, + ) + } + + @Test + fun theUpdatedAtIndexExists() { + val cursor = database.openHelper.readableDatabase.query("PRAGMA index_list(`PlaybackProgress`)") + val names = mutableListOf() + cursor.use { while (it.moveToNext()) names += it.getString(it.getColumnIndexOrThrow("name")) } + assertTrue(names.toString(), "idx_playback_progress_updated" in names) + } + + private suspend fun seed() { + val books = (0 until 30).map { index -> + AudioBookEntity( + id = "book-$index", + libraryId = if (index % 5 == 0) "other" else "lib", + isLocal = if (index % 7 == 0) 1 else 0, + title = "Title $index", + archivedAt = if (index % 11 == 0) 1L else null, + ) + } + database.audioBookDao().upsertAll(books) + val progress = (0 until 30).filter { it % 3 != 1 }.map { index -> + PlaybackProgressEntity( + audioBookId = "book-$index", + positionSeconds = 1.0, + updatedAt = "2026-01-%02dT00:00:00Z".format(index + 1), + ) + } + PlaybackProgressEntity(audioBookId = "deleted-book", updatedAt = "2027-01-01T00:00:00Z") + progress.forEach { database.playbackProgressDao().upsert(it) } + } + + /** The pre-change query shape, run raw: AudioBooks first, then PlaybackProgress. */ + private fun reference(libraryId: String?, isLocal: Int?, limit: Int): List { + val where = buildList { + add("ab.ArchivedAt IS NULL") + if (libraryId != null) add("ab.LibraryId = '$libraryId'") + if (isLocal != null) add("ab.IsLocal = $isLocal") + }.joinToString(" AND ") + val sql = "SELECT ab.Id FROM AudioBooks ab INNER JOIN PlaybackProgress pp ON ab.Id = pp.AudioBookId " + + "WHERE $where ORDER BY pp.UpdatedAt DESC LIMIT $limit" + val ids = mutableListOf() + database.openHelper.readableDatabase.query(sql).use { while (it.moveToNext()) ids += it.getString(0) } + return ids + } +} 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 1cf9ea38..29758fdd 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 = 8, + version = 9, exportSchema = true, ) abstract class AppDatabase : RoomDatabase() { 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 81cec4fe..20275700 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 @@ -250,11 +250,19 @@ interface AudioBookDao { @Query("DELETE FROM AudioBooks WHERE IsLocal = 0") suspend fun deleteAudiobookshelf() + // ─── Recently played ───────────────────────────────────────────────── + // + // These start from PlaybackProgress and walk it newest first through + // idx_playback_progress_updated, looking each book up by id. CROSS JOIN + // pins that order. Left to itself SQLite started from the library index + // instead and read every book in the library, and Home reruns this on + // every playback progress write (every 500 ms while listening). + /** Nine Lives — recently played books with their last-played timestamp. */ @Query(""" SELECT ab.*, pp.UpdatedAt AS lastPlayedAt - FROM AudioBooks ab - INNER JOIN PlaybackProgress pp ON ab.Id = pp.AudioBookId + FROM PlaybackProgress pp + CROSS JOIN AudioBooks ab ON ab.Id = pp.AudioBookId WHERE ab.ArchivedAt IS NULL ORDER BY pp.UpdatedAt DESC LIMIT :limit @@ -264,8 +272,8 @@ interface AudioBookDao { /** Nine Lives — observable version for reactive UI. */ @Query(""" SELECT ab.*, pp.UpdatedAt AS lastPlayedAt - FROM AudioBooks ab - INNER JOIN PlaybackProgress pp ON ab.Id = pp.AudioBookId + FROM PlaybackProgress pp + CROSS JOIN AudioBooks ab ON ab.Id = pp.AudioBookId WHERE ab.ArchivedAt IS NULL ORDER BY pp.UpdatedAt DESC LIMIT :limit @@ -275,8 +283,8 @@ interface AudioBookDao { /** Nine Lives — recently played books filtered by library. */ @Query(""" SELECT ab.*, pp.UpdatedAt AS lastPlayedAt - FROM AudioBooks ab - INNER JOIN PlaybackProgress pp ON ab.Id = pp.AudioBookId + FROM PlaybackProgress pp + CROSS JOIN AudioBooks ab ON ab.Id = pp.AudioBookId WHERE ab.LibraryId = :libraryId AND ab.ArchivedAt IS NULL ORDER BY pp.UpdatedAt DESC LIMIT :limit @@ -286,8 +294,8 @@ interface AudioBookDao { /** Android Auto variant applies source scope before LIMIT. */ @Query(""" SELECT ab.*, pp.UpdatedAt AS lastPlayedAt - FROM AudioBooks ab - INNER JOIN PlaybackProgress pp ON ab.Id = pp.AudioBookId + FROM PlaybackProgress pp + CROSS JOIN AudioBooks ab ON ab.Id = pp.AudioBookId WHERE ab.LibraryId = :libraryId AND ab.IsLocal = :isLocal AND ab.ArchivedAt IS NULL ORDER BY pp.UpdatedAt DESC LIMIT :limit @@ -301,8 +309,8 @@ interface AudioBookDao { /** Nine Lives — observable recently played books filtered by library. */ @Query(""" SELECT ab.*, pp.UpdatedAt AS lastPlayedAt - FROM AudioBooks ab - INNER JOIN PlaybackProgress pp ON ab.Id = pp.AudioBookId + FROM PlaybackProgress pp + CROSS JOIN AudioBooks ab ON ab.Id = pp.AudioBookId WHERE ab.LibraryId = :libraryId AND ab.ArchivedAt IS NULL ORDER BY pp.UpdatedAt DESC LIMIT :limit @@ -353,8 +361,8 @@ interface AudioBookDao { @Query(""" SELECT COUNT(DISTINCT ab.Id) - FROM AudioBooks ab - INNER JOIN PlaybackProgress pp ON ab.Id = pp.AudioBookId + FROM PlaybackProgress pp + CROSS JOIN AudioBooks ab ON ab.Id = pp.AudioBookId WHERE ab.LibraryId = :libraryId AND ab.IsLocal = :isLocal AND ab.ArchivedAt IS NULL """) suspend fun countRecentlyPlayedByLibrary(libraryId: String, isLocal: Int): Int diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/PlaybackProgressEntity.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/PlaybackProgressEntity.kt index bcb8158d..303577f0 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/PlaybackProgressEntity.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/PlaybackProgressEntity.kt @@ -2,9 +2,14 @@ package com.ninelivesaudio.app.data.local.entity import androidx.room.ColumnInfo import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey -@Entity(tableName = "PlaybackProgress") +@Entity( + tableName = "PlaybackProgress", + // Lets the recently played queries read newest first and stop at LIMIT. + indices = [Index(value = ["UpdatedAt"], name = "idx_playback_progress_updated")], +) data class PlaybackProgressEntity( @PrimaryKey @ColumnInfo(name = "AudioBookId") 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 e96e1695..c4317cbc 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 @@ -98,6 +98,14 @@ val MIGRATION_7_8 = object : Migration(7, 8) { } } +val MIGRATION_8_9 = object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + // Index only, no data changes. The recently played queries walk + // PlaybackProgress newest first instead of reading the whole library. + db.execSQL("CREATE INDEX IF NOT EXISTS `idx_playback_progress_updated` ON `PlaybackProgress` (`UpdatedAt`)") + } +} + /** * All migrations to register with Room, in order. * Add new migrations here as they are created. @@ -110,4 +118,5 @@ val ALL_MIGRATIONS: Array = arrayOf( MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, + MIGRATION_8_9, ) 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 new file mode 100644 index 00000000..c3141a69 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/local/migration/MigrationsTest.kt @@ -0,0 +1,59 @@ +package com.ninelivesaudio.app.data.local.migration + +import androidx.sqlite.db.SupportSQLiteDatabase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +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. + */ +class MigrationsTest { + + private val schemaDir = File("schemas/com.ninelivesaudio.app.data.local.AppDatabase") + + @Test + fun `migrations run 1 to the newest exported schema with no gaps`() { + val steps = ALL_MIGRATIONS.map { it.startVersion to it.endVersion } + val newestSchema = schemaDir.listFiles().orEmpty() + .mapNotNull { it.name.removeSuffix(".json").toIntOrNull() } + .maxOrNull() + + assertEquals((1 until 9).map { it to it + 1 }, steps) + assertEquals(newestSchema, steps.last().second) + } + + @Test + fun `8 to 9 only creates the recently played index Room expects`() { + val executed = mutableListOf() + MIGRATION_8_9.migrate(recordingDatabase(executed)) + + val expected = Regex("\"createSql\":\\s*\"(CREATE INDEX IF NOT EXISTS `idx_playback_progress_updated`[^\"]*)\"") + .find(File(schemaDir, "9.json").readText()) + ?.groupValues?.get(1) + ?.replace("\${TABLE_NAME}", "PlaybackProgress") + + assertTrue("index missing from the exported v9 schema", expected != null) + assertEquals(listOf(expected), executed) + } + + /** A database that records execSQL and fails on anything else. */ + private fun recordingDatabase(executed: MutableList): SupportSQLiteDatabase = + Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + when (method.name) { + "execSQL" -> { + executed += args!![0] as String + null + } + "toString" -> "RecordingDatabase" + else -> error("migration 8 to 9 should only run SQL, it called ${method.name}") + } + } as SupportSQLiteDatabase +} From 8d98ed71cd3deccf8524fe047f32443767be23fd Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:01:56 -0400 Subject: [PATCH 35/69] The Library shelf reads light rows instead of whole books Every filter change, search, and sync read every matching book with all of its columns and decoded them. Most of a server book's weight is its description, which the shelf never shows, so a 50,000 book library built about 115 MB of objects per change, twice over while the old list was still on screen. The shelf query now names the columns a row, a group, or an Android Auto browse row shows and leaves out the description, the audio file list, and the tags. Chapters stay for the row's chapter label. These shelf books are for showing and opening only. They are never saved back, since that would wipe the stored description and track list, and the code says so where they are made. Co-Authored-By: Claude Opus 5.5 --- .../local/ShelfProjectionInstrumentedTest.kt | 108 ++++++++++++++++ .../library/LibraryViewModelFilterRaceTest.kt | 49 ++++---- .../app/data/local/converter/EntityMappers.kt | 9 ++ .../app/data/local/dao/AudioBookDao.kt | 8 +- .../app/data/local/entity/ShelfBookRow.kt | 92 ++++++++++++++ .../data/repository/AudioBookRepository.kt | 15 ++- .../app/data/local/ShelfBookRowTest.kt | 117 ++++++++++++++++++ 7 files changed, 368 insertions(+), 30 deletions(-) create mode 100644 app/src/androidTest/java/com/ninelivesaudio/app/data/local/ShelfProjectionInstrumentedTest.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/data/local/entity/ShelfBookRow.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/data/local/ShelfBookRowTest.kt 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 new file mode 100644 index 00000000..26b1bda3 --- /dev/null +++ b/app/src/androidTest/java/com/ninelivesaudio/app/data/local/ShelfProjectionInstrumentedTest.kt @@ -0,0 +1,108 @@ +package com.ninelivesaudio.app.data.local + +import androidx.room.Room +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.ninelivesaudio.app.data.local.converter.toDomain +import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity +import com.ninelivesaudio.app.data.local.entity.toShelfBook +import com.ninelivesaudio.app.data.repository.buildLibrarySql +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.util.toEpochMillis +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The light shelf query returns the same books, with the same shelf fields, + * as the former `ab.*` read, on a real Room database. + */ +@RunWith(AndroidJUnit4::class) +class ShelfProjectionInstrumentedTest { + + private lateinit var database: AppDatabase + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + ).allowMainThreadQueries().build() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun shelfRowsMatchTheFullRowsMinusTheHeavyFields() = runBlocking { + val dao = database.audioBookDao() + dao.upsertAll( + listOf( + book("b", "Beta", chaptersJson = """[{"Id":1,"Start":0.0,"End":60.0,"Title":"One"}]"""), + book("a", "alpha", genresJson = """["Horror"]"""), + book("c", "Gamma", archivedAt = 1L), + book("d", "Delta", libraryId = "other"), + ) + ) + database.playbackProgressDao().upsert( + PlaybackProgressEntity(audioBookId = "b", positionSeconds = 30.0, updatedAt = "2026-09-20T08:30:00Z") + ) + + val former: Map = dao.getByLibraryWithLastPlayed(LIBRARY) + .filter { it.audioBook.archivedAt == null } + .associate { result -> + result.audioBook.id to result.audioBook.toDomain().copy( + lastPlayedAt = result.lastPlayedAt?.toEpochMillis(), + description = null, + audioFiles = emptyList(), + tags = emptyList(), + ) + } + val shelf = dao.getFilteredBooks( + SimpleSQLiteQuery( + buildLibrarySql(tab = 0, hideFinished = false, downloadedOnly = false, hasSearch = false), + arrayOf(LIBRARY), + ) + ).map { it.toShelfBook() } + + assertEquals(former, shelf.associateBy { it.id }) + assertEquals(setOf("a", "b"), shelf.map { it.id }.toSet()) + assertEquals(1, shelf.single { it.id == "b" }.chapters.size) + } + + private fun book( + id: String, + title: String, + libraryId: String = LIBRARY, + archivedAt: Long? = null, + genresJson: String = "[]", + chaptersJson: String = "[]", + ) = AudioBookEntity( + id = id, + libraryId = libraryId, + title = title, + author = "Author $id", + description = "Long description for $id", + durationSeconds = 120.0, + addedAt = "2026-09-01T12:00:00Z", + audioFilesJson = """[{"Id":"f-$id","Ino":"1","Index":1,"Duration":120.0,"Filename":"$id.mp3"}]""", + progress = 0.25, + archivedAt = archivedAt, + seriesName = "Series", + seriesSequence = "1", + genresJson = genresJson, + tagsJson = """["tag"]""", + chaptersJson = chaptersJson, + ) + + private companion object { + const val LIBRARY = "lib" + } +} diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt index 43e3c31a..286443d3 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt @@ -10,10 +10,9 @@ import com.ninelivesaudio.app.data.local.dao.LibraryDao import com.ninelivesaudio.app.data.local.dao.LocalBookmarkDao import com.ninelivesaudio.app.data.local.dao.LocalListeningSessionDao import com.ninelivesaudio.app.data.local.dao.PlaybackProgressDao -import com.ninelivesaudio.app.data.local.entity.AudioBookEntity import com.ninelivesaudio.app.data.local.entity.LibraryEntity import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry -import com.ninelivesaudio.app.data.local.entity.RecentlyPlayedResult +import com.ninelivesaudio.app.data.local.entity.ShelfBookRow import com.ninelivesaudio.app.data.repository.AudioBookRepository import com.ninelivesaudio.app.data.repository.LibrarySyncWatermarkStore import com.ninelivesaudio.app.data.repository.LibraryRepository @@ -161,7 +160,7 @@ private object FixtureLibraries { private class DelayedLibraryFilterDao { private val delayedQueryStarted = CountDownLatch(1) @Volatile private var delayNextQuery = false - @Volatile private var delayedContinuation: Continuation>? = null + @Volatile private var delayedContinuation: Continuation>? = null val audioBookDao: AudioBookDao = proxy { method, args -> when (method.name) { @@ -193,32 +192,38 @@ private class DelayedLibraryFilterDao { delayNextQuery = false @Suppress("UNCHECKED_CAST") - val continuation = args?.lastOrNull() as? Continuation> + val continuation = args?.lastOrNull() as? Continuation> ?: error("Room suspend continuation missing from getFilteredBooks") delayedContinuation = continuation delayedQueryStarted.countDown() return COROUTINE_SUSPENDED } - private fun betaResult() = RecentlyPlayedResult( - audioBook = AudioBookEntity( - id = "beta-book", - libraryId = FixtureLibraries.beta.id, - isLocal = 0, - title = "Beta Book", - author = "Author", - ), - lastPlayedAt = null, - ) + private fun betaResult() = shelfRow(id = "beta-book", libraryId = FixtureLibraries.beta.id, title = "Beta Book") + + private fun alphaResult() = shelfRow(id = "alpha-book", libraryId = FixtureLibraries.alpha.id, title = "Alpha Book") - private fun alphaResult() = RecentlyPlayedResult( - audioBook = AudioBookEntity( - id = "alpha-book", - libraryId = FixtureLibraries.alpha.id, - isLocal = 0, - title = "Alpha Book", - author = "Author", - ), + private fun shelfRow(id: String, libraryId: String, title: String) = ShelfBookRow( + id = id, + libraryId = libraryId, + isLocal = 0, + title = title, + author = "Author", + narrator = null, + coverPath = null, + durationSeconds = 0.0, + addedAt = null, + currentTimeSeconds = 0.0, + progress = 0.0, + isFinished = 0, + isDownloaded = 0, + localPath = null, + localCoverPath = null, + archivedAt = null, + seriesName = null, + seriesSequence = null, + genresJson = null, + chaptersJson = null, lastPlayedAt = null, ) } diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/converter/EntityMappers.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/converter/EntityMappers.kt index 2009c2bf..7071cdf3 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/converter/EntityMappers.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/converter/EntityMappers.kt @@ -16,6 +16,15 @@ private inline fun decodeJsonList(jsonString: String?): List = catch (_: Exception) { emptyList() } } ?: emptyList() +// Shelf rows decode these per book. Most server rows hold "[]", which skips +// the JSON parser entirely. +internal fun decodeStringListJson(jsonString: String?): List = + if (jsonString == null || jsonString == "[]") emptyList() else decodeJsonList(jsonString) + +internal fun decodeChapterListJson(jsonString: String?): List = + if (jsonString == null || jsonString == "[]") emptyList() + else decodeJsonList(jsonString).map { it.toDomain() } + // ─── AudioBook ─────────────────────────────────────────────────────────── fun AudioBookEntity.toDomain(): AudioBook = AudioBook( 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 20275700..27248072 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 @@ -14,6 +14,7 @@ import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.data.local.entity.RecentlyPlayedResult import com.ninelivesaudio.app.data.local.entity.SlotBookRow +import com.ninelivesaudio.app.data.local.entity.ShelfBookRow import kotlinx.coroutines.flow.Flow @Dao @@ -340,9 +341,12 @@ interface AudioBookDao { @Query("UPDATE AudioBooks SET CurrentTimeSeconds = :currentTimeSeconds, Progress = :progress, IsFinished = :isFinished WHERE Id = :id") suspend fun updateProgress(id: String, currentTimeSeconds: Double, progress: Double, isFinished: Int) - /** Dynamic filtered query — built by AudioBookRepository.getFilteredBooks(). */ + /** + * Dynamic filtered shelf query, built by AudioBookRepository.getFilteredBooks(). + * Light rows only (see [ShelfBookRow]), never `ab.*`. + */ @RawQuery(observedEntities = [AudioBookEntity::class, PlaybackProgressEntity::class]) - suspend fun getFilteredBooks(query: SupportSQLiteQuery): List + suspend fun getFilteredBooks(query: SupportSQLiteQuery): List /** Count live audiobooks in a library (drives the empty-state copy, so it * excludes archived books — an archive-only library reads as empty). */ diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/ShelfBookRow.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/ShelfBookRow.kt new file mode 100644 index 00000000..4131cf17 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/ShelfBookRow.kt @@ -0,0 +1,92 @@ +package com.ninelivesaudio.app.data.local.entity + +import androidx.room.ColumnInfo +import com.ninelivesaudio.app.data.local.converter.decodeChapterListJson +import com.ninelivesaudio.app.data.local.converter.decodeStringListJson +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.util.toEpochMillis +import kotlin.time.Duration.Companion.seconds + +/** + * One Library shelf row: every column a shelf, a group, or an Auto browse row + * shows, without the heavy ones. Description is most of a server row's + * weight (about 1.9 KB a book on a big library), and the audio file and tag + * lists are never shown on a shelf, so reading `ab.*` for 50,000 books + * decoded about 115 MB per filter change for nothing. + * + * ChaptersJson stays because the row's "Ch x/y" label needs it, and it is + * "[]" for every list-synced server book anyway. + */ +data class ShelfBookRow( + @ColumnInfo(name = "Id") val id: String, + @ColumnInfo(name = "LibraryId") val libraryId: String?, + @ColumnInfo(name = "IsLocal") val isLocal: Int, + @ColumnInfo(name = "Title") val title: String, + @ColumnInfo(name = "Author") val author: String?, + @ColumnInfo(name = "Narrator") val narrator: String?, + @ColumnInfo(name = "CoverPath") val coverPath: String?, + @ColumnInfo(name = "DurationSeconds") val durationSeconds: Double, + @ColumnInfo(name = "AddedAt") val addedAt: String?, + @ColumnInfo(name = "CurrentTimeSeconds") val currentTimeSeconds: Double, + @ColumnInfo(name = "Progress") val progress: Double, + @ColumnInfo(name = "IsFinished") val isFinished: Int, + @ColumnInfo(name = "IsDownloaded") val isDownloaded: Int, + @ColumnInfo(name = "LocalPath") val localPath: String?, + @ColumnInfo(name = "LocalCoverPath") val localCoverPath: String?, + @ColumnInfo(name = "ArchivedAt") val archivedAt: Long?, + @ColumnInfo(name = "SeriesName") val seriesName: String?, + @ColumnInfo(name = "SeriesSequence") val seriesSequence: String?, + @ColumnInfo(name = "GenresJson") val genresJson: String?, + @ColumnInfo(name = "ChaptersJson") val chaptersJson: String?, + @ColumnInfo(name = "lastPlayedAt") val lastPlayedAt: String?, +) + +/** + * The select list for [ShelfBookRow], aliased so Room maps it by name. Keep + * it in step with the fields above. Description, AudioFilesJson, and + * TagsJson are left out on purpose. + */ +internal const val SHELF_BOOK_COLUMNS = + "ab.Id AS Id, ab.LibraryId AS LibraryId, ab.IsLocal AS IsLocal, ab.Title AS Title, " + + "ab.Author AS Author, ab.Narrator AS Narrator, ab.CoverPath AS CoverPath, " + + "ab.DurationSeconds AS DurationSeconds, ab.AddedAt AS AddedAt, " + + "ab.CurrentTimeSeconds AS CurrentTimeSeconds, ab.Progress AS Progress, " + + "ab.IsFinished AS IsFinished, ab.IsDownloaded AS IsDownloaded, ab.LocalPath AS LocalPath, " + + "ab.LocalCoverPath AS LocalCoverPath, ab.ArchivedAt AS ArchivedAt, " + + "ab.SeriesName AS SeriesName, ab.SeriesSequence AS SeriesSequence, " + + "ab.GenresJson AS GenresJson, ab.ChaptersJson AS ChaptersJson, " + + "pp.UpdatedAt AS lastPlayedAt" + +/** + * A shelf book. Description, audio files, and tags are EMPTY here, not + * unknown, so this book is for showing and navigating only. Never hand it to + * AudioBookRepository.save or anything else that writes a row back: that + * would wipe the stored description and track list. Code that needs the + * whole book reads it by id (getById). + */ +fun ShelfBookRow.toShelfBook(): AudioBook = AudioBook( + id = id, + libraryId = libraryId, + isLocal = isLocal == 1, + title = title, + author = author ?: "", + narrator = narrator, + description = null, + coverPath = coverPath, + duration = durationSeconds.seconds, + addedAt = addedAt?.toEpochMillis(), + audioFiles = emptyList(), + seriesName = seriesName, + seriesSequence = seriesSequence, + genres = decodeStringListJson(genresJson), + tags = emptyList(), + chapters = decodeChapterListJson(chaptersJson), + currentTime = currentTimeSeconds.seconds, + progress = progress, + isFinished = isFinished == 1, + isDownloaded = isDownloaded == 1, + localPath = localPath, + localCoverPath = localCoverPath, + archivedAt = archivedAt, + lastPlayedAt = lastPlayedAt?.toEpochMillis(), +) 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 12d4824b..4f481c52 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 @@ -14,7 +14,9 @@ import com.ninelivesaudio.app.data.local.entity.AudioBookEntity import com.ninelivesaudio.app.data.local.entity.AutoBrowseRow import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity +import com.ninelivesaudio.app.data.local.entity.SHELF_BOOK_COLUMNS import com.ninelivesaudio.app.data.local.entity.SyncMergeState +import com.ninelivesaudio.app.data.local.entity.toShelfBook import com.ninelivesaudio.app.data.remote.ApiService import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.domain.model.AudioBook @@ -190,6 +192,9 @@ class AudioBookRepository @Inject constructor( * Get filtered books for a library, pushing WHERE clauses to SQL. * Eliminates the need to hold all books in memory for filtering. * + * Returns light shelf books: description, audio files, and tags are + * empty. Never pass one to [save] or [saveAll], read the book by id. + * * @param tab 0=All, 1=InProgress, 2=Completed, 3=Downloaded * @param hideFinished whether to exclude finished books * @param downloadedOnly whether to show only downloaded books @@ -211,11 +216,9 @@ class AudioBookRepository @Inject constructor( } val results = audioBookDao.getFilteredBooks(SimpleSQLiteQuery(sql, args.toTypedArray())) - return results.map { result -> - result.audioBook.toDomain().copy( - lastPlayedAt = result.lastPlayedAt?.toEpochMillis() - ) - } + // Shelf books carry no description, audio files, or tags. They are + // for showing and navigating, never for saving back. See toShelfBook. + return results.map { it.toShelfBook() } } /** Count all audiobooks in a library. */ @@ -1040,7 +1043,7 @@ internal fun buildLibrarySql( downloadedOnly: Boolean, hasSearch: Boolean, ): String = buildString { - append("SELECT ab.*, pp.UpdatedAt AS lastPlayedAt FROM AudioBooks ab") + append("SELECT ").append(SHELF_BOOK_COLUMNS).append(" FROM AudioBooks ab") append(" LEFT JOIN PlaybackProgress pp ON ab.Id = pp.AudioBookId") append(" WHERE ab.LibraryId = ?") diff --git a/app/src/test/java/com/ninelivesaudio/app/data/local/ShelfBookRowTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/local/ShelfBookRowTest.kt new file mode 100644 index 00000000..ba1b18fc --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/local/ShelfBookRowTest.kt @@ -0,0 +1,117 @@ +package com.ninelivesaudio.app.data.local + +import com.ninelivesaudio.app.data.local.converter.toDomain +import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.data.local.entity.SHELF_BOOK_COLUMNS +import com.ninelivesaudio.app.data.local.entity.ShelfBookRow +import com.ninelivesaudio.app.data.local.entity.toShelfBook +import com.ninelivesaudio.app.data.repository.buildLibrarySql +import com.ninelivesaudio.app.domain.util.toEpochMillis +import java.lang.reflect.Modifier +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The Library shelf reads light rows, and they show the same book the full row does. */ +class ShelfBookRowTest { + + private val chaptersJson = + """[{"Id":1,"Start":0.0,"End":600.0,"Title":"One"},{"Id":2,"Start":600.0,"End":1200.0,"Title":"Two"}]""" + + private val entity = AudioBookEntity( + id = "book-1", + libraryId = "lib-1", + isLocal = 0, + title = "The Long Hum", + author = "Static", + narrator = "A Voice", + description = "A very long description that the shelf never shows.", + coverPath = "https://abs.example/api/items/book-1/cover", + durationSeconds = 1200.0, + addedAt = "2026-09-01T12:00:00Z", + audioFilesJson = """[{"Id":"f1","Ino":"1","Index":1,"Duration":1200.0,"Filename":"a.mp3"}]""", + currentTimeSeconds = 700.0, + progress = 0.58, + isFinished = 0, + isDownloaded = 1, + localPath = "/storage/books/The Long Hum", + localCoverPath = "file:///storage/books/The Long Hum/cover.jpg", + archivedAt = null, + seriesName = "Hum Cycle", + seriesSequence = "2", + genresJson = """["Horror","Mystery"]""", + tagsJson = """["tag-a"]""", + chaptersJson = chaptersJson, + ) + + private fun rowOf(e: AudioBookEntity, lastPlayedAt: String?) = ShelfBookRow( + id = e.id, + libraryId = e.libraryId, + isLocal = e.isLocal, + title = e.title, + author = e.author, + narrator = e.narrator, + coverPath = e.coverPath, + durationSeconds = e.durationSeconds, + addedAt = e.addedAt, + currentTimeSeconds = e.currentTimeSeconds, + progress = e.progress, + isFinished = e.isFinished, + isDownloaded = e.isDownloaded, + localPath = e.localPath, + localCoverPath = e.localCoverPath, + archivedAt = e.archivedAt, + seriesName = e.seriesName, + seriesSequence = e.seriesSequence, + genresJson = e.genresJson, + chaptersJson = e.chaptersJson, + lastPlayedAt = lastPlayedAt, + ) + + @Test + fun `a shelf book equals the full book minus description, audio files, and tags`() { + val lastPlayed = "2026-09-20T08:30:00Z" + val full = entity.toDomain().copy(lastPlayedAt = lastPlayed.toEpochMillis()) + + val shelf = rowOf(entity, lastPlayed).toShelfBook() + + assertEquals(full.copy(description = null, audioFiles = emptyList(), tags = emptyList()), shelf) + // The row's "Ch x/y" label still works. + assertEquals("58% • Ch 2/2", shelf.progressText) + } + + @Test + fun `empty lists and nulls map the way the full row maps them`() { + val bare = entity.copy(genresJson = "[]", chaptersJson = null, author = null, addedAt = null) + val shelf = rowOf(bare, lastPlayedAt = null).toShelfBook() + val full = bare.toDomain() + + assertEquals(full.genres, shelf.genres) + assertEquals(full.chapters, shelf.chapters) + assertEquals("", shelf.author) + assertEquals(null, shelf.addedAt) + assertEquals(null, shelf.lastPlayedAt) + } + + @Test + fun `the shelf query selects every row field by name and none of the heavy columns`() { + val sql = buildLibrarySql(tab = 0, hideFinished = false, downloadedOnly = false, hasSearch = false) + assertTrue(sql.contains(SHELF_BOOK_COLUMNS)) + assertFalse(sql.contains("ab.*")) + for (heavy in listOf("Description", "AudioFilesJson", "TagsJson")) { + assertFalse("$heavy is read", sql.contains(heavy)) + } + + // Room maps a raw query by column name, so a field without a matching + // alias would read as a default. Compare names case-insensitively. + val aliases = SHELF_BOOK_COLUMNS.split(",") + .map { it.trim().substringAfter(" AS ").lowercase() } + .toSet() + val fields = ShelfBookRow::class.java.declaredFields + .filterNot { Modifier.isStatic(it.modifiers) || it.isSynthetic } + .map { it.name.lowercase() } + .toSet() + assertEquals(fields, aliases) + } +} From af1b8a62d87a497054d95926d397779b3f138870 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:03:07 -0400 Subject: [PATCH 36/69] A replaced shelf filter stops decoding instead of finishing in the background Search waits 300 ms before filtering but chip taps do not, and a new filter cancels the old one. The old one never noticed: decoding and sorting a big library is one long loop with no pause in it, so it ran to the end anyway, and fast taps on a 50,000 book library stacked two full builds side by side. The decode loops now check for a cancel every 256 books, and the build checks again before and after sorting, so a replaced filter stops within a few milliseconds. Co-Authored-By: Claude Opus 5.5 --- .../data/repository/AudioBookRepository.kt | 3 +- .../app/domain/util/Cooperative.kt | 26 +++++++++++++ .../app/ui/library/LibraryViewModel.kt | 7 +++- .../app/domain/util/CooperativeMapTest.kt | 38 +++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/domain/util/Cooperative.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/domain/util/CooperativeMapTest.kt 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 4f481c52..29a8c84e 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 @@ -20,6 +20,7 @@ import com.ninelivesaudio.app.data.local.entity.toShelfBook import com.ninelivesaudio.app.data.remote.ApiService import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.util.mapCooperatively import com.ninelivesaudio.app.domain.util.toEpochMillis import com.ninelivesaudio.app.service.shelfProgress import com.ninelivesaudio.app.service.local.LocalBookFingerprint @@ -218,7 +219,7 @@ class AudioBookRepository @Inject constructor( val results = audioBookDao.getFilteredBooks(SimpleSQLiteQuery(sql, args.toTypedArray())) // Shelf books carry no description, audio files, or tags. They are // for showing and navigating, never for saving back. See toShelfBook. - return results.map { it.toShelfBook() } + return results.mapCooperatively { it.toShelfBook() } } /** Count all audiobooks in a library. */ diff --git a/app/src/main/java/com/ninelivesaudio/app/domain/util/Cooperative.kt b/app/src/main/java/com/ninelivesaudio/app/domain/util/Cooperative.kt new file mode 100644 index 00000000..f77be2cc --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/domain/util/Cooperative.kt @@ -0,0 +1,26 @@ +package com.ninelivesaudio.app.domain.util + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive + +/** How many items a long loop handles between cancellation checks. */ +internal const val COOPERATIVE_CHECK_EVERY = 256 + +/** + * [map] that stops when its coroutine is cancelled, checking every + * [checkEvery] items. A plain map over tens of thousands of rows never + * suspends, so a cancelled shelf build kept decoding to the end while the + * newer one started beside it. + */ +internal suspend inline fun List.mapCooperatively( + checkEvery: Int = COOPERATIVE_CHECK_EVERY, + transform: (T) -> R, +): List { + val context = currentCoroutineContext() + val out = ArrayList(size) + for (index in indices) { + if (index % checkEvery == 0) context.ensureActive() + out.add(transform(this[index])) + } + return out +} 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 8139f4cb..e28def38 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 @@ -20,6 +20,7 @@ import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.LastSyncRecord import com.ninelivesaudio.app.domain.model.Library import com.ninelivesaudio.app.domain.model.SyncResult +import com.ninelivesaudio.app.domain.util.mapCooperatively import com.ninelivesaudio.app.service.ConnectivityMonitor import com.ninelivesaudio.app.service.ConnectivityMonitor.ConnectionStatus import com.ninelivesaudio.app.service.PersistedSyncOutcome @@ -985,8 +986,10 @@ class LibraryViewModel @Inject constructor( ) if (!filterPublication.isCurrent(snapshot.request)) return null val accessibleLocalIds = localFolderAccess.accessibleLibraryIds(snapshot.libraries) + // A newer filter cancels this one. The long loops below check for + // that, so two big builds never run side by side on fast chip taps. val books = storedBooks - .map { reconcileLocalBookAccess(it, accessibleLocalIds).book } + .mapCooperatively { reconcileLocalBookAccess(it, accessibleLocalIds).book } .filterNot { !it.isDownloaded && (snapshot.selectedTab == LibraryTab.Downloaded || snapshot.showDownloadedOnly) @@ -1003,7 +1006,9 @@ class LibraryViewModel @Inject constructor( val effectiveViewMode = FreeTier.effectiveViewMode(snapshot.viewMode, isUnlocked) // Sort and group in-memory (complex logic stays in Kotlin) + currentCoroutineContext().ensureActive() val sortedBooks = sortBooks(books, effectiveSort) + currentCoroutineContext().ensureActive() val groupedSections = buildGroupedSections( books = sortedBooks, viewMode = effectiveViewMode, diff --git a/app/src/test/java/com/ninelivesaudio/app/domain/util/CooperativeMapTest.kt b/app/src/test/java/com/ninelivesaudio/app/domain/util/CooperativeMapTest.kt new file mode 100644 index 00000000..56a3be7d --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/domain/util/CooperativeMapTest.kt @@ -0,0 +1,38 @@ +package com.ninelivesaudio.app.domain.util + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.job +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.coroutines.coroutineContext + +/** A cancelled shelf build stops decoding instead of running to the end. */ +class CooperativeMapTest { + + @Test + fun `maps every item when nothing cancels`() = runBlocking { + val out = (1..1_000).toList().mapCooperatively { it * 2 } + assertEquals((1..1_000).map { it * 2 }, out) + } + + @Test + fun `a cancel mid-loop stops at the next check`() = runBlocking { + var mapped = 0 + val build = async(Dispatchers.Default) { + (0 until 10_000).toList().mapCooperatively { item -> + if (item == 10) coroutineContext.job.cancel() + mapped++ + item + } + } + val error = runCatching { build.await() }.exceptionOrNull() + + assertTrue(error is CancellationException) + // Items 0 to 255 run, the check before item 256 throws. + assertEquals(COOPERATIVE_CHECK_EVERY, mapped) + } +} From f6483d0f23bdb0a9b1c4f8ebe04560a8c5181e0e Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:15:21 -0400 Subject: [PATCH 37/69] The shelf sorts once, on keys worked out once per book Sorting lowercased a title or author inside every comparison, about 1.5 million throwaway strings per sort on a 50,000 book shelf, and the query sorted by title first even though Kotlin always sorts again. Grouped views then sorted each group a second time though every group already came out of the sorted shelf in order. Each book's lowercased title and author are now worked out once, the query has no ORDER BY, and groups keep the order they were built in. The order is the same as before: every mode ends on the raw title, which is what the query used to sort by. A desktop run on 50,000 books goes from about 35 ms to about 15 ms per sort, plus the query's own sort. Co-Authored-By: Claude Opus 5.5 --- .../data/repository/AudioBookRepository.kt | 3 +- .../app/ui/library/LibraryViewModel.kt | 85 +++++++++----- .../app/ui/library/LibrarySortTest.kt | 109 ++++++++++++++++++ 3 files changed, 164 insertions(+), 33 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySortTest.kt 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 29a8c84e..1569c0c7 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 @@ -1085,5 +1085,6 @@ internal fun buildLibrarySql( append(" AND (ab.Title LIKE ? OR ab.Author LIKE ? OR ab.SeriesName LIKE ? OR ab.Narrator LIKE ?)") } - append(" ORDER BY ab.Title") + // No ORDER BY: every caller sorts in Kotlin (the Library's sortBooks, the + // Auto browse lists), so sorting here too was paid twice. } 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 e28def38..fdef6fcc 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 @@ -1291,6 +1291,11 @@ internal fun shouldSyncOnLibraryLoad(isLocalLibrary: Boolean, isOnline: Boolean) // ─── Grouping helpers (internal for testability) ────────────────────────── +/** + * 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. + */ internal fun buildGroupedSections( books: List, viewMode: ViewMode, @@ -1299,17 +1304,16 @@ internal fun buildGroupedSections( if (viewMode == ViewMode.ALL) return emptyList() // Genre view uses multi-placement: a book appears in every genre group it belongs to. - val grouped = mutableMapOf>() + val grouped = linkedMapOf>() books.forEach { book -> val keys = groupingKeysForBook(book, viewMode) keys.forEach { key -> grouped.getOrPut(key) { mutableListOf() }.add(book) } } return grouped.entries - .map { (key, values) -> - GroupedSection(key = key, title = key, books = sortBooks(values, sortMode)) - } + .map { (key, values) -> SectionSortEntry(GroupedSection(key = key, title = key, books = values)) } .sortedWith(groupedSectionComparator(sortMode)) + .map { it.section } } internal fun flattenGroupedItems( @@ -1345,16 +1349,21 @@ private fun groupingKeysForBook(book: AudioBook, viewMode: ViewMode): List emptyList() } -private fun groupedSectionComparator(sortMode: SortMode): Comparator { - val alphaAsc = compareBy { it.title.lowercase() } +/** 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() +} + +private fun groupedSectionComparator(sortMode: SortMode): Comparator { + val alphaAsc = compareBy { it.titleKey } return when (sortMode) { SortMode.TITLE_ZA, SortMode.AUTHOR_ZA -> alphaAsc.reversed() SortMode.TITLE_AZ, SortMode.AUTHOR_AZ -> alphaAsc SortMode.PROGRESS_LOW, SortMode.DURATION_SHORT -> - compareBy { it.books.firstOrNull()?.let { b -> sortSignal(b, sortMode) } ?: Long.MAX_VALUE } + compareBy { it.section.books.firstOrNull()?.let { b -> sortSignal(b, sortMode) } ?: Long.MAX_VALUE } .then(alphaAsc) else -> - compareByDescending { it.books.firstOrNull()?.let { b -> sortSignal(b, sortMode) } ?: Long.MIN_VALUE } + compareByDescending { it.section.books.firstOrNull()?.let { b -> sortSignal(b, sortMode) } ?: Long.MIN_VALUE } .then(alphaAsc) } } @@ -1368,29 +1377,41 @@ private fun sortSignal(book: AudioBook, sortMode: SortMode): Long = when (sortMo SortMode.TITLE_AZ, SortMode.TITLE_ZA, SortMode.AUTHOR_AZ, SortMode.AUTHOR_ZA -> 0L } +/** + * A book with its sort keys worked out once. Lowercasing inside a comparator + * ran about 1.5 million times per sort on a 50,000 book shelf. + */ +private class BookSortEntry(val book: AudioBook, needsAuthor: Boolean) { + val titleKey: String = book.title.lowercase() + val authorKey: String = if (needsAuthor) book.author.lowercase() else "" +} + +/** + * The shelf order. The query has no ORDER BY, since this sorts every time, so + * every mode ends on the raw title (the order the query used to return) and + * then the id, and the same shelf always comes out the same way. + */ internal fun sortBooks(books: List, sortMode: SortMode): List { - val sequence = books.asSequence() - return when (sortMode) { - SortMode.RECENTLY_ADDED -> sequence.sortedWith( - compareByDescending { it.addedAt ?: Long.MIN_VALUE } - .thenBy { it.title.lowercase() } - ) - SortMode.TITLE_AZ -> sequence.sortedBy { it.title.lowercase() } - SortMode.TITLE_ZA -> sequence.sortedByDescending { it.title.lowercase() } - SortMode.AUTHOR_AZ -> sequence.sortedWith(compareBy({ it.author.lowercase() }, { it.title.lowercase() })) - SortMode.AUTHOR_ZA -> sequence.sortedWith(compareByDescending { it.author.lowercase() }.thenByDescending { it.title.lowercase() }) - SortMode.PROGRESS_HIGH -> sequence.sortedByDescending { it.progressPercent } - SortMode.PROGRESS_LOW -> sequence.sortedBy { it.progressPercent } - SortMode.DURATION_LONG -> sequence.sortedByDescending { it.duration.inWholeSeconds } - SortMode.DURATION_SHORT -> sequence.sortedBy { it.duration.inWholeSeconds } - SortMode.RECENTLY_PLAYED -> sequence.sortedWith( - // Treat books with no playback history as oldest via Long.MIN_VALUE fallback. - compareByDescending { it.lastPlayedAt ?: Long.MIN_VALUE } - .thenBy { it.title.lowercase() } - ) - SortMode.UNPLAYED_FIRST -> sequence.sortedWith( - compareBy { if (it.hasProgress) 1 else 0 } - .thenBy { it.title.lowercase() } - ) - }.toList() + val needsAuthor = sortMode == SortMode.AUTHOR_AZ || sortMode == SortMode.AUTHOR_ZA + val entries = books.map { BookSortEntry(it, needsAuthor) } + val primary: Comparator = when (sortMode) { + SortMode.RECENTLY_ADDED -> + compareByDescending { it.book.addedAt ?: Long.MIN_VALUE }.thenBy { it.titleKey } + SortMode.TITLE_AZ -> compareBy { it.titleKey } + SortMode.TITLE_ZA -> compareByDescending { it.titleKey } + SortMode.AUTHOR_AZ -> compareBy { it.authorKey }.thenBy { it.titleKey } + SortMode.AUTHOR_ZA -> compareByDescending { it.authorKey }.thenByDescending { it.titleKey } + SortMode.PROGRESS_HIGH -> compareByDescending { it.book.progressPercent } + SortMode.PROGRESS_LOW -> compareBy { it.book.progressPercent } + SortMode.DURATION_LONG -> compareByDescending { it.book.duration.inWholeSeconds } + SortMode.DURATION_SHORT -> compareBy { it.book.duration.inWholeSeconds } + // Treat books with no playback history as oldest via Long.MIN_VALUE fallback. + SortMode.RECENTLY_PLAYED -> + compareByDescending { it.book.lastPlayedAt ?: Long.MIN_VALUE }.thenBy { it.titleKey } + SortMode.UNPLAYED_FIRST -> + compareBy { if (it.book.hasProgress) 1 else 0 }.thenBy { it.titleKey } + } + return entries + .sortedWith(primary.thenBy { it.book.title }.thenBy { it.book.id }) + .map { it.book } } diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySortTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySortTest.kt new file mode 100644 index 00000000..62d4d15d --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySortTest.kt @@ -0,0 +1,109 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.data.repository.buildLibrarySql +import com.ninelivesaudio.app.domain.model.AudioBook +import kotlin.random.Random +import kotlin.time.Duration.Companion.seconds +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** + * The shelf sort works out its keys once per book and the query no longer + * sorts first. The order must match the former SQL-then-Kotlin sort exactly. + */ +class LibrarySortTest { + + @Test + fun `every sort mode matches the former order on 1,000 random books`() { + val books = randomBooks(Random(42), 1_000) + // The former query returned books ORDER BY Title, then Kotlin sorted + // stably on lowercased keys. Now the query order is anything. + val formerInput = books.sortedBy { it.title } + val newInput = books.shuffled(Random(7)) + + for (mode in SortMode.entries) { + assertEquals( + "order differs for $mode", + formerSortBooks(formerInput, mode).map { it.id }, + sortBooks(newInput, mode).map { it.id }, + ) + } + } + + @Test + fun `groups keep the shelf order without sorting again`() { + val books = randomBooks(Random(3), 400) + for (mode in SortMode.entries) { + val sorted = sortBooks(books.shuffled(Random(11)), mode) + val sections = buildGroupedSections(sorted, ViewMode.AUTHOR, mode) + sections.forEach { section -> + assertEquals( + "group ${section.key} out of order for $mode", + formerSortBooks(section.books.sortedBy { it.title }, mode).map { it.id }, + section.books.map { it.id }, + ) + } + } + } + + @Test + fun `the shelf query leaves sorting to Kotlin`() { + val sql = buildLibrarySql(tab = 0, hideFinished = true, downloadedOnly = false, hasSearch = true) + assertFalse(sql.contains("ORDER BY")) + } + + private fun randomBooks(random: Random, count: Int): List { + val words = listOf("alpha", "Beta", "gamma", "DELTA", "Echo", "zulu", "\u00e9lan", "omega") + val authors = listOf("Ann", "ann", "Bob", "", "Cy", "cy", "Dee") + val seen = mutableSetOf() + return (0 until count).map { index -> + var title: String + do { + // Case-only twins ("Echo 3", "echo 3") tie on the lowercased key. + val word = words[random.nextInt(words.size)] + val cased = if (random.nextBoolean()) word.lowercase() else word.replaceFirstChar { it.uppercase() } + title = "$cased ${random.nextInt(400)}" + } while (!seen.add(title)) + AudioBook( + id = "id-$index", + title = title, + author = authors[random.nextInt(authors.size)], + duration = (random.nextInt(4) * 3_600).seconds, + progress = listOf(0.0, 0.0, 0.25, 0.5, 1.0)[random.nextInt(5)], + addedAt = if (random.nextInt(4) == 0) null else random.nextLong(5) * 1_000L, + lastPlayedAt = if (random.nextInt(3) == 0) null else random.nextLong(5) * 1_000L, + genres = listOf("Horror"), + ) + } + } + + /** The sort as it was before keys were worked out once, kept here as the reference. */ + private fun formerSortBooks(books: List, sortMode: SortMode): List { + val sequence = books.asSequence() + return when (sortMode) { + SortMode.RECENTLY_ADDED -> sequence.sortedWith( + compareByDescending { it.addedAt ?: Long.MIN_VALUE } + .thenBy { it.title.lowercase() } + ) + SortMode.TITLE_AZ -> sequence.sortedBy { it.title.lowercase() } + SortMode.TITLE_ZA -> sequence.sortedByDescending { it.title.lowercase() } + SortMode.AUTHOR_AZ -> sequence.sortedWith(compareBy({ it.author.lowercase() }, { it.title.lowercase() })) + SortMode.AUTHOR_ZA -> sequence.sortedWith( + compareByDescending { it.author.lowercase() }.thenByDescending { it.title.lowercase() } + ) + SortMode.PROGRESS_HIGH -> sequence.sortedByDescending { it.progressPercent } + SortMode.PROGRESS_LOW -> sequence.sortedBy { it.progressPercent } + SortMode.DURATION_LONG -> sequence.sortedByDescending { it.duration.inWholeSeconds } + SortMode.DURATION_SHORT -> sequence.sortedBy { it.duration.inWholeSeconds } + SortMode.RECENTLY_PLAYED -> sequence.sortedWith( + compareByDescending { it.lastPlayedAt ?: Long.MIN_VALUE } + .thenBy { it.title.lowercase() } + ) + SortMode.UNPLAYED_FIRST -> sequence.sortedWith( + compareBy { if (it.hasProgress) 1 else 0 } + .thenBy { it.title.lowercase() } + ) + }.toList() + } +} From aab8533f5ce7c08d0cf3f5a73eaaa977de27002f Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:16:22 -0400 Subject: [PATCH 38/69] Grouped views stop running a groups query nothing shows Every refilter in Series, Author, or Genre view first asked the database for the distinct group names, and Genre view decoded every distinct genre list to do it. Nothing on screen used the answer, since the group picker it was meant for was never built, so it cost a few hundred milliseconds per refilter on a big library for nothing. The query and its repository helpers are removed. The picker can bring back a query shaped for it when it exists. Co-Authored-By: Claude Opus 5.5 --- .../library/LibraryViewModelFilterRaceTest.kt | 1 - .../app/data/local/dao/AudioBookDao.kt | 12 --------- .../data/repository/AudioBookRepository.kt | 25 ------------------- .../app/ui/library/LibraryViewModel.kt | 15 +++-------- 4 files changed, 4 insertions(+), 49 deletions(-) diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt index 286443d3..499d75f2 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt @@ -166,7 +166,6 @@ private class DelayedLibraryFilterDao { when (method.name) { "getFilteredBooks" -> filteredBooks(args) "countByLibrary" -> 2 - "getDistinctSeries", "getDistinctAuthors", "getDistinctGenresJson" -> emptyList() "observeLocalCatalog" -> flowOf(emptyList()) "toString" -> "DelayedLibraryFilterDao" "hashCode" -> System.identityHashCode(this) 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 27248072..d5e26369 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 @@ -370,16 +370,4 @@ interface AudioBookDao { WHERE ab.LibraryId = :libraryId AND ab.IsLocal = :isLocal AND ab.ArchivedAt IS NULL """) suspend fun countRecentlyPlayedByLibrary(libraryId: String, isLocal: Int): Int - - /** Distinct series names for a library. */ - @Query("SELECT DISTINCT SeriesName FROM AudioBooks WHERE LibraryId = :libraryId AND ArchivedAt IS NULL AND SeriesName IS NOT NULL AND SeriesName != '' ORDER BY SeriesName") - suspend fun getDistinctSeries(libraryId: String): List - - /** Distinct authors for a library. */ - @Query("SELECT DISTINCT Author FROM AudioBooks WHERE LibraryId = :libraryId AND ArchivedAt IS NULL AND Author IS NOT NULL AND Author != '' ORDER BY Author") - suspend fun getDistinctAuthors(libraryId: String): List - - /** Distinct genres for a library (genres stored as JSON array). */ - @Query("SELECT DISTINCT GenresJson FROM AudioBooks WHERE LibraryId = :libraryId AND ArchivedAt IS NULL AND GenresJson IS NOT NULL AND GenresJson != '[]' AND GenresJson != ''") - suspend fun getDistinctGenresJson(libraryId: String): List } 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 1569c0c7..b57538c6 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 @@ -43,7 +43,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -235,30 +234,6 @@ class AudioBookRepository @Inject constructor( suspend fun countRecentlyPlayedForAuto(libraryId: String, isLocal: Boolean): Int = audioBookDao.countRecentlyPlayedByLibrary(libraryId, if (isLocal) 1 else 0) - /** Get distinct series names for a library. */ - suspend fun getDistinctSeries(libraryId: String): List = - audioBookDao.getDistinctSeries(libraryId) - - /** Get distinct authors for a library. */ - suspend fun getDistinctAuthors(libraryId: String): List = - audioBookDao.getDistinctAuthors(libraryId) - - /** Get distinct genres for a library (parsed from JSON arrays). */ - suspend fun getDistinctGenres(libraryId: String): List { - val jsonStrings = audioBookDao.getDistinctGenresJson(libraryId) - return jsonStrings - .flatMap { json -> - try { - Json.decodeFromString>(json) - } catch (_: Exception) { - emptyList() - } - } - .filter { it.isNotBlank() } - .distinct() - .sorted() - } - /** * Download every item in a library and save it, a page at a time. This is * the full sync: sign-in, a server or account switch, a switch back from 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 fdef6fcc..3d07e2f5 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 @@ -308,6 +308,10 @@ class LibraryViewModel @Inject constructor( val searchQuery: String = "", val viewMode: ViewMode = ViewMode.ALL, val sortMode: SortMode = SortMode.RECENTLY_PLAYED, + // No picker sets or applies these yet. The distinct-groups query that + // filled availableGroups ran on every grouped refilter (Genre view + // decoded every distinct genre list) and nothing rendered it, so it + // is gone until a picker exists. val selectedGroupFilter: String? = null, val availableGroups: List = emptyList(), val groupedSections: List = emptyList(), @@ -950,7 +954,6 @@ class LibraryViewModel @Inject constructor( private data class FilterResult( val books: List, - val availableGroups: List, val groupedSections: List, val totalBookCount: Int, ) @@ -958,14 +961,6 @@ class LibraryViewModel @Inject constructor( private suspend fun buildFilterResult(snapshot: FilterSnapshot): FilterResult? { val libraryId = snapshot.request.libraryId - val groups = when (snapshot.viewMode) { - ViewMode.SERIES -> audioBookRepository.getDistinctSeries(libraryId) - ViewMode.AUTHOR -> audioBookRepository.getDistinctAuthors(libraryId) - ViewMode.GENRE -> audioBookRepository.getDistinctGenres(libraryId) - ViewMode.ALL -> emptyList() - } - if (!filterPublication.isCurrent(snapshot.request)) return null - // Push filters to SQL — only load the books that match val tab = when (snapshot.selectedTab) { LibraryTab.All -> 0 @@ -1021,7 +1016,6 @@ class LibraryViewModel @Inject constructor( return FilterResult( books = sortedBooks, - availableGroups = groups, groupedSections = groupedSections, totalBookCount = totalCount, ) @@ -1036,7 +1030,6 @@ class LibraryViewModel @Inject constructor( .apply { addAll(groupKeys - previousKeys) } it.copy( filteredBooks = result.books, - availableGroups = result.availableGroups, groupedSections = result.groupedSections, expandedGroups = expandedGroups, totalBookCount = result.totalBookCount, From 3a719b76e672bbb631f8f845001540dc24573a85 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:17:50 -0400 Subject: [PATCH 39/69] Library search treats % and _ as plain characters The search box went straight into a LIKE pattern, so "%" matched every book (loading the whole shelf on a big library) and "_" matched any single character. The same held for the title and author search Android Auto uses. Typed text is now escaped and every LIKE declares the escape character, so "%" finds only titles with a percent sign in them. Accented capitals still miss their lowercase twins, since SQLite only folds plain ASCII. That one needs a search column and is filed separately. Co-Authored-By: Claude Opus 5.5 --- .../local/ShelfProjectionInstrumentedTest.kt | 27 +++++++++++++ .../ninelivesaudio/app/data/local/SqlLike.kt | 16 ++++++++ .../app/data/local/dao/AudioBookDao.kt | 6 +-- .../data/repository/AudioBookRepository.kt | 29 +++++++++----- .../app/data/local/SqlLikeTest.kt | 39 +++++++++++++++++++ 5 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/data/local/SqlLike.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/data/local/SqlLikeTest.kt 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 26b1bda3..faf6a834 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 @@ -9,6 +9,7 @@ import com.ninelivesaudio.app.data.local.entity.AudioBookEntity import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.data.local.entity.toShelfBook import com.ninelivesaudio.app.data.repository.buildLibrarySql +import com.ninelivesaudio.app.data.repository.buildLibrarySqlArgs import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.util.toEpochMillis import kotlinx.coroutines.runBlocking @@ -77,6 +78,32 @@ class ShelfProjectionInstrumentedTest { assertEquals(1, shelf.single { it.id == "b" }.chapters.size) } + @Test + fun percentAndUnderscoreMatchOnlyThemselves() = runBlocking { + val dao = database.audioBookDao() + dao.upsertAll( + listOf( + book("pct", "100% Pure"), + book("num", "100 Days"), + book("under", "a_b"), + book("any", "axb"), + ) + ) + + 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("pct"), shelfSearch("%")) + assertEquals(setOf("under"), shelfSearch("_")) + assertEquals(setOf("pct", "num"), shelfSearch("100")) + assertEquals(setOf("pct"), dao.search(escapeLike("%")).map { it.id }.toSet()) + assertEquals(setOf("under"), dao.search(escapeLike("_")).map { it.id }.toSet()) + } + private fun book( id: String, title: String, 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 new file mode 100644 index 00000000..1edeb291 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/SqlLike.kt @@ -0,0 +1,16 @@ +package com.ninelivesaudio.app.data.local + +/** + * 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 + * "_" matched any one character. + */ +internal fun escapeLike(text: String): String = buildString(text.length + 4) { + for (c in text) { + if (c == '\\' || c == '%' || c == '_') append('\\') + append(c) + } +} + +/** A LIKE pattern that finds [text] anywhere, for a LIKE that declares ESCAPE '\'. */ +internal fun containsLikePattern(text: String): String = "%${escapeLike(text)}%" 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 d5e26369..951a16c3 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 @@ -328,11 +328,11 @@ interface AudioBookDao { """) suspend fun getByLibraryWithLastPlayed(libraryId: String): List - /** Search audiobooks by title or author. */ + /** Search audiobooks by title or author. [query] must be escaped with escapeLike. */ @Query(""" SELECT * FROM AudioBooks - WHERE Title LIKE '%' || :query || '%' - OR Author LIKE '%' || :query || '%' + WHERE Title LIKE '%' || :query || '%' ESCAPE '\' + OR Author LIKE '%' || :query || '%' ESCAPE '\' ORDER BY Title """) suspend fun search(query: String): List 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 b57538c6..51b77f9e 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,6 +4,8 @@ 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.escapeLike import com.ninelivesaudio.app.data.local.converter.toDomain import com.ninelivesaudio.app.data.local.converter.toEntity import com.ninelivesaudio.app.data.local.dao.AudioBookDao @@ -142,7 +144,7 @@ class AudioBookRepository @Inject constructor( suspend fun search(query: String): List { val normalized = query.trim() if (normalized.isEmpty()) return getAll() - return audioBookDao.search(normalized).map { it.toDomain() } + return audioBookDao.search(escapeLike(normalized)).map { it.toDomain() } } /** Get recently played audiobooks for Nine Lives home screen. */ @@ -208,14 +210,9 @@ class AudioBookRepository @Inject constructor( searchQuery: String = "", ): List { val sql = buildLibrarySql(tab, hideFinished, downloadedOnly, searchQuery.isNotBlank()) + val args = buildLibrarySqlArgs(libraryId, searchQuery) - val args = mutableListOf(libraryId) - if (searchQuery.isNotBlank()) { - val pattern = "%${searchQuery}%" - args.addAll(listOf(pattern, pattern, pattern, pattern)) - } - - val results = audioBookDao.getFilteredBooks(SimpleSQLiteQuery(sql, args.toTypedArray())) + val results = audioBookDao.getFilteredBooks(SimpleSQLiteQuery(sql, args)) // Shelf books carry no description, audio files, or tags. They are // for showing and navigating, never for saving back. See toShelfBook. return results.mapCooperatively { it.toShelfBook() } @@ -1055,11 +1052,23 @@ internal fun buildLibrarySql( append(" AND ab.IsDownloaded = 1") } - // Search + // Search. The bound patterns are escaped (see buildLibrarySqlArgs), so + // "%" and "_" typed in the search box match themselves. if (hasSearch) { - append(" AND (ab.Title LIKE ? OR ab.Author LIKE ? OR ab.SeriesName LIKE ? OR ab.Narrator LIKE ?)") + append(" AND (ab.Title LIKE ? ESCAPE '\\' OR ab.Author LIKE ? ESCAPE '\\'") + append(" OR ab.SeriesName LIKE ? ESCAPE '\\' OR ab.Narrator LIKE ? ESCAPE '\\')") } // No ORDER BY: every caller sorts in Kotlin (the Library's sortBooks, the // Auto browse lists), so sorting here too was paid twice. } + +/** + * The arguments for [buildLibrarySql]: the library, then one escaped pattern + * per searched column 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) +} 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 new file mode 100644 index 00000000..152b7f81 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/local/SqlLikeTest.kt @@ -0,0 +1,39 @@ +package com.ninelivesaudio.app.data.local + +import com.ninelivesaudio.app.data.repository.buildLibrarySql +import com.ninelivesaudio.app.data.repository.buildLibrarySqlArgs +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +/** "%" and "_" typed in the Library search match themselves, not anything. */ +class SqlLikeTest { + + @Test + fun `wildcards and the escape character are escaped`() { + assertEquals("100\\%", escapeLike("100%")) + assertEquals("a\\_b", escapeLike("a_b")) + assertEquals("C:\\\\books", escapeLike("C:\\books")) + assertEquals("plain words", escapeLike("plain words")) + assertEquals("%\\%\\_%", containsLikePattern("%_")) + } + + @Test + fun `every searched column 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) + assertEquals(likes, escaped) + } + + @Test + fun `the search binds escaped patterns, one per column`() { + val pattern = "%50\\% off\\_sale%" + assertArrayEquals( + arrayOf("lib", pattern, pattern, pattern, pattern), + buildLibrarySqlArgs("lib", "50% off_sale"), + ) + assertArrayEquals(arrayOf("lib"), buildLibrarySqlArgs("lib", " ")) + } +} From 7c213165db6afa0e6f22d43b82c6c96ab8c4bc9f Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:18:50 -0400 Subject: [PATCH 40/69] A grouped view that loses its unlock shows the flat shelf, not a blank one If the unlock went away while Series, Author, or Genre view was picked (a refund, or Play billing pinned to another account), the shelf clamped the books to the plain list but the screen still laid out the stored grouped view, which had no groups to show. The whole shelf went blank. The shelf result now carries the view mode it was built for, and the screen lays out by that one. The stored pick stays, so unlocking again brings the groups back. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/library/LibraryScreen.kt | 4 +- .../app/ui/library/LibraryViewModel.kt | 67 +++++++++++++------ .../library/LibraryEffectiveViewModeTest.kt | 48 +++++++++++++ 3 files changed, 98 insertions(+), 21 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryEffectiveViewModeTest.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 4cf5a5d6..2366f2ff 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 @@ -255,7 +255,9 @@ fun LibraryScreen( ) } } - if (uiState.viewMode == ViewMode.ALL) { + // 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, 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 3d07e2f5..c74f0219 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 @@ -306,7 +306,11 @@ class LibraryViewModel @Inject constructor( val selectedLibrary: Library? = null, val filteredBooks: List = emptyList(), val searchQuery: String = "", + // The user's chosen view. The shelf shows [effectiveViewMode], which + // is this clamped to ALL when grouping is locked, so a lost unlock + // shows the flat shelf instead of empty groups. val viewMode: ViewMode = ViewMode.ALL, + val effectiveViewMode: ViewMode = ViewMode.ALL, val sortMode: SortMode = SortMode.RECENTLY_PLAYED, // No picker sets or applies these yet. The distinct-groups query that // filled availableGroups ran on every grouped refilter (Genre view @@ -954,6 +958,7 @@ class LibraryViewModel @Inject constructor( private data class FilterResult( val books: List, + val effectiveViewMode: ViewMode, val groupedSections: List, val totalBookCount: Int, ) @@ -990,24 +995,11 @@ class LibraryViewModel @Inject constructor( (snapshot.selectedTab == LibraryTab.Downloaded || snapshot.showDownloadedOnly) } - // Clamped at the point of consumption, not just in the UI. Gating the - // chips stops a free user CHOOSING a premium sort, but says nothing - // about one already selected before a downgrade, which would otherwise - // keep running behind a greyed control. - // - // The stored choice in uiState is left alone, so unlocking restores it. - val isUnlocked = entitlements.current.isUnlocked - val effectiveSort = FreeTier.effectiveSort(snapshot.sortMode, isUnlocked) - val effectiveViewMode = FreeTier.effectiveViewMode(snapshot.viewMode, isUnlocked) - - // Sort and group in-memory (complex logic stays in Kotlin) - currentCoroutineContext().ensureActive() - val sortedBooks = sortBooks(books, effectiveSort) - currentCoroutineContext().ensureActive() - val groupedSections = buildGroupedSections( - books = sortedBooks, - viewMode = effectiveViewMode, - sortMode = effectiveSort, + val shelf = arrangeShelf( + books = books, + storedSort = snapshot.sortMode, + storedViewMode = snapshot.viewMode, + isUnlocked = entitlements.current.isUnlocked, ) // Get total count from DB (not from filtered set) @@ -1015,8 +1007,9 @@ class LibraryViewModel @Inject constructor( if (!filterPublication.isCurrent(snapshot.request)) return null return FilterResult( - books = sortedBooks, - groupedSections = groupedSections, + books = shelf.books, + effectiveViewMode = shelf.viewMode, + groupedSections = shelf.sections, totalBookCount = totalCount, ) } @@ -1030,6 +1023,7 @@ class LibraryViewModel @Inject constructor( .apply { addAll(groupKeys - previousKeys) } it.copy( filteredBooks = result.books, + effectiveViewMode = result.effectiveViewMode, groupedSections = result.groupedSections, expandedGroups = expandedGroups, totalBookCount = result.totalBookCount, @@ -1284,6 +1278,39 @@ internal fun shouldSyncOnLibraryLoad(isLocalLibrary: Boolean, isOnline: Boolean) // ─── Grouping helpers (internal for testability) ────────────────────────── +/** The shelf as shown: sorted books, the view mode in effect, and its groups. */ +internal data class ArrangedShelf( + val books: List, + val viewMode: ViewMode, + val sections: List, +) + +/** + * Sorts and groups the filtered books. Sort and view mode are clamped here, + * at the point of use, not just in the UI. Gating the chips stops a free user + * CHOOSING a premium mode, but says nothing about one already selected before + * a downgrade, which would otherwise keep running behind a greyed control. + * The stored choice is left alone, so unlocking restores it, and the screen + * lays out by [ArrangedShelf.viewMode], never the stored one. + */ +internal suspend fun arrangeShelf( + books: List, + storedSort: SortMode, + storedViewMode: ViewMode, + isUnlocked: Boolean, +): ArrangedShelf { + val sort = FreeTier.effectiveSort(storedSort, isUnlocked) + val viewMode = FreeTier.effectiveViewMode(storedViewMode, isUnlocked) + currentCoroutineContext().ensureActive() + val sorted = sortBooks(books, sort) + currentCoroutineContext().ensureActive() + return ArrangedShelf( + books = sorted, + viewMode = viewMode, + sections = buildGroupedSections(books = sorted, viewMode = viewMode, sortMode = sort), + ) +} + /** * 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 diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryEffectiveViewModeTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryEffectiveViewModeTest.kt new file mode 100644 index 00000000..e68c6a57 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryEffectiveViewModeTest.kt @@ -0,0 +1,48 @@ +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.Assert.assertTrue +import org.junit.Test + +/** + * A grouped view whose unlock was lost (a refund, a Play account pin) used to + * show a blank shelf: the books were clamped to ALL but the screen still laid + * out the stored SERIES view, which had no groups. The shelf now carries the + * mode in effect. + */ +class LibraryEffectiveViewModeTest { + + private val books = listOf( + AudioBook(id = "1", title = "One", seriesName = "Saga"), + AudioBook(id = "2", title = "Two", seriesName = "Saga"), + AudioBook(id = "3", title = "Three"), + ) + + @Test + fun `stored series without the unlock lays out the flat shelf with every book`() = runBlocking { + val shelf = arrangeShelf(books, SortMode.TITLE_AZ, storedViewMode = ViewMode.SERIES, isUnlocked = false) + + assertEquals(ViewMode.ALL, shelf.viewMode) + assertTrue(shelf.sections.isEmpty()) + assertEquals(setOf("1", "2", "3"), shelf.books.map { it.id }.toSet()) + } + + @Test + fun `stored series with the unlock groups the shelf`() = runBlocking { + val shelf = arrangeShelf(books, SortMode.TITLE_AZ, storedViewMode = ViewMode.SERIES, isUnlocked = true) + + assertEquals(ViewMode.SERIES, shelf.viewMode) + assertEquals(listOf("Saga", "Standalone/Unknown Series"), shelf.sections.map { it.key }) + } + + @Test + fun `every locked grouped view falls back to all`() = runBlocking { + for (mode in listOf(ViewMode.SERIES, ViewMode.AUTHOR, ViewMode.GENRE)) { + val shelf = arrangeShelf(books, SortMode.RECENTLY_PLAYED, storedViewMode = mode, isUnlocked = false) + assertEquals("$mode", ViewMode.ALL, shelf.viewMode) + assertEquals("$mode", 3, shelf.books.size) + } + } +} From d7a19ddd98c661545c7b6b64202afbea9e316285 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:20:13 -0400 Subject: [PATCH 41/69] Big grouped views start collapsed and build their rows off the main thread Series, Author, and Genre views opened with every group expanded, and the header and row list was rebuilt inside the screen on the main thread for every tap on a header. A big Genre view, where a book sits in each of its genres, meant about 150,000 rows per tap and a visible stutter. Grouped views with more than 50 groups now start with every group closed. Smaller libraries open exactly as before. A group the user opens or closes by hand stays that way for the rest of the session, across tab switches, searches, and refilters, in each view mode. The header and row list is built on a background thread whenever the groups or a choice change. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/library/LibraryScreen.kt | 6 +- .../app/ui/library/LibraryViewModel.kt | 75 +++++++++++++++---- .../ui/library/LibraryGroupExpansionTest.kt | 69 +++++++++++++++++ 3 files changed, 130 insertions(+), 20 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryGroupExpansionTest.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 2366f2ff..96ef6b02 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 @@ -83,10 +83,6 @@ fun LibraryScreen( viewModel.onScreenEntered() } - // Flatten grouped items only when groupedSections or expandedGroups change - val groupedListItems = remember(uiState.groupedSections, uiState.expandedGroups) { - flattenGroupedItems(uiState.groupedSections, uiState.expandedGroups) - } val shelfDecision = remember( uiState.lastSyncResult, uiState.lastSyncSequence, @@ -273,7 +269,7 @@ fun LibraryScreen( } else { // Grouped expandable list in SERIES / AUTHOR / GENRE modes itemsIndexed( - items = groupedListItems, + items = uiState.groupedListItems, key = { _, item -> when (item) { is LibraryListItem.GroupHeader -> "header-${item.groupKey}" 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 c74f0219..d5b93931 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 @@ -319,7 +319,9 @@ class LibraryViewModel @Inject constructor( val selectedGroupFilter: String? = null, val availableGroups: List = emptyList(), val groupedSections: List = emptyList(), - val expandedGroups: Set = emptySet(), + // Headers and rows for the grouped views, built off the main thread + // whenever the groups or an expand choice change. See groupChoices. + val groupedListItems: List = emptyList(), val selectedTab: LibraryTab = LibraryTab.All, val isLocalMode: Boolean = false, // LOCAL mode shows the Archive tab val hideFinished: Boolean = false, @@ -409,7 +411,30 @@ class LibraryViewModel @Inject constructor( // A library picked on this screen whose settings write has not finished. private var pendingLibraryPickId: String? = null + // The groups the user expanded or collapsed by hand, per view mode, kept + // for as long as this ViewModel lives (tab switches included). A group + // with no choice follows groupsStartExpanded. + private val groupChoices = MutableStateFlow>>(emptyMap()) + init { + // Flatten the grouped shelf on Default whenever its groups or the + // expand choices change. Doing it in composition ran on the main + // thread for every toggle, about 150,000 rows in a big Genre view. + viewModelScope.launch { + combine( + _uiState + .map { it.groupedSections to it.effectiveViewMode } + .distinctUntilChanged { old, new -> old.first === new.first && old.second == new.second }, + groupChoices, + ) { (sections, mode), choices -> Triple(sections, mode, choices[mode].orEmpty()) } + .collectLatest { (sections, _, choices) -> + val items = withContext(Dispatchers.Default) { flattenGroupedItems(sections, choices) } + _uiState.update { + if (it.groupedSections === sections) it.copy(groupedListItems = items) else it + } + } + } + // Observe connectivity and auto-filter to downloaded when offline viewModelScope.launch { connectivityMonitor.connectionStatus.collect { status -> @@ -894,11 +919,10 @@ class LibraryViewModel @Inject constructor( } fun onGroupExpansionToggled(groupKey: String) { - _uiState.update { state -> - val updated = state.expandedGroups.toMutableSet().apply { - if (!add(groupKey)) remove(groupKey) - } - state.copy(expandedGroups = updated) + val state = _uiState.value + val mode = state.effectiveViewMode + groupChoices.update { all -> + all + (mode to toggledGroupChoices(all[mode].orEmpty(), groupKey, state.groupedSections.size)) } } @@ -1016,16 +1040,10 @@ class LibraryViewModel @Inject constructor( private fun publishFilterResult(result: FilterResult) { _uiState.update { - val groupKeys = result.groupedSections.map { section -> section.key }.toSet() - val previousKeys = it.groupedSections.map { section -> section.key }.toSet() - val expandedGroups = it.expandedGroups - .filterTo(mutableSetOf()) { key -> key in groupKeys } - .apply { addAll(groupKeys - previousKeys) } it.copy( filteredBooks = result.books, effectiveViewMode = result.effectiveViewMode, groupedSections = result.groupedSections, - expandedGroups = expandedGroups, totalBookCount = result.totalBookCount, ) } @@ -1171,7 +1189,7 @@ internal fun LibraryViewModel.UiState.withLibrarySelection( filteredBooks = if (selectedLibrary == null) emptyList() else filteredBooks, availableGroups = if (selectedLibrary == null) emptyList() else availableGroups, groupedSections = if (selectedLibrary == null) emptyList() else groupedSections, - expandedGroups = if (selectedLibrary == null) emptySet() else expandedGroups, + groupedListItems = if (selectedLibrary == null) emptyList() else groupedListItems, totalBookCount = if (selectedLibrary == null) 0 else totalBookCount, // A stale outcome from whatever was PREVIOUSLY selected must never be // read as this (possibly different) library's own verdict — cleared on @@ -1336,12 +1354,39 @@ internal fun buildGroupedSections( .map { it.section } } +/** Grouped views start collapsed when they have more groups than this. */ +internal const val COLLAPSE_GROUPS_ABOVE = 50 + +/** + * Whether a group the user has not touched starts open. A small library keeps + * every group open as before. A big one starts with headers only, or a Genre + * view of 50,000 books opened as one endless list. + */ +internal fun groupsStartExpanded(groupCount: Int): Boolean = groupCount <= COLLAPSE_GROUPS_ABOVE + +/** + * The user's choices after tapping [groupKey]: the opposite of what that + * header showed, which is their earlier choice or else the default for + * [groupCount] groups. + */ +internal fun toggledGroupChoices( + choices: Map, + groupKey: String, + groupCount: Int, +): Map = + choices + (groupKey to !(choices[groupKey] ?: groupsStartExpanded(groupCount))) + +/** + * Headers and rows for a grouped view. A group the user expanded or collapsed + * by hand ([choices]) stays that way, any other follows [groupsStartExpanded]. + */ internal fun flattenGroupedItems( groupedSections: List, - expandedGroups: Set, + choices: Map, ): List = buildList { + val startExpanded = groupsStartExpanded(groupedSections.size) groupedSections.forEach { section -> - val expanded = section.key in expandedGroups + val expanded = choices[section.key] ?: startExpanded add( LibraryListItem.GroupHeader( groupKey = section.key, diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryGroupExpansionTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryGroupExpansionTest.kt new file mode 100644 index 00000000..72665078 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryGroupExpansionTest.kt @@ -0,0 +1,69 @@ +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 + +/** + * Grouped views start collapsed only past 50 groups. Small libraries open as + * before, and a group the user opened or closed stays that way. + */ +class LibraryGroupExpansionTest { + + private fun sections(count: Int) = (1..count).map { index -> + GroupedSection( + key = "group-$index", + title = "Group $index", + books = listOf(AudioBook(id = "a-$index"), AudioBook(id = "b-$index")), + ) + } + + private fun List.headers() = filterIsInstance() + private fun List.rows() = filterIsInstance() + + @Test + fun `50 groups start open, 51 start closed`() { + val fifty = flattenGroupedItems(sections(COLLAPSE_GROUPS_ABOVE), emptyMap()) + assertTrue(fifty.headers().all { it.isExpanded }) + assertEquals(100, fifty.rows().size) + + val fiftyOne = flattenGroupedItems(sections(COLLAPSE_GROUPS_ABOVE + 1), emptyMap()) + assertTrue(fiftyOne.headers().none { it.isExpanded }) + assertEquals(51, fiftyOne.size) + } + + @Test + fun `a hand-picked choice beats the default both ways`() { + val big = flattenGroupedItems(sections(200), mapOf("group-7" to true)) + assertEquals(listOf("group-7"), big.headers().filter { it.isExpanded }.map { it.groupKey }) + assertEquals(listOf("a-7", "b-7"), big.rows().map { it.book.id }) + + val small = flattenGroupedItems(sections(3), mapOf("group-2" to false)) + assertEquals(listOf(true, false, true), small.headers().map { it.isExpanded }) + assertEquals(4, small.rows().size) + } + + @Test + fun `a tap flips what the header showed`() { + // Big view: closed by default, a tap opens it, a second tap closes it. + val opened = toggledGroupChoices(emptyMap(), "group-7", groupCount = 200) + assertEquals(mapOf("group-7" to true), opened) + assertEquals(mapOf("group-7" to false), toggledGroupChoices(opened, "group-7", groupCount = 200)) + + // Small view: open by default, so the first tap closes it. + assertEquals(mapOf("group-2" to false), toggledGroupChoices(emptyMap(), "group-2", groupCount = 3)) + } + + @Test + fun `choices survive a refilter that changes the group count`() { + val choices = toggledGroupChoices(emptyMap(), "group-7", groupCount = 200) + // A search narrows to 10 groups (all open by default) and back to 200. + val narrowed = flattenGroupedItems(sections(10), choices) + assertTrue(narrowed.headers().all { it.isExpanded }) + val widened = flattenGroupedItems(sections(200), choices) + assertTrue(widened.headers().single { it.groupKey == "group-7" }.isExpanded) + assertFalse(widened.headers().single { it.groupKey == "group-8" }.isExpanded) + } +} From 583780ec2471715384fe3b2833f7d239868cb192 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:21:56 -0400 Subject: [PATCH 42/69] A failed Library load or filter shows a banner with Retry The Library kept an error message when loading libraries, loading books, or filtering the shelf threw, but nothing on screen ever showed it. A failed query just left the old shelf, or nothing, with no word why. The message now shows as a banner under the search bar on every shelf state, with Retry (the same full refresh as pull to refresh) and a dismiss button. The lines read like the rest of the screen, such as "Saved books could not be read.", instead of raw exception text. The exception goes to the log. Co-Authored-By: Claude Opus 5.5 --- .../app/ui/library/LibraryErrorBannerTest.kt | 45 +++++++++++++++++ .../app/ui/library/LibraryScreen.kt | 50 +++++++++++++++++++ .../app/ui/library/LibraryViewModel.kt | 31 ++++++++++-- .../ui/library/LibraryErrorBannerCopyTest.kt | 32 ++++++++++++ 4 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryErrorBannerTest.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryErrorBannerCopyTest.kt diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryErrorBannerTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryErrorBannerTest.kt new file mode 100644 index 00000000..96efc797 --- /dev/null +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryErrorBannerTest.kt @@ -0,0 +1,45 @@ +package com.ninelivesaudio.app.ui.library + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ninelivesaudio.app.ui.theme.NineLivesAudioTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LibraryErrorBannerTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun bannerShowsTheLineAndOffersRetryAndDismiss() { + var retries by mutableIntStateOf(0) + var dismissals by mutableIntStateOf(0) + + composeTestRule.setContent { + NineLivesAudioTheme { + LibraryErrorBanner( + message = libraryErrorMessage(LibraryLoadFailure.SHELF), + onRetry = { retries++ }, + onDismiss = { dismissals++ }, + ) + } + } + + composeTestRule.onNodeWithText("Saved books could not be read.").assertExists() + composeTestRule.onNodeWithText("Retry").performClick() + composeTestRule.onNodeWithContentDescription("Dismiss").performClick() + + assertEquals(1, retries) + assertEquals(1, dismissals) + } +} 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 96ef6b02..1fcb771d 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 @@ -193,6 +193,16 @@ fun LibraryScreen( } } + // A load or filter that threw. Shown over every shelf state, + // full or empty, so a failed query is never silent. + uiState.errorMessage?.let { message -> + LibraryErrorBanner( + message = message, + onRetry = viewModel::refresh, + onDismiss = viewModel::dismissError, + ) + } + Spacer(modifier = Modifier.height(4.dp)) } @@ -1082,6 +1092,46 @@ private fun SyncWarningBanner( } } +/** A failed load or filter, with Retry and a way to dismiss it. */ +@Composable +internal fun LibraryErrorBanner( + message: String, + onRetry: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = NineLivesTheme.colors.archiveVoidSurface, + border = BorderStroke(1.dp, NineLivesTheme.colors.archiveOutline), + shape = RoundedCornerShape(8.dp), + ) { + Row( + modifier = Modifier.padding(start = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = message, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = NineLivesTheme.colors.archiveTextSecondary, + ) + TextButton(onClick = onRetry) { + Text("Retry") + } + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = "Dismiss", + tint = NineLivesTheme.colors.archiveTextMuted, + modifier = Modifier.size(18.dp), + ) + } + } + } +} + // ─── Empty State ────────────────────────────────────────────────────────── @Composable 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 d5b93931..30af19e8 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 @@ -1,5 +1,6 @@ package com.ninelivesaudio.app.ui.library +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ninelivesaudio.app.entitlement.EntitlementRepository @@ -698,7 +699,7 @@ class LibraryViewModel @Inject constructor( } catch (e: Exception) { rethrowLibraryLoadCancellation(e) _uiState.update { - it.copy(errorMessage = "Failed to load libraries: ${e.message}") + it.copy(errorMessage = loadFailureMessage(LibraryLoadFailure.LIBRARIES, e)) } } finally { updateLibraryLoadStateIfActive { @@ -803,7 +804,7 @@ class LibraryViewModel @Inject constructor( } catch (e: Exception) { rethrowLibraryLoadCancellation(e) _uiState.update { - it.copy(errorMessage = "Failed to load audiobooks: ${e.message}") + it.copy(errorMessage = loadFailureMessage(LibraryLoadFailure.BOOKS, e)) } } return itemLoad @@ -965,6 +966,16 @@ class LibraryViewModel @Inject constructor( _uiState.update { it.copy(errorMessage = null) } } + /** The banner line for [failure]. The exception goes to the log, not the screen. */ + private fun loadFailureMessage(failure: LibraryLoadFailure, error: Throwable): String { + Log.w(TAG, "Library load failed: $failure", error) + return libraryErrorMessage(failure) + } + + private companion object { + const val TAG = "LibraryViewModel" + } + // ─── Filter/Sort Logic ──────────────────────────────────────────────── private data class FilterSnapshot( @@ -1078,7 +1089,7 @@ class LibraryViewModel @Inject constructor( // Decoding and sorting thousands of books stays off the main thread. load = { withContext(Dispatchers.Default) { buildFilterResult(snapshot) } }, onFailure = { e -> - _uiState.update { it.copy(errorMessage = "Failed to load audiobooks: ${e.message}") } + _uiState.update { it.copy(errorMessage = loadFailureMessage(LibraryLoadFailure.SHELF, e)) } }, ) { result -> result?.let(::publishFilterResult) @@ -1095,6 +1106,20 @@ class LibraryViewModel @Inject constructor( // ─── Load-path decisions (internal for testability) ─────────────────────── +/** What failed, for the Library's error banner. */ +internal enum class LibraryLoadFailure { LIBRARIES, BOOKS, SHELF } + +/** + * The banner line for a failed load, in the screen's own voice. The banner + * sits beside a Retry button. The exception text is for the log only, it + * means nothing to a listener. + */ +internal fun libraryErrorMessage(failure: LibraryLoadFailure): String = when (failure) { + LibraryLoadFailure.LIBRARIES -> "Libraries could not be loaded." + LibraryLoadFailure.BOOKS -> "Books could not be loaded." + LibraryLoadFailure.SHELF -> "Saved books could not be read." +} + internal sealed interface LibraryShelfDecision { data object Empty : LibraryShelfDecision data class LoadFailed(val result: SyncResult) : LibraryShelfDecision diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryErrorBannerCopyTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryErrorBannerCopyTest.kt new file mode 100644 index 00000000..01bf340f --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryErrorBannerCopyTest.kt @@ -0,0 +1,32 @@ +package com.ninelivesaudio.app.ui.library + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The Library's error banner speaks like the rest of the screen ("Library + * could not be loaded") and never shows raw exception text, which used to + * read "Failed to load audiobooks: SQLiteException ...". + */ +class LibraryErrorBannerCopyTest { + + @Test + fun `each failure has its own line in the screen voice`() { + assertEquals("Libraries could not be loaded.", libraryErrorMessage(LibraryLoadFailure.LIBRARIES)) + assertEquals("Books could not be loaded.", libraryErrorMessage(LibraryLoadFailure.BOOKS)) + assertEquals("Saved books could not be read.", libraryErrorMessage(LibraryLoadFailure.SHELF)) + } + + @Test + fun `no line carries exception detail or a dash`() { + for (failure in LibraryLoadFailure.entries) { + val line = libraryErrorMessage(failure) + assertTrue(line, line.endsWith(".")) + for (banned in listOf(":", ";", "Failed", "\u2014", "\u2013", " - ")) { + assertFalse("$failure: $line", line.contains(banned)) + } + } + } +} From ffaf76e018f3217bc0f958713eac19282b8b8c70 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:22:52 -0400 Subject: [PATCH 43/69] Group headers tell TalkBack they are headings, open or closed A Series, Author, or Genre header was a plain clickable row. TalkBack could not jump from group to group, did not say whether a group was open, and did not say what a tap would do, which in a library with thousands of groups left a screen reader user lost. Headers are now headings with an "Expanded" or "Collapsed" state and a "Expand group" or "Collapse group" action label, read as one button. Co-Authored-By: Claude Opus 5.5 --- ...aryGroupHeaderSemanticsInstrumentedTest.kt | 53 +++++++++++++++++++ .../app/ui/library/LibraryScreen.kt | 27 +++++++++- .../LibraryGroupHeaderSemanticsTest.kt | 20 +++++++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryGroupHeaderSemanticsInstrumentedTest.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryGroupHeaderSemanticsTest.kt diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryGroupHeaderSemanticsInstrumentedTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryGroupHeaderSemanticsInstrumentedTest.kt new file mode 100644 index 00000000..fe83b405 --- /dev/null +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryGroupHeaderSemanticsInstrumentedTest.kt @@ -0,0 +1,53 @@ +package com.ninelivesaudio.app.ui.library + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ninelivesaudio.app.ui.theme.NineLivesAudioTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LibraryGroupHeaderSemanticsInstrumentedTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun headerIsAHeadingWithItsStateAndClickLabel() { + var expanded by mutableStateOf(false) + + composeTestRule.setContent { + NineLivesAudioTheme { + GroupHeaderRow(title = "Saga", count = 3, isExpanded = expanded, onClick = { expanded = !expanded }) + } + } + + val header = composeTestRule.onNodeWithText("Saga", substring = true) + header + .assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.Heading)) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "Collapsed")) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button)) + assertEquals( + "Expand group", + header.fetchSemanticsNode().config.getOrNull(SemanticsActions.OnClick)?.label, + ) + + header.performClick() + + composeTestRule.onNodeWithText("Saga", substring = true) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "Expanded")) + } +} 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 1fcb771d..8c65fcbb 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 @@ -33,6 +33,10 @@ import androidx.compose.ui.graphics.Brush 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.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign @@ -711,17 +715,36 @@ private fun LibraryFiltersRow( // ─── Group Header Row ──────────────────────────────────────────────────── +/** What TalkBack says a group header is: open or closed. */ +internal fun groupHeaderStateDescription(isExpanded: Boolean): String = + if (isExpanded) "Expanded" else "Collapsed" + +/** What TalkBack says a tap on a group header does. */ +internal fun groupHeaderClickLabel(isExpanded: Boolean): String = + if (isExpanded) "Collapse group" else "Expand group" + @Composable -private fun GroupHeaderRow( +internal fun GroupHeaderRow( title: String, count: Int, isExpanded: Boolean, onClick: () -> Unit, ) { + // A heading, so TalkBack can jump group to group, that says whether it is + // open and what a tap does. Thousands of groups were otherwise one long + // run of unlabeled buttons. Surface( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick), + .semantics(mergeDescendants = true) { + heading() + stateDescription = groupHeaderStateDescription(isExpanded) + } + .clickable( + onClickLabel = groupHeaderClickLabel(isExpanded), + role = Role.Button, + onClick = onClick, + ), shape = RoundedCornerShape(10.dp), color = NineLivesTheme.colors.archiveVoidElevated, border = BorderStroke(1.dp, NineLivesTheme.colors.archiveOutline), diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryGroupHeaderSemanticsTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryGroupHeaderSemanticsTest.kt new file mode 100644 index 00000000..faeff428 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryGroupHeaderSemanticsTest.kt @@ -0,0 +1,20 @@ +package com.ninelivesaudio.app.ui.library + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** TalkBack hears whether a group is open and what a tap on it does. */ +class LibraryGroupHeaderSemanticsTest { + + @Test + fun `an open group says so and offers to collapse`() { + assertEquals("Expanded", groupHeaderStateDescription(isExpanded = true)) + assertEquals("Collapse group", groupHeaderClickLabel(isExpanded = true)) + } + + @Test + fun `a closed group says so and offers to expand`() { + assertEquals("Collapsed", groupHeaderStateDescription(isExpanded = false)) + assertEquals("Expand group", groupHeaderClickLabel(isExpanded = false)) + } +} From c6a66018ab99ed31bf45b33052e4b86340913ab4 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:28:29 -0400 Subject: [PATCH 44/69] Android Auto search escapes its text with the Library's LIKE helper Package A and Package B each wrote the same backslash escape for LIKE patterns, one for the Library search box and one for Auto search. Auto search now builds its pattern with the shared containsLikePattern and its own copy of the escape loop is gone. It still trims the query and still searches nothing when the query is blank, and its tests pass unchanged. Co-Authored-By: Claude Opus 5.5 --- .../app/service/AutoBookItem.kt | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) 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 49f647ec..254549c2 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt @@ -1,5 +1,6 @@ package com.ninelivesaudio.app.service +import com.ninelivesaudio.app.data.local.containsLikePattern import com.ninelivesaudio.app.data.local.entity.AutoBrowseRow import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.AppSettings @@ -79,20 +80,12 @@ internal suspend fun autoBrowsePage( internal const val AUTO_SEARCH_RESULT_CAP = 100 /** - * [query] as a LIKE "contains" pattern with backslash escapes, so `%`, `_` - * and `\` match themselves. Null for a blank query, which searches nothing. + * [query] as a LIKE "contains" pattern escaped by [containsLikePattern], so + * `%`, `_` and `\` match themselves. Null for a blank query, which searches + * nothing. */ -internal fun autoSearchPattern(query: String): String? { - val trimmed = query.trim() - if (trimmed.isEmpty()) return null - val escaped = buildString(trimmed.length + 8) { - for (char in trimmed) { - if (char == '\\' || char == '%' || char == '_') append('\\') - append(char) - } - } - return "%$escaped%" -} +internal fun autoSearchPattern(query: String): String? = + query.trim().takeIf { it.isNotEmpty() }?.let(::containsLikePattern) /** * One page of Auto search hits in the active library and source, never past From f55e93d7b8ec0b7d2a6e86b0c45b61fdc308325d Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:29:44 -0400 Subject: [PATCH 45/69] The old title and author search goes, now that Android Auto pages its own Android Auto used to search with AudioBookRepository.search and browse with the Library's shelf query. Auto now reads its own paged browse and search queries, so search, the getAll it fell back to on a blank query, and the DAO search query behind it had no callers left outside one instrumented assertion. They are removed, along with that assertion, since Auto's own instrumented test already checks that % and _ match literally. The shelf query's comments no longer say Auto reads it. Co-Authored-By: Claude Opus 5.5 --- .../local/ShelfProjectionInstrumentedTest.kt | 2 -- .../app/data/local/dao/AudioBookDao.kt | 9 --------- .../app/data/local/entity/ShelfBookRow.kt | 10 +++++----- .../app/data/repository/AudioBookRepository.kt | 16 ++-------------- 4 files changed, 7 insertions(+), 30 deletions(-) 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 faf6a834..757b1ed0 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 @@ -100,8 +100,6 @@ class ShelfProjectionInstrumentedTest { assertEquals(setOf("pct"), shelfSearch("%")) assertEquals(setOf("under"), shelfSearch("_")) assertEquals(setOf("pct", "num"), shelfSearch("100")) - assertEquals(setOf("pct"), dao.search(escapeLike("%")).map { it.id }.toSet()) - assertEquals(setOf("under"), dao.search(escapeLike("_")).map { it.id }.toSet()) } private fun book( 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 951a16c3..462eeea6 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 @@ -328,15 +328,6 @@ interface AudioBookDao { """) suspend fun getByLibraryWithLastPlayed(libraryId: String): List - /** Search audiobooks by title or author. [query] must be escaped with escapeLike. */ - @Query(""" - SELECT * FROM AudioBooks - WHERE Title LIKE '%' || :query || '%' ESCAPE '\' - OR Author LIKE '%' || :query || '%' ESCAPE '\' - ORDER BY Title - """) - suspend fun search(query: String): List - /** Update just the progress fields on an audiobook. */ @Query("UPDATE AudioBooks SET CurrentTimeSeconds = :currentTimeSeconds, Progress = :progress, IsFinished = :isFinished WHERE Id = :id") suspend fun updateProgress(id: String, currentTimeSeconds: Double, progress: Double, isFinished: Int) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/ShelfBookRow.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/ShelfBookRow.kt index 4131cf17..2d28584f 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/ShelfBookRow.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/ShelfBookRow.kt @@ -8,11 +8,11 @@ import com.ninelivesaudio.app.domain.util.toEpochMillis import kotlin.time.Duration.Companion.seconds /** - * One Library shelf row: every column a shelf, a group, or an Auto browse row - * shows, without the heavy ones. Description is most of a server row's - * weight (about 1.9 KB a book on a big library), and the audio file and tag - * lists are never shown on a shelf, so reading `ab.*` for 50,000 books - * decoded about 115 MB per filter change for nothing. + * One Library shelf row: every column a shelf or a group row shows, without + * the heavy ones. Description is most of a server row's weight (about 1.9 KB + * a book on a big library), and the audio file and tag lists are never shown + * on a shelf, so reading `ab.*` for 50,000 books decoded about 115 MB per + * filter change for nothing. * * ChaptersJson stays because the row's "Ch x/y" label needs it, and it is * "[]" for every list-synced server book anyway. 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 51b77f9e..e9993a07 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 @@ -5,7 +5,6 @@ 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.escapeLike import com.ninelivesaudio.app.data.local.converter.toDomain import com.ninelivesaudio.app.data.local.converter.toEntity import com.ninelivesaudio.app.data.local.dao.AudioBookDao @@ -112,10 +111,6 @@ class AudioBookRepository @Inject constructor( fun observeById(id: String): Flow = audioBookDao.observeById(id).map { it?.toDomain() } - /** Get all audiobooks from local DB (one-shot). */ - suspend fun getAll(): List = - audioBookDao.getAll().map { it.toDomain() } - /** Get audiobooks by library (one-shot). */ suspend fun getByLibrary(libraryId: String): List = audioBookDao.getByLibrary(libraryId).map { it.toDomain() } @@ -140,13 +135,6 @@ class AudioBookRepository @Inject constructor( suspend fun getById(id: String): AudioBook? = audioBookDao.getById(id)?.toDomain() - /** Search audiobooks by title or author. */ - suspend fun search(query: String): List { - val normalized = query.trim() - if (normalized.isEmpty()) return getAll() - return audioBookDao.search(escapeLike(normalized)).map { it.toDomain() } - } - /** Get recently played audiobooks for Nine Lives home screen. */ suspend fun getRecentlyPlayed(limit: Int = 9): List> = audioBookDao.getRecentlyPlayed(limit).map { result -> @@ -1059,8 +1047,8 @@ internal fun buildLibrarySql( append(" OR ab.SeriesName LIKE ? ESCAPE '\\' OR ab.Narrator LIKE ? ESCAPE '\\')") } - // No ORDER BY: every caller sorts in Kotlin (the Library's sortBooks, the - // Auto browse lists), so sorting here too was paid twice. + // No ORDER BY: the Library sorts in Kotlin (sortBooks), so sorting here + // too was paid twice. Android Auto reads its own paged queries. } /** From 515b1096797c66d069f3455d2a3ce82ea05d1369 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:22:26 -0400 Subject: [PATCH 46/69] A book in two series groups under its first series instead of a made-up one The library list never sends the structured series array. It sends one string, " #" per series joined with ", ". The parser only accepted a number at the very end, so "S1 #1, S2 #3" became a series named "S1 #1, S2" at sequence 3, and a sequence like "1-3" stayed glued to the name, giving every such book a series of its own in Series view. The parser now takes the first " #" segment, ending at the separator or the end, and accepts any sequence text. A comma or a "#" inside a series name still stays in the name. It moved to its own file so it can be tested directly. Books already cached keep the old series name until the sync next rewrites them. Co-Authored-By: Claude Opus 5.5 --- .../app/data/remote/ApiService.kt | 19 -------- .../app/data/remote/SeriesNameParser.kt | 37 +++++++++++++++ .../app/data/remote/SeriesNameParserTest.kt | 46 +++++++++++++++++++ 3 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/data/remote/SeriesNameParser.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/data/remote/SeriesNameParserTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt index 91c211bc..de9ca42f 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt @@ -1138,25 +1138,6 @@ class ApiService @Inject constructor( ) } - /** - * Parses the ABS combined seriesName field (e.g. "Dungeon Crawler Carl #7") into a - * (name, sequence) pair. The non-expanded library items endpoint returns this single - * concatenated string instead of the structured series array that the expanded endpoint - * provides. Supported formats: - * "Series Name #7" → ("Series Name", "7") - * "Series Name #1.5" → ("Series Name", "1.5") - * "Series Name" → ("Series Name", null) - */ - private fun parseSeriesNameField(seriesName: String): Pair { - val trimmed = seriesName.trim() - val hashMatch = Regex("""^(.+?)\s*#([\d.]+)\s*$""").find(trimmed) - return if (hashMatch != null) { - hashMatch.groupValues[1].trim() to hashMatch.groupValues[2] - } else { - trimmed to null - } - } - private fun normalizeProgress(value: Double): Double { val nonNegative = value.coerceAtLeast(0.0) return if (nonNegative > 1.0) { diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/SeriesNameParser.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/SeriesNameParser.kt new file mode 100644 index 00000000..1b591b85 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/SeriesNameParser.kt @@ -0,0 +1,37 @@ +package com.ninelivesaudio.app.data.remote + +/** + * Splits the list endpoint's combined `seriesName` into the first series' + * name and sequence. + * + * Audiobookshelf's list endpoint never sends the structured series array. It + * sends one string, `" #"` per series, joined with `", "`: + * "Series Name #7" -> ("Series Name", "7") + * "Series Name #1.5" -> ("Series Name", "1.5") + * "S1 #1, S2 #3" -> ("S1", "1") first series only + * "Love, Death #2" -> ("Love, Death", "2") a comma inside a name + * "Saga #1-3" -> ("Saga", "1-3") sequences are free text + * "Series Name" -> ("Series Name", null) + * + * The old parser only accepted a numeric sequence at the very end, so a + * multi-series book became one bogus series ("S1 #1, S2") and a non-numeric + * sequence kept its "#..." in the name, splitting the series per book. + * + * A first series with no sequence followed by one with a sequence + * ("S1, S2 #3") reads the same as a series named "S1, S2". The string + * carries nothing that tells them apart, so it parses as the latter. + */ +internal fun parseSeriesNameField(seriesName: String): Pair { + val trimmed = seriesName.trim() + if (trimmed.isEmpty()) return null to null + val match = FIRST_SERIES_SEGMENT.find(trimmed) + return if (match != null) { + match.groupValues[1].trim() to match.groupValues[2].trim() + } else { + trimmed to null + } +} + +// Lazy name, then "#", then a sequence with no comma or "#" in it, ending at +// the series separator or the end of the string. +private val FIRST_SERIES_SEGMENT = Regex("""^(.+?)\s*#([^,#]+?)\s*(?:,\s*|$)""") diff --git a/app/src/test/java/com/ninelivesaudio/app/data/remote/SeriesNameParserTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/remote/SeriesNameParserTest.kt new file mode 100644 index 00000000..bf698337 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/remote/SeriesNameParserTest.kt @@ -0,0 +1,46 @@ +package com.ninelivesaudio.app.data.remote + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SeriesNameParserTest { + + @Test + fun `a single series keeps its name and numeric sequence`() { + assertEquals("Dungeon Crawler Carl" to "7", parseSeriesNameField("Dungeon Crawler Carl #7")) + assertEquals("Series Name" to "1.5", parseSeriesNameField("Series Name #1.5")) + } + + @Test + fun `a multi series book groups under its first series only`() { + assertEquals("S1" to "1", parseSeriesNameField("S1 #1, S2 #3")) + assertEquals("The Expanse" to "4", parseSeriesNameField("The Expanse #4, Space Operas #12, Hugo Nominees #2")) + } + + @Test + fun `a comma inside the series name stays in the name`() { + assertEquals("Love, Death" to "2", parseSeriesNameField("Love, Death #2")) + } + + @Test + fun `a non numeric sequence is still split from the name`() { + assertEquals("Saga" to "1-3", parseSeriesNameField("Saga #1-3")) + assertEquals("Discworld" to "Book 2", parseSeriesNameField("Discworld #Book 2")) + } + + @Test + fun `a hash inside the series name stays in the name`() { + assertEquals("C# Recipes" to "2", parseSeriesNameField("C# Recipes #2")) + } + + @Test + fun `a plain series name has no sequence`() { + assertEquals("Saga" to null, parseSeriesNameField("Saga")) + assertEquals("Saga" to null, parseSeriesNameField(" Saga ")) + } + + @Test + fun `blank input is no series`() { + assertEquals(null to null, parseSeriesNameField(" ")) + } +} From 111a0293ea30df90b08885ddfad58523bcc4f5d1 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:24:50 -0400 Subject: [PATCH 47/69] One slow /ping no longer empties a streaming user's shelf A single /ping over 5 seconds called the server unreachable, and losing the server switches the Library to Downloaded only. On a busy Pi or a slow VPN that hid the whole shelf behind "Nothing downloaded" until the next ping a minute later. And any answer counted as reachable, so a reverse proxy returning 502 for a stopped Audiobookshelf showed Connected. Now a failed probe on a server that was reachable, with the network still up, waits 3 seconds and tries once more with a 10 second budget before calling it unreachable. A server already unreachable is not retried, so confirming a dead server still costs one short probe. A 5xx answer counts as down. While the network is up and the server is not answering, the periodic ping comes back after 15 seconds instead of 60. Co-Authored-By: Claude Opus 5.5 --- .../app/service/ConnectivityMonitor.kt | 92 ++++++++++-- .../app/service/ServerProbeRetryTest.kt | 135 ++++++++++++++++++ 2 files changed, 212 insertions(+), 15 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/ServerProbeRetryTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt index 0cdef195..d1fdae96 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt @@ -36,12 +36,11 @@ class ConnectivityMonitor @Inject constructor( private const val TAG = "ConnectivityMonitor" /** Minimum background duration (ms) before triggering recovery on foreground. */ private const val MIN_BACKGROUND_DURATION_MS = 5_000L - /** Reachability probe budget. Short so the app drops to offline quickly. */ - private const val PROBE_TIMEOUT_MS = 5_000L } // A clone of the app client with a short total-call timeout, used only for the // reachability probe so it fails fast instead of waiting out the 30s sync timeout. + // Each probe sets its own call budget on top (see probeServerWithRetry). private val probeClient: OkHttpClient by lazy { okHttpClient.newBuilder() .callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS) @@ -141,12 +140,11 @@ class ConnectivityMonitor @Inject constructor( * Prevents unbounded concurrent server pings during network flaps * (e.g., WiFi → cellular handoff firing onAvailable + onCapabilitiesChanged). */ - private fun launchReachabilityCheck() { + private fun launchReachabilityCheck(): Job = synchronized(reachabilityJobLock) { reachabilityJob?.cancel() - reachabilityJob = scope.launch { reachabilityCheckGate.run() } + scope.launch { reachabilityCheckGate.run() }.also { reachabilityJob = it } } - } fun requestReachabilityCheck() { launchReachabilityCheck() @@ -168,17 +166,19 @@ class ConnectivityMonitor @Inject constructor( // Initial state check checkCurrentConnectivity() - // Start periodic server ping (every 60 seconds) + // Start the periodic server ping: every 60 seconds, or 15 while the + // network is up and the server is not answering, so a server that + // comes back is noticed soon. pingJob?.cancel() pingJob = scope.launch { while (isActive) { - delay(60_000) + delay(nextPingDelayMs(isOnline = _isOnline.value, isServerReachable = _isServerReachable.value)) // Route through launchReachabilityCheck so the periodic ping // shares the single-flight cancellation with the callback- and // foreground-driven checks. Calling checkServerReachable() // directly let two checks race and the slower (stale) one win // the last write to _isServerReachable. - launchReachabilityCheck() + launchReachabilityCheck().join() } } } @@ -250,18 +250,28 @@ class ConnectivityMonitor @Inject constructor( updateConnectionStatus() return false } - val reachable = withContext(Dispatchers.IO) { + val pingUrl = "${serverUrl.trimEnd('/')}/ping" + val reachable = probeServerWithRetry( + wasReachable = _isServerReachable.value, + stillOnline = { _isOnline.value }, + probe = { budgetMs -> pingOnce(pingUrl, budgetMs) }, + ) + _isServerReachable.value = reachable + updateConnectionStatus() + return reachable + } + + private suspend fun pingOnce(pingUrl: String, budgetMs: Long): Boolean = + withContext(Dispatchers.IO) { try { - val request = Request.Builder().url("${serverUrl.trimEnd('/')}/ping").build() - probeClient.newCall(request).execute().use { true } + val request = Request.Builder().url(pingUrl).build() + val call = probeClient.newCall(request) + call.timeout().timeout(budgetMs, TimeUnit.MILLISECONDS) + call.execute().use { response -> pingStatusMeansReachable(response.code) } } catch (_: Exception) { false } } - _isServerReachable.value = reachable - updateConnectionStatus() - return reachable - } // ─── Sync State (updated by SyncManager) ───────────────────────────── @@ -343,6 +353,58 @@ internal fun computeConnectionStatus( else -> ConnectivityMonitor.ConnectionStatus.SERVER_UNREACHABLE } +/** First probe budget. Short so a gone server shows as unreachable quickly. */ +internal const val PROBE_TIMEOUT_MS = 5_000L + +/** Wait before the confirming probe, so a busy moment can pass. */ +internal const val PROBE_RETRY_DELAY_MS = 3_000L + +/** Budget for the confirming probe. Longer, for a busy Pi or a slow VPN. */ +internal const val PROBE_RETRY_TIMEOUT_MS = 10_000L + +/** The periodic ping while the server answers (or the network is down). */ +internal const val PING_INTERVAL_MS = 60_000L + +/** The periodic ping while the network is up and the server is not answering. */ +internal const val PING_RETRY_INTERVAL_MS = 15_000L + +/** + * Any answer from /ping below 500 means Audiobookshelf itself is up. A 5xx is + * a reverse proxy (or tunnel) answering for a server that is down, which used + * to read as Connected because any response counted. + */ +internal fun pingStatusMeansReachable(code: Int): Boolean = code in 100..499 + +/** + * One /ping, and when it fails while the server was reachable and the OS + * still reports a network, one more after [retryDelayMs] with a longer + * budget before the server is called unreachable. + * + * Calling it unreachable switches a streaming user's Library to Downloaded + * only, so one slow answer from a busy Pi or a VPN must not do that. A server + * already marked unreachable gets no retry: a failure there changes nothing, + * and a success flips it back on the first try. + */ +internal suspend fun probeServerWithRetry( + wasReachable: Boolean, + stillOnline: () -> Boolean, + probe: suspend (budgetMs: Long) -> Boolean, + sleep: suspend (Long) -> Unit = { delay(it) }, + firstBudgetMs: Long = PROBE_TIMEOUT_MS, + retryDelayMs: Long = PROBE_RETRY_DELAY_MS, + retryBudgetMs: Long = PROBE_RETRY_TIMEOUT_MS, +): Boolean { + if (probe(firstBudgetMs)) return true + if (!wasReachable || !stillOnline()) return false + sleep(retryDelayMs) + if (!stillOnline()) return false + return probe(retryBudgetMs) +} + +/** How long the periodic ping waits before its next probe. */ +internal fun nextPingDelayMs(isOnline: Boolean, isServerReachable: Boolean): Long = + if (isOnline && !isServerReachable) PING_RETRY_INTERVAL_MS else PING_INTERVAL_MS + internal class ReachabilityCheckGate( private val check: suspend () -> Boolean, ) { diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ServerProbeRetryTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ServerProbeRetryTest.kt new file mode 100644 index 00000000..67473f96 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/ServerProbeRetryTest.kt @@ -0,0 +1,135 @@ +package com.ninelivesaudio.app.service + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ServerProbeRetryTest { + + private class FakeProbe(vararg answers: Boolean) { + private val queue = ArrayDeque(answers.toList()) + val budgets = mutableListOf() + suspend fun probe(budgetMs: Long): Boolean { + budgets += budgetMs + return queue.removeFirst() + } + } + + @Test + fun `a 5xx from a proxy means the server is down`() { + assertFalse(pingStatusMeansReachable(502)) + assertFalse(pingStatusMeansReachable(503)) + assertFalse(pingStatusMeansReachable(500)) + } + + @Test + fun `any answer below 500 means the server is up`() { + assertTrue(pingStatusMeansReachable(200)) + assertTrue(pingStatusMeansReachable(204)) + assertTrue(pingStatusMeansReachable(401)) + assertTrue(pingStatusMeansReachable(404)) + } + + @Test + fun `one slow probe on a reachable server is retried and stays reachable`() = runBlocking { + val probe = FakeProbe(false, true) + val sleeps = mutableListOf() + + val reachable = probeServerWithRetry( + wasReachable = true, + stillOnline = { true }, + probe = probe::probe, + sleep = { sleeps += it }, + ) + + assertTrue(reachable) + assertEquals(listOf(PROBE_TIMEOUT_MS, PROBE_RETRY_TIMEOUT_MS), probe.budgets) + assertEquals(listOf(PROBE_RETRY_DELAY_MS), sleeps) + } + + @Test + fun `two failed probes call a reachable server unreachable`() = runBlocking { + val probe = FakeProbe(false, false) + + val reachable = probeServerWithRetry( + wasReachable = true, + stillOnline = { true }, + probe = probe::probe, + sleep = {}, + ) + + assertFalse(reachable) + assertEquals(2, probe.budgets.size) + } + + @Test + fun `a server already unreachable is not retried`() = runBlocking { + val probe = FakeProbe(false) + + val reachable = probeServerWithRetry( + wasReachable = false, + stillOnline = { true }, + probe = probe::probe, + sleep = { error("no retry wait for a server already unreachable") }, + ) + + assertFalse(reachable) + assertEquals(listOf(PROBE_TIMEOUT_MS), probe.budgets) + } + + @Test + fun `no retry once the network is gone`() = runBlocking { + val probe = FakeProbe(false) + + val reachable = probeServerWithRetry( + wasReachable = true, + stillOnline = { false }, + probe = probe::probe, + sleep = { error("no retry wait without a network") }, + ) + + assertFalse(reachable) + assertEquals(1, probe.budgets.size) + } + + @Test + fun `a network lost during the retry wait skips the second probe`() = runBlocking { + val probe = FakeProbe(false) + var online = true + + val reachable = probeServerWithRetry( + wasReachable = true, + stillOnline = { online }, + probe = probe::probe, + sleep = { online = false }, + ) + + assertFalse(reachable) + assertEquals(1, probe.budgets.size) + } + + @Test + fun `a first probe that answers needs no retry`() = runBlocking { + val probe = FakeProbe(true) + + assertTrue( + probeServerWithRetry( + wasReachable = true, + stillOnline = { true }, + probe = probe::probe, + sleep = { error("no retry after a good answer") }, + ), + ) + assertEquals(1, probe.budgets.size) + } + + @Test + fun `an unreachable server is pinged again in 15 seconds, otherwise every minute`() { + assertEquals(PING_RETRY_INTERVAL_MS, nextPingDelayMs(isOnline = true, isServerReachable = false)) + assertEquals(PING_INTERVAL_MS, nextPingDelayMs(isOnline = true, isServerReachable = true)) + assertEquals(PING_INTERVAL_MS, nextPingDelayMs(isOnline = false, isServerReachable = false)) + assertEquals(15_000L, PING_RETRY_INTERVAL_MS) + } +} From c2c959c4fe410fb509fe032fdf78d643c2329e73 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:27:08 -0400 Subject: [PATCH 48/69] The server ping stops while the app is in the background The app pinged /ping every minute for as long as the process lived, screen off included, even though syncing now only happens in the foreground. The periodic ping now follows the same process lifecycle flag as the sync timer and stops the moment the app leaves the foreground. Reachability is still checked when it matters. Every network change runs a probe, as before, and coming back to the foreground runs one too. Three things used to lean on the background ping and are covered another way: - Background playback reports progress exactly as before. Its pushes never waited on the ping, and a push that lands now marks the server reachable again, which also flushes the offline progress queue. - Android Auto probes the server before it refuses to stream a book on a status of offline or unreachable, the way restoring the last book does, so a car session never starts on a stale "unreachable". - The offline queue still flushes on every return to connected, and on every foreground check. Co-Authored-By: Claude Opus 5.5 --- .../com/ninelivesaudio/app/NineLivesApp.kt | 13 ++- .../app/service/ConnectivityMonitor.kt | 78 +++++++++++++++-- .../app/service/PlaybackManager.kt | 12 +++ .../ninelivesaudio/app/service/SyncManager.kt | 3 + .../app/service/ForegroundPingTest.kt | 87 +++++++++++++++++++ 5 files changed, 181 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/ForegroundPingTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt b/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt index d191d640..2f2384cc 100644 --- a/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt +++ b/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt @@ -131,11 +131,18 @@ class NineLivesApp : Application(), ImageLoaderFactory { // waits a moment past the last activity so a rotation is not a trip // to the background. Registered here, before settings load, so the // first foreground entry is never missed: the flag is state, and the - // timer reads it whenever SyncManager.start() gets to it. + // timer reads it whenever SyncManager.start() gets to it. The server + // ping follows the same flag, so nothing polls in the background. ProcessLifecycleOwner.get().lifecycle.addObserver( object : DefaultLifecycleObserver { - override fun onStart(owner: LifecycleOwner) = syncManager.setAppForeground(true) - override fun onStop(owner: LifecycleOwner) = syncManager.setAppForeground(false) + override fun onStart(owner: LifecycleOwner) { + syncManager.setAppForeground(true) + connectivityMonitor.setAppForeground(true) + } + override fun onStop(owner: LifecycleOwner) { + syncManager.setAppForeground(false) + connectivityMonitor.setAppForeground(false) + } } ) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt index d1fdae96..a78b8efc 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.OkHttpClient @@ -62,6 +63,10 @@ class ConnectivityMonitor @Inject constructor( // Track when the app went to background for debouncing foreground recovery @Volatile private var backgroundedAt: Long = 0L + // Set from the process lifecycle (NineLivesApp). The periodic ping only + // runs while this is true. + private val appInForeground = MutableStateFlow(false) + // Emitted when the app returns from a meaningful background period private val _appResumedFromBackground = MutableSharedFlow(extraBufferCapacity = 1) val appResumedFromBackground: SharedFlow = _appResumedFromBackground.asSharedFlow() @@ -166,20 +171,25 @@ class ConnectivityMonitor @Inject constructor( // Initial state check checkCurrentConnectivity() - // Start the periodic server ping: every 60 seconds, or 15 while the - // network is up and the server is not answering, so a server that - // comes back is noticed soon. + // Start the periodic server ping, foreground only: every 60 seconds, + // or 15 while the network is up and the server is not answering, so a + // server that comes back is noticed soon. collectLatest cancels the + // loop the moment the app leaves the foreground. In the background + // the network callbacks still re-check on every network change, and + // the playback progress push marks the server reachable when it lands + // (see reportServerAnswered), so nothing needs a timer there. pingJob?.cancel() pingJob = scope.launch { - while (isActive) { - delay(nextPingDelayMs(isOnline = _isOnline.value, isServerReachable = _isServerReachable.value)) + runForegroundPing( + appInForeground = appInForeground, + nextDelayMs = { nextPingDelayMs(isOnline = _isOnline.value, isServerReachable = _isServerReachable.value) }, // Route through launchReachabilityCheck so the periodic ping // shares the single-flight cancellation with the callback- and // foreground-driven checks. Calling checkServerReachable() // directly let two checks race and the slower (stale) one win // the last write to _isServerReachable. - launchReachabilityCheck().join() - } + check = { launchReachabilityCheck().join() }, + ) } } @@ -273,6 +283,20 @@ class ConnectivityMonitor @Inject constructor( } } + /** + * A real request to the server just succeeded (a playback progress push). + * That proves reachability better than a /ping, and with no ping in the + * background it is how a server marked unreachable comes back while the + * app is not visible, which in turn flushes the offline progress queue + * and lets Android Auto stream again. A failed request proves nothing, + * so there is no counterpart: only a probe marks the server unreachable. + */ + fun reportServerAnswered() { + if (!serverAnswerMarksReachable(_isOnline.value, _isServerReachable.value)) return + _isServerReachable.value = true + updateConnectionStatus() + } + // ─── Sync State (updated by SyncManager) ───────────────────────────── fun setSyncing(syncing: Boolean) { @@ -282,6 +306,15 @@ class ConnectivityMonitor @Inject constructor( // ─── App Lifecycle (foreground / background) ───────────────────────── + /** + * The app came to the foreground (true) or left it (false), from the + * process lifecycle so it covers every activity together. The periodic + * ping runs only in the foreground. + */ + fun setAppForeground(inForeground: Boolean) { + appInForeground.value = inForeground + } + /** * Called when the app moves to the background (Activity.onStop). * Records the timestamp for debouncing foreground recovery. @@ -297,7 +330,8 @@ class ConnectivityMonitor @Inject constructor( * pool are often dead but not yet detected — making all API calls fail * until the pool cycles. Fix: evict idle connections immediately, then * force a server reachability check so the rest of the app knows the - * connection state within seconds instead of waiting up to 60s. + * connection state within seconds. Nothing pings while the app is in the + * background, so this check is what refreshes a stale state. */ fun onAppForegrounded() { // Never backgrounded yet (first foreground after cold start): there are @@ -318,7 +352,7 @@ class ConnectivityMonitor @Inject constructor( Log.w(TAG, "onAppForegrounded: evictAll failed: ${e.message}") } - // Force immediate server check (don't wait for the 60s timer) + // Force immediate server check (the periodic ping was off in the background) launchReachabilityCheck() // Notify observers (PlaybackManager) that we're back from background @@ -401,6 +435,32 @@ internal suspend fun probeServerWithRetry( return probe(retryBudgetMs) } +/** + * The periodic ping loop. It runs only while [appInForeground] is true and + * stops the moment it turns false, so nothing pings in the background. + */ +internal suspend fun runForegroundPing( + appInForeground: StateFlow, + nextDelayMs: () -> Long, + sleep: suspend (Long) -> Unit = { delay(it) }, + check: suspend () -> Unit, +) { + appInForeground.collectLatest { inForeground -> + if (!inForeground) return@collectLatest + while (true) { + sleep(nextDelayMs()) + check() + } + } +} + +/** + * A successful server request marks the server reachable only while the OS + * reports a network, and only when it is not already marked reachable. + */ +internal fun serverAnswerMarksReachable(isOnline: Boolean, isServerReachable: Boolean): Boolean = + isOnline && !isServerReachable + /** How long the periodic ping waits before its next probe. */ internal fun nextPingDelayMs(isOnline: Boolean, isServerReachable: Boolean): Long = if (isOnline && !isServerReachable) PING_RETRY_INTERVAL_MS else PING_INTERVAL_MS diff --git a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt index a461c06e..4df7001e 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt @@ -866,6 +866,18 @@ class PlaybackManager @Inject constructor( ) ) return@withNewLoadRequest false + // Nothing pings while the app is in the background, so a car + // session can start on a stale "unreachable". Probe once before + // the load refuses a streamed book on it, the way restore does. + if ( + shouldProbeServerBeforeRestore( + book = book, + connectionStatus = connectivityMonitor.connectionStatus.value, + ) + ) { + connectivityMonitor.checkServerReachable() + } + withContext(Dispatchers.Main) { loadAudioBookOwned(loadRequest, book, skipServiceStart = true) } diff --git a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt index 7ac20e4c..33d77ae0 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt @@ -598,6 +598,9 @@ class SyncManager @Inject constructor( if (pushed) { playbackThrottleOwner.recordSuccess(itemId, safeCurrentTime, now) + // Nothing pings in the background, so a push that lands is what + // tells a background session the server is back. + connectivityMonitor.reportServerAnswered() } } diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ForegroundPingTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundPingTest.kt new file mode 100644 index 00000000..f833107a --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundPingTest.kt @@ -0,0 +1,87 @@ +package com.ninelivesaudio.app.service + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ForegroundPingTest { + + private suspend fun settle() = repeat(10) { yield() } + + @Test + fun `the periodic ping runs only while the app is in the foreground`() = runBlocking { + val foreground = MutableStateFlow(false) + // Rendezvous: a tick is taken only when the loop is waiting out a delay. + val ticks = Channel() + var checks = 0 + val loop = launch(start = CoroutineStart.UNDISPATCHED) { + runForegroundPing( + appInForeground = foreground, + nextDelayMs = { PING_INTERVAL_MS }, + sleep = { ticks.receive() }, + check = { checks += 1 }, + ) + } + + settle() + assertFalse("no ping waits in the background", ticks.trySend(Unit).isSuccess) + assertEquals(0, checks) + + foreground.value = true + settle() + assertTrue(ticks.trySend(Unit).isSuccess) + settle() + assertEquals(1, checks) + assertTrue(ticks.trySend(Unit).isSuccess) + settle() + assertEquals(2, checks) + + foreground.value = false + settle() + assertFalse("leaving the foreground stops the ping", ticks.trySend(Unit).isSuccess) + assertEquals(2, checks) + + foreground.value = true + settle() + assertTrue("coming back starts it again", ticks.trySend(Unit).isSuccess) + settle() + assertEquals(3, checks) + + loop.cancel() + } + + @Test + fun `each wait uses the delay chosen at that moment`() = runBlocking { + val foreground = MutableStateFlow(true) + val ticks = Channel() + val waits = mutableListOf() + var reachable = true + val loop = launch(start = CoroutineStart.UNDISPATCHED) { + runForegroundPing( + appInForeground = foreground, + nextDelayMs = { nextPingDelayMs(isOnline = true, isServerReachable = reachable) }, + sleep = { waits += it; ticks.receive() }, + check = { reachable = false }, + ) + } + settle() + ticks.send(Unit) + settle() + assertEquals(listOf(PING_INTERVAL_MS, PING_RETRY_INTERVAL_MS), waits) + loop.cancel() + } + + @Test + fun `a successful request marks the server reachable only with a network`() { + assertTrue(serverAnswerMarksReachable(isOnline = true, isServerReachable = false)) + assertFalse(serverAnswerMarksReachable(isOnline = false, isServerReachable = false)) + assertFalse(serverAnswerMarksReachable(isOnline = true, isServerReachable = true)) + } +} From 5ebde228d04c7926b9baf539075e570f25335e03 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:32:17 -0400 Subject: [PATCH 49/69] Playback saves its place every 10 seconds instead of twice a second Every 500 ms poll during playback saved the position row, added a row to the offline progress queue, and rewrote the whole book row (description and all) with a REPLACE. That is battery and database churn for every hour of listening, screen off included, and each write woke every screen that watches the books table. The poll now saves locally every 10 seconds, and at once on a seek (including the notification scrubber and Android Auto), a chapter change, the first moment of a play, the book finishing, the app leaving the foreground, and the service tearing down with a book still playing. Pause, stop, and switching books already saved through their own paths and still do. A seek while paused is saved now too, which it never was. A crash loses at most about 10 seconds, which the resume rewind covers. The book row gets a targeted update of its three progress columns instead of a full rewrite. Server pushes keep their 30 second throttle and still go out the moment they are due. After a failed push the retry waits for the next 10 second save, so a server refusing connections no longer means a save and a failed request every 500 ms. Co-Authored-By: Claude Opus 5.5 --- .../com/ninelivesaudio/app/NineLivesApp.kt | 2 + .../app/service/PlaybackManager.kt | 123 ++++++++++++ .../ninelivesaudio/app/service/SyncManager.kt | 138 +++++++++++-- .../service/LocalPositionWriteCadenceTest.kt | 186 ++++++++++++++++++ .../app/service/PolledProgressSaveTest.kt | 49 +++++ 5 files changed, 477 insertions(+), 21 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/LocalPositionWriteCadenceTest.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/PolledProgressSaveTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt b/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt index 2f2384cc..3dd99b25 100644 --- a/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt +++ b/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt @@ -142,6 +142,8 @@ class NineLivesApp : Application(), ImageLoaderFactory { override fun onStop(owner: LifecycleOwner) { syncManager.setAppForeground(false) connectivityMonitor.setAppForeground(false) + // A playing book saves its position now, not 10 seconds on. + playbackManager.onAppBackgrounded() } } ) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt index 4df7001e..5b3f3142 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt @@ -64,6 +64,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import java.io.File import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration @@ -238,8 +239,22 @@ internal data class PolledProgressReport( val bookId: String, val currentTime: Double, val duration: Double, + /** + * The immediate-save request count when this sample was taken. Every later + * sample carries a count at least as high, so the conflated channel can + * drop a sample without losing the request it carried. + */ + val saveRequest: Long = 0L, ) +/** + * Whether a polled sample asks for an immediate local save: it is the first + * sample the reporting worker has delivered this play (null), or an + * immediate save was requested since the last sample it delivered. + */ +internal fun polledReportForcesSave(saveRequest: Long, lastDeliveredSaveRequest: Long?): Boolean = + lastDeliveredSaveRequest == null || saveRequest != lastDeliveredSaveRequest + private class StaleProgressWriteException : Exception() internal fun newPolledProgressReportChannel(): Channel = @@ -935,6 +950,10 @@ class PlaybackManager @Inject constructor( private var positionPollingJob: Job? = null private var progressReportingJob: Job? = null private var progressReportingChannel: Channel? = null + // Bumped by anything that should save the position now instead of on the + // 10 second cadence: a seek, a chapter change, the app leaving the + // foreground. Read by the poll with each sample. + private val immediateSaveRequests = AtomicLong(0L) private var sessionSyncJob: Job? = null private val playbackProgressOwner = PlaybackProgressOwner() private val pendingTerminalOwner = PendingTerminalOwner() @@ -2515,6 +2534,22 @@ class PlaybackManager @Inject constructor( } } + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int, + ) { + // Covers every seek, including the notification scrubber and + // Android Auto, which reach the player without passing seekTo(). + // A load's own seek lands while LOADING, so it only bumps the + // request. SEEK_ADJUSTMENT is left out on purpose: the player can + // raise it on its own after a paused restore, and saving then + // would stamp an old position as new and queue it for the server. + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + onSeekApplied() + } + } + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { val idx = exoPlayer?.currentMediaItemIndex ?: 0 val reasonStr = when (reason) { @@ -2543,6 +2578,9 @@ class PlaybackManager @Inject constructor( val reportChannel = newPolledProgressReportChannel() progressReportingChannel = reportChannel progressReportingJob = scope.launch { + // Null until the first sample of this play lands, so a play (and the + // resume rewind it may apply) is saved at once. + var lastDeliveredSaveRequest: Long? = null for (report in reportChannel) { try { playbackProgressOwner.report(report.bookId) { @@ -2551,8 +2589,12 @@ class PlaybackManager @Inject constructor( currentTime = report.currentTime, duration = report.duration, isFinished = false, + force = polledReportForcesSave(report.saveRequest, lastDeliveredSaveRequest), ) } + // Only after the save returned, so a failed forced save is + // forced again on the next sample. + lastDeliveredSaveRequest = report.saveRequest } catch (cancellation: CancellationException) { throw cancellation } catch (_: Exception) {} @@ -2566,6 +2608,10 @@ class PlaybackManager @Inject constructor( _position.value = pos chapterPlayer?.absolutePositionMs = pos.inWholeMilliseconds updateCurrentChapter(pos) + // Read after the chapter check so a chapter change in this tick + // rides on this sample. Seeks run on this same main thread, so + // no seek lands between the position read and this one. + val saveRequest = immediateSaveRequests.get() // Keep the 500 ms playback clock independent from slow server I/O. // The single reporting worker stays sequential while this conflated @@ -2579,6 +2625,7 @@ class PlaybackManager @Inject constructor( bookId = reportBook.id, currentTime = pos.toDouble(kotlin.time.DurationUnit.SECONDS), duration = dur.toDouble(kotlin.time.DurationUnit.SECONDS), + saveRequest = saveRequest, ), ) } @@ -2586,6 +2633,80 @@ class PlaybackManager @Inject constructor( } } + /** Save the position on the poll's next sample instead of the 10 second cadence. */ + private fun requestImmediateProgressSave() { + immediateSaveRequests.incrementAndGet() + } + + /** + * The app left the foreground. A playing book saves its position on the + * next poll sample, so a process killed in the background loses nothing + * past that moment. A paused book already saved on pause. + */ + fun onAppBackgrounded() { + requestImmediateProgressSave() + } + + /** + * A seek landed, from the app, the notification, Android Auto, or a + * chapter jump. While playing, the next poll sample saves it. While paused + * nothing polls, so save it now through the same per-book owner the poll + * uses. The server push keeps its usual throttle. + */ + private fun onSeekApplied() { + requestImmediateProgressSave() + if (positionPollingJob?.isActive == true) return + if (_playbackState.value != PlaybackState.PAUSED) return + val book = _currentBook.value ?: return + val player = exoPlayer ?: return + if (player.mediaItemCount == 0) return + val position = getCurrentPosition() + val duration = _duration.value + scope.launch { + try { + playbackProgressOwner.report(book.id) { + syncManager.reportPlaybackPosition( + itemId = book.id, + currentTime = position.toDouble(kotlin.time.DurationUnit.SECONDS), + duration = duration.toDouble(kotlin.time.DurationUnit.SECONDS), + isFinished = false, + force = true, + ) + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) {} + } + } + + /** + * The service is going away with a book still playing. Save where it is + * before the player is released. Detached from the scope's cancellation + * so the write finishes during teardown. + */ + private fun saveProgressBeforeTeardown() { + if (positionPollingJob?.isActive != true) return + val book = _currentBook.value ?: return + if (exoPlayer == null) return + val position = getCurrentPosition() + val duration = _duration.value + scope.launch(NonCancellable) { + try { + playbackProgressOwner.report(book.id) { + syncManager.reportPlaybackPosition( + itemId = book.id, + currentTime = position.toDouble(kotlin.time.DurationUnit.SECONDS), + duration = duration.toDouble(kotlin.time.DurationUnit.SECONDS), + isFinished = false, + force = true, + ) + } + } catch (e: Exception) { + Log.e(TAG, "Teardown progress save failed", e) + } + } + } + private fun stopPositionPolling() { positionPollingJob?.cancel() positionPollingJob = null @@ -2614,6 +2735,7 @@ class PlaybackManager @Inject constructor( } if (newIndex != _currentChapterIndex.value) { + requestImmediateProgressSave() _currentChapterIndex.value = newIndex val chapter = if (newIndex >= 0) cachedChapters[newIndex] else null _currentChapter.value = chapter @@ -3138,6 +3260,7 @@ class PlaybackManager @Inject constructor( /** Full teardown: release player, session, and all resources. Called on service destroy. */ fun releaseAll() { Log.d(TAG, "releaseAll: full teardown starting (sessionInit=$sessionInitialized)") + saveProgressBeforeTeardown() stopPositionPolling() stopSessionSync() val session = mediaSession diff --git a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt index 33d77ae0..8fff6311 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt @@ -85,6 +85,7 @@ class SyncManager @Inject constructor( private val appInForeground = MutableStateFlow(false) private val playbackThrottleOwner = PlaybackThrottleOwner() + private val localPositionCadence = LocalPositionWriteCadence() // ─── Events ────────────────────────────────────────────────────────────── @@ -530,14 +531,22 @@ class SyncManager @Inject constructor( } /** - * Report playback position with throttling. - * Always saves locally. Only pushes to server if throttle conditions are met. + * Report a playback position sampled by the player's 500 ms poll. + * + * Saves locally at most every [LOCAL_POSITION_WRITE_INTERVAL_MS], or at + * once when [force] is set (a seek, a chapter change, the first sample of + * a play, the app leaving the foreground, teardown), when the book is + * finished, or when the server push is due. Pause and stop save through + * their own terminal paths. A crash loses at most about 10 seconds, which + * the resume rewind covers. The server push keeps its own throttle + * ([shouldPushPlaybackPosition]) and only goes out with a local save. */ suspend fun reportPlaybackPosition( itemId: String, currentTime: Double, duration: Double, isFinished: Boolean, + force: Boolean = false, ) { val safeCurrentTime = currentTime.coerceAtLeast(0.0) val safeDuration = duration.coerceAtLeast(0.0) @@ -554,25 +563,34 @@ class SyncManager @Inject constructor( now = now, ) + val nowMonotonic = monotonicNowMs() + val cadence = localPositionCadence.snapshot(itemId) + val pushToServer = shouldSync && connectivityMonitor.isOnline.value + if ( + !shouldWriteLocalPosition( + cadence = cadence, + nowMs = nowMonotonic, + force = force, + isFinished = computedFinished, + pushToServer = pushToServer, + ) + ) return + + // Only the progress columns. The old full-row REPLACE rewrote the + // book's description and blobs twice a second. val updateShelf: suspend () -> Unit = { - readAndWriteShelfProgressIfCurrent( - isCurrent = { true }, - read = { audioBookDao.getById(itemId) }, - write = { book -> - if (book != null) { - audioBookDao.upsert( - book.copy( - currentTimeSeconds = safeCurrentTime, - progress = shelfProgress( - currentTime = safeCurrentTime, - duration = safeDuration, - isFinished = computedFinished, - existingProgress = book.progress, - ), - isFinished = if (computedFinished) 1 else 0, - ), - ) - } + writeShelfProgressColumns( + currentTime = safeCurrentTime, + duration = safeDuration, + isFinished = computedFinished, + readExistingProgress = { audioBookDao.getById(itemId)?.progress ?: 0.0 }, + update = { time, progress, finished -> + audioBookDao.updateProgress( + id = itemId, + currentTimeSeconds = time, + progress = progress, + isFinished = finished, + ) }, ) } @@ -591,10 +609,15 @@ class SyncManager @Inject constructor( currentTime = safeCurrentTime, isFinished = computedFinished, duration = safeDuration, - pushToServer = shouldSync && connectivityMonitor.isOnline.value, + pushToServer = pushToServer, onPersisted = updateShelf, ) } + localPositionCadence.recordWrite( + itemId = itemId, + atMs = nowMonotonic, + pushFailed = pushToServer && !pushed, + ) if (pushed) { playbackThrottleOwner.recordSuccess(itemId, safeCurrentTime, now) @@ -940,6 +963,79 @@ internal suspend fun runSyncAttempt( } } +/** How often the 500 ms playback poll saves the position locally. */ +internal const val LOCAL_POSITION_WRITE_INTERVAL_MS = 10_000L + +internal data class LocalPositionCadenceSnapshot( + /** Monotonic time of the last local save, null before the first one. */ + val lastWriteAtMs: Long? = null, + /** The server push that went out with the last save failed. */ + val lastPushFailed: Boolean = false, +) + +internal class LocalPositionWriteCadence { + private val lock = Any() + private val states = mutableMapOf() + + fun snapshot(itemId: String): LocalPositionCadenceSnapshot = synchronized(lock) { + states[itemId] ?: LocalPositionCadenceSnapshot() + } + + fun recordWrite(itemId: String, atMs: Long, pushFailed: Boolean) { + synchronized(lock) { + states[itemId] = LocalPositionCadenceSnapshot(atMs, pushFailed) + } + } +} + +/** + * Whether a polled position is saved locally now. + * + * Saves on [force], a finished book, a due server push, the first sample, or + * once [LOCAL_POSITION_WRITE_INTERVAL_MS] has passed. A due push skips the + * wait only when the previous push landed: the push throttle stays due + * until a push succeeds, so a server refusing connections would otherwise + * bring back a save (and a failed request) every 500 ms. After a failure + * the retry rides the 10 second save. + */ +internal fun shouldWriteLocalPosition( + cadence: LocalPositionCadenceSnapshot, + nowMs: Long, + force: Boolean, + isFinished: Boolean, + pushToServer: Boolean, +): Boolean { + if (force || isFinished) return true + val lastWriteAt = cadence.lastWriteAtMs ?: return true + if (pushToServer && !cadence.lastPushFailed) return true + return nowMs - lastWriteAt >= LOCAL_POSITION_WRITE_INTERVAL_MS || nowMs < lastWriteAt +} + +/** + * Write a playback position into the shelf row's progress columns only. + * Reads the stored progress only when the duration is unknown and the old + * value has to stand. + */ +internal suspend fun writeShelfProgressColumns( + currentTime: Double, + duration: Double, + isFinished: Boolean, + readExistingProgress: suspend () -> Double, + update: suspend (currentTime: Double, progress: Double, isFinished: Int) -> Unit, +) { + val existingProgress = if (!isFinished && duration <= 0.0) readExistingProgress() else 0.0 + update( + currentTime, + shelfProgress( + currentTime = currentTime, + duration = duration, + isFinished = isFinished, + existingProgress = existingProgress, + ), + if (isFinished) 1 else 0, + ) +} + internal data class PlaybackThrottleSnapshot( val lastSyncedTime: Double = 0.0, val lastSyncTimestamp: Long = 0L, diff --git a/app/src/test/java/com/ninelivesaudio/app/service/LocalPositionWriteCadenceTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/LocalPositionWriteCadenceTest.kt new file mode 100644 index 00000000..e7822cb4 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/LocalPositionWriteCadenceTest.kt @@ -0,0 +1,186 @@ +package com.ninelivesaudio.app.service + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LocalPositionWriteCadenceTest { + + /** Feeds 500 ms poll samples through the real decision and cadence store. */ + private class FakePoll( + val cadence: LocalPositionWriteCadence = LocalPositionWriteCadence(), + var nowMs: Long = 1_000_000L, + ) { + val writesAt = mutableListOf() + + fun sample( + force: Boolean = false, + isFinished: Boolean = false, + pushToServer: Boolean = false, + pushSucceeds: Boolean = true, + ) { + val write = shouldWriteLocalPosition( + cadence = cadence.snapshot("book"), + nowMs = nowMs, + force = force, + isFinished = isFinished, + pushToServer = pushToServer, + ) + if (write) { + writesAt += nowMs + cadence.recordWrite("book", nowMs, pushFailed = pushToServer && !pushSucceeds) + } + nowMs += 500L + } + } + + @Test + fun `a minute of playback saves every 10 seconds instead of every 500 ms`() { + val poll = FakePoll() + val start = poll.nowMs + repeat(120) { poll.sample() } + + assertEquals( + listOf(0L, 10_000L, 20_000L, 30_000L, 40_000L, 50_000L), + poll.writesAt.map { it - start }, + ) + } + + @Test + fun `the first sample of a book is saved`() { + assertTrue( + shouldWriteLocalPosition( + cadence = LocalPositionCadenceSnapshot(), + nowMs = 5L, + force = false, + isFinished = false, + pushToServer = false, + ), + ) + } + + @Test + fun `a forced sample is saved at once, and the 10 seconds restart from it`() { + val poll = FakePoll() + val start = poll.nowMs + poll.sample() // t=0, saved + repeat(5) { poll.sample() } // to t=2.5 s + poll.sample(force = true) // t=3 s, a seek + repeat(30) { poll.sample() } + + assertEquals(listOf(0L, 3_000L, 13_000L), poll.writesAt.map { it - start }) + } + + @Test + fun `a finished book is saved at once`() { + assertTrue( + shouldWriteLocalPosition( + cadence = LocalPositionCadenceSnapshot(lastWriteAtMs = 1_000L), + nowMs = 1_500L, + force = false, + isFinished = true, + pushToServer = false, + ), + ) + } + + @Test + fun `a due server push goes out with a save at once`() { + assertTrue( + shouldWriteLocalPosition( + cadence = LocalPositionCadenceSnapshot(lastWriteAtMs = 1_000L, lastPushFailed = false), + nowMs = 1_500L, + force = false, + isFinished = false, + pushToServer = true, + ), + ) + } + + @Test + fun `after a failed push the retry waits for the 10 second save`() { + val poll = FakePoll() + val start = poll.nowMs + // The push throttle stays due until a push lands, so every sample + // asks to push. A server refusing connections fails each one. + repeat(40) { poll.sample(pushToServer = true, pushSucceeds = false) } + + assertEquals(listOf(0L, 10_000L), poll.writesAt.map { it - start }) + } + + @Test + fun `a clock that went backwards saves instead of waiting`() { + assertTrue( + shouldWriteLocalPosition( + cadence = LocalPositionCadenceSnapshot(lastWriteAtMs = 50_000L), + nowMs = 10_000L, + force = false, + isFinished = false, + pushToServer = false, + ), + ) + } + + @Test + fun `nothing is saved between cadence points`() { + assertFalse( + shouldWriteLocalPosition( + cadence = LocalPositionCadenceSnapshot(lastWriteAtMs = 1_000L), + nowMs = 1_000L + LOCAL_POSITION_WRITE_INTERVAL_MS - 1, + force = false, + isFinished = false, + pushToServer = false, + ), + ) + assertEquals(10_000L, LOCAL_POSITION_WRITE_INTERVAL_MS) + } + + @Test + fun `the shelf write touches only the progress columns and skips the read when the duration is known`() = runBlocking { + var reads = 0 + val updates = mutableListOf>() + + writeShelfProgressColumns( + currentTime = 300.0, + duration = 1_200.0, + isFinished = false, + readExistingProgress = { reads += 1; 0.9 }, + update = { time, progress, finished -> updates += Triple(time, progress, finished) }, + ) + + assertEquals(0, reads) + assertEquals(listOf(Triple(300.0, 0.25, 0)), updates) + } + + @Test + fun `an unknown duration keeps the stored progress`() = runBlocking { + val updates = mutableListOf>() + + writeShelfProgressColumns( + currentTime = 300.0, + duration = 0.0, + isFinished = false, + readExistingProgress = { 0.4 }, + update = { time, progress, finished -> updates += Triple(time, progress, finished) }, + ) + + assertEquals(listOf(Triple(300.0, 0.4, 0)), updates) + } + + @Test + fun `a finished book writes full progress and the finished flag`() = runBlocking { + val updates = mutableListOf>() + + writeShelfProgressColumns( + currentTime = 1_199.5, + duration = 1_200.0, + isFinished = true, + readExistingProgress = { error("no read for a finished book") }, + update = { time, progress, finished -> updates += Triple(time, progress, finished) }, + ) + + assertEquals(listOf(Triple(1_199.5, 1.0, 1)), updates) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/PolledProgressSaveTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/PolledProgressSaveTest.kt new file mode 100644 index 00000000..e493afaa --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/PolledProgressSaveTest.kt @@ -0,0 +1,49 @@ +package com.ninelivesaudio.app.service + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PolledProgressSaveTest { + + @Test + fun `the first sample of a play is saved at once`() { + assertTrue(polledReportForcesSave(saveRequest = 0L, lastDeliveredSaveRequest = null)) + assertTrue(polledReportForcesSave(saveRequest = 7L, lastDeliveredSaveRequest = null)) + } + + @Test + fun `a sample with no new save request rides the cadence`() { + assertFalse(polledReportForcesSave(saveRequest = 3L, lastDeliveredSaveRequest = 3L)) + } + + @Test + fun `a sample taken after a seek or chapter change is saved at once`() { + assertTrue(polledReportForcesSave(saveRequest = 4L, lastDeliveredSaveRequest = 3L)) + } + + @Test + fun `a dropped sample does not lose the save request it carried`() = runBlocking { + // The poll channel keeps only the newest unsent sample. A seek bumps the + // request, its sample is replaced before the worker reads it, and the + // replacing sample must still force the save. + val channel = newPolledProgressReportChannel() + channel.send(PolledProgressReport("book", currentTime = 10.0, duration = 100.0, saveRequest = 0L)) + val first = channel.receive() + var lastDelivered: Long? = null + assertTrue(polledReportForcesSave(first.saveRequest, lastDelivered)) + lastDelivered = first.saveRequest + + // Seek: request 1. Its sample is overwritten by the next tick's. + channel.trySend(PolledProgressReport("book", currentTime = 60.0, duration = 100.0, saveRequest = 1L)) + channel.trySend(PolledProgressReport("book", currentTime = 60.5, duration = 100.0, saveRequest = 1L)) + val delivered = channel.receive() + + assertEquals(60.5, delivered.currentTime, 0.0) + assertTrue(polledReportForcesSave(delivered.saveRequest, lastDelivered)) + lastDelivered = delivered.saveRequest + assertFalse(polledReportForcesSave(1L, lastDelivered)) + } +} From 6fde92819db990a03b7e0f45af77413b6505bcf9 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:29:54 -0400 Subject: [PATCH 50/69] A sync for the old account can no longer prune or save after the next one signs in A library sync checked that its account was still signed in and then wrote the cache as a separate step. A sync could pass the check, pause inside a database read or delete, and resume after another account had signed in and synced, then prune books only the new account can see. The page saves and the "added since" save had the same gap. The check and the write now run as one step under the auth token lock, the lock every sign-in, sign-out, and token swap already takes. A sign-in waits for a save or prune that already passed its check, and a write that comes after a sign-in sees it and is refused. The prune reads the cached ids and deletes inside that one step. This adds one small method to SettingsManager that runs a block while holding that lock. Co-Authored-By: Claude Opus 5.5 --- .../data/repository/AudioBookRepository.kt | 45 +++++++++-------- .../repository/LibrarySyncWatermarkStore.kt | 20 ++++++++ .../app/service/SettingsManager.kt | 20 ++++++++ .../repository/LibrarySyncSingleFlightTest.kt | 50 ++++++++++++++++++- 4 files changed, 114 insertions(+), 21 deletions(-) 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 e9993a07..6295d49a 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 @@ -320,9 +320,11 @@ class AudioBookRepository @Inject constructor( val merged = mergeForSave(books) // Added books are saved even when the counts disagree below: they // are real, and the full download that follows prunes, not this. - // Never into a cache another account has taken over meanwhile. + // Never into a cache another account has taken over meanwhile: + // the account check and the save are one step no sign-in splits. + val entities = withContext(Dispatchers.Default) { merged.map { it.toEntity() } } val saved = withContext(NonCancellable) { - isCurrent(identity).also { current -> if (current) saveMerged(merged) } + watermarkStore.runIfCurrent(identity) { audioBookDao.upsertAll(entities) } } if (!saved) return LibraryRefreshOutcome.CheckFailed(SYNC_ACCOUNT_CHANGED) _booksSaved.tryEmit(libraryId) @@ -391,7 +393,7 @@ class AudioBookRepository @Inject constructor( tally.add(books) _booksSaved.tryEmit(libraryId) }, - isCurrent = { isCurrent(identity) }, + writeIfCurrent = { write -> watermarkStore.runIfCurrent(identity, write) }, ) // Another account signed in mid-download. Nothing more was written, // and this account's watermark and failure count stay as they were. @@ -783,10 +785,12 @@ internal suspend fun pruneServerLibraryTo( * caller is cancelled in between. A Partial or Failed fetch keeps the pages * it saved and prunes nothing. * - * [isCurrent] says whether the account the pass runs for is still signed in. - * Each save and the prune ask it first, and once it says no the pass stops, - * writes nothing more, and fails with [SYNC_ACCOUNT_CHANGED]: the cache now - * belongs to someone else, whose own sync fills and prunes it. + * [writeIfCurrent] runs a write only while the account the pass runs for is + * still signed in, checking and writing as one step that no sign-in can + * split (see [LibrarySyncWatermarkStore.runIfCurrent]). Each save and the + * prune go through it, and once it refuses the pass stops, writes nothing + * more, and fails with [SYNC_ACCOUNT_CHANGED]: the cache now belongs to + * someone else, whose own sync fills and prunes it. */ internal suspend fun runLibraryItemSyncPass( libraryId: String, @@ -797,14 +801,14 @@ internal suspend fun runLibraryItemSyncPass( deleteByIds: suspend (libraryId: String, ids: List) -> Unit, deleteAllServerBooks: suspend (libraryId: String) -> Unit, onPageSaved: suspend (List) -> Unit = {}, - isCurrent: suspend () -> Boolean = { true }, + writeIfCurrent: suspend (write: suspend () -> Unit) -> Boolean = { write -> write(); true }, ): RemoteResult { val keptIds = HashSet() val result = try { fetchPages { page -> val merged = mergeItems(page) val saved = withContext(NonCancellable) { - isCurrent().also { current -> if (current && merged.isNotEmpty()) upsertAll(merged) } + writeIfCurrent { if (merged.isNotEmpty()) upsertAll(merged) } } // Thrown to stop the download: the rest is for an account that // is no longer signed in. @@ -817,19 +821,20 @@ internal suspend fun runLibraryItemSyncPass( } // A streaming fetch turns a throw in onPage into a short result, so // look again rather than trusting the catch above alone. - if (!isCurrent()) return RemoteResult.Failed(SYNC_ACCOUNT_CHANGED) + if (!writeIfCurrent {}) return RemoteResult.Failed(SYNC_ACCOUNT_CHANGED) if (result is RemoteResult.Ok) { + // The read of cached ids and every delete run inside one gated + // step, so a sign-in waits for the whole prune rather than slipping + // in between the read and a delete. val pruned = withContext(NonCancellable) { - isCurrent().also { current -> - if (current) { - pruneServerLibraryTo( - keptIds = keptIds, - libraryId = libraryId, - cachedNonDownloadedIds = cachedNonDownloadedIds, - deleteByIds = deleteByIds, - deleteAllServerBooks = deleteAllServerBooks, - ) - } + writeIfCurrent { + pruneServerLibraryTo( + keptIds = keptIds, + libraryId = libraryId, + cachedNonDownloadedIds = cachedNonDownloadedIds, + deleteByIds = deleteByIds, + deleteAllServerBooks = deleteAllServerBooks, + ) } } if (!pruned) return RemoteResult.Failed(SYNC_ACCOUNT_CHANGED) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncWatermarkStore.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncWatermarkStore.kt index 9b78a224..55565b43 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncWatermarkStore.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncWatermarkStore.kt @@ -39,6 +39,26 @@ class LibrarySyncWatermarkStore @Inject constructor( return LibrarySyncIdentity(currentAccount(), token?.hashCode() ?: 0) } + /** + * Runs [write] only if [identity] is still the signed-in one, and holds + * the auth token lock while it runs. Checking first and writing after + * left a gap: the sync could pass the check, suspend inside a DAO call, + * and resume after the next account signed in and synced, then prune + * that account's books. Under the lock a sign-in, sign-out, or token + * swap waits for the write, and a write that comes after one sees it. + * Returns whether [write] ran. [write] must not touch the settings + * document (put and remove take the same lock). + */ + internal suspend fun runIfCurrent(identity: LibrarySyncIdentity, write: suspend () -> Unit): Boolean = + settingsManager.withAuthTokenLocked { token -> + if (LibrarySyncIdentity(currentAccount(), token?.hashCode() ?: 0) != identity) { + false + } else { + write() + true + } + } + internal fun get(key: SyncAccountKey, libraryId: String): LibrarySyncWatermark? = settingsManager.currentSettings.librarySyncWatermarks.watermarkFor(key, libraryId) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/SettingsManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/SettingsManager.kt index abc5280e..96aa643d 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/SettingsManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/SettingsManager.kt @@ -393,6 +393,26 @@ class SettingsManager @Inject constructor( true } + /** + * Runs [block] with the stored auth token while holding [authTokenMutex], + * so no sign-in, sign-out, or token swap can land until it returns. A + * library sync checks its account and writes the cache inside one of + * these, which keeps a sync that outlives its sign-in from writing or + * pruning once the next account's token exists. [block] must not call + * anything here that takes the same lock (it is not reentrant). + */ + internal suspend fun withAuthTokenLocked(block: suspend (token: String?) -> T): T = + authTokenMutex.withLock { + val token = try { + withContext(Dispatchers.IO) { encryptedPrefs.getString(KEY_AUTH_TOKEN, null) } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + null + } + block(token) + } + suspend fun markChangelogVersionSeen(version: String) { if (currentSettings.lastSeenChangelogVersion == version) return updateSettings { settings -> diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncSingleFlightTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncSingleFlightTest.kt index 7efadaa5..c961095c 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncSingleFlightTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncSingleFlightTest.kt @@ -12,7 +12,9 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.yield import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -186,7 +188,7 @@ class LibrarySyncSingleFlightTest { cachedNonDownloadedIds = { cache.toList() }, deleteByIds = { _, ids -> cache.removeAll(ids) }, deleteAllServerBooks = { cache.clear() }, - isCurrent = { signedIn == "alice" }, + writeIfCurrent = { write -> if (signedIn == "alice") { write(); true } else false }, ) assertEquals(RemoteResult.Failed(SYNC_ACCOUNT_CHANGED), result) // Page one landed before the switch. Nothing after it, and bob's @@ -194,6 +196,52 @@ class LibrarySyncSingleFlightTest { assertEquals(listOf("bob-book", "alice-1"), cache) } + @Test + fun `a sign-in waits for a prune already past its account check`() = runBlocking { + // Alice's prune passes the account check, then suspends reading the + // cached ids. Bob signs in and his sync saves a book. When alice + // resumes, her prune must not delete it: the sign-in has to wait + // for the whole prune, not just for the check in front of it. + val authLock = Mutex() + var signedIn = "alice" + val cache = mutableListOf("shared", "alice-gone") + val pruneReading = CompletableDeferred() + val releasePrune = CompletableDeferred() + val gate: suspend (suspend () -> Unit) -> Boolean = { write -> + authLock.withLock { if (signedIn == "alice") { write(); true } else false } + } + val alice = async { + runLibraryItemSyncPass( + libraryId = "lib", + fetchPages = { onPage -> + onPage(listOf(AudioBook(id = "shared"))) + RemoteResult.Ok(1) + }, + mergeItems = { it }, + upsertAll = { books -> books.forEach { if (it.id !in cache) cache += it.id } }, + cachedNonDownloadedIds = { + pruneReading.complete(Unit) + releasePrune.await() + cache.toList() + }, + deleteByIds = { _, ids -> cache.removeAll(ids) }, + deleteAllServerBooks = { cache.clear() }, + writeIfCurrent = gate, + ) + } + pruneReading.await() + val bob = launch { + authLock.withLock { signedIn = "bob" } + cache += "bob-book" + } + // Give bob every chance to get in while alice is suspended. + withTimeoutOrNull(300) { bob.join() } + releasePrune.complete(Unit) + assertEquals(RemoteResult.Ok(1), alice.await()) + bob.join() + assertEquals(listOf("shared", "bob-book"), cache) + } + @Test fun `a caller that leaves does not cancel the sync others are waiting on`() = runBlocking { val scope = flightScope() From 7230609bd4379c78c4204eca5fee012b93dfe141 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:31:51 -0400 Subject: [PATCH 51/69] A book cached early inside the overlap window can no longer hide a removal The change check counts the books an "added since" fetch brings that the last good sync did not count, and calls the shelf current when that plus the old count matches the server. Inside the one minute overlap window it used the cache to decide: a cached book counted as old. A delayed scan can stamp a new book just before the stored newest one, and a progress pull can cache it before the check runs. With one more new book and one removal the counts still matched, so the removed book stayed on the shelf. A watermark now records which books inside its overlap window its count includes, and a book there is new exactly when it is not on that list, whatever the cache holds. Full downloads and incremental syncs both write the list. A watermark saved before the list existed cannot tell, so a cached book in its window sends the library to a full download once. The watermark model in AppSettings gains one optional field. Co-Authored-By: Claude Opus 5.5 --- .../data/repository/AudioBookRepository.kt | 8 +- .../app/data/repository/LibrarySyncPlanner.kt | 94 +++++++++++++++---- .../app/domain/model/AppSettings.kt | 5 + .../data/repository/LibrarySyncPlannerTest.kt | 47 +++++++++- 4 files changed, 131 insertions(+), 23 deletions(-) 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 6295d49a..3abe20ca 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 @@ -329,7 +329,7 @@ class AudioBookRepository @Inject constructor( if (!saved) return LibraryRefreshOutcome.CheckFailed(SYNC_ACCOUNT_CHANGED) _booksSaved.tryEmit(libraryId) } - if (!incrementalCountsAgree(watermark.itemCount, newBookCount, value.total)) { + if (newBookCount == null || !incrementalCountsAgree(watermark.itemCount, newBookCount, value.total)) { // The books just saved are not in the watermark's count, so it no // longer describes the cache. Forgetting it keeps every later // check on the full download until one completes. @@ -337,7 +337,11 @@ class AudioBookRepository @Inject constructor( return fallBackToFullLocked( libraryId, identity, - "server has ${value.total} books, expected ${watermark.itemCount} + $newBookCount new", + if (newBookCount == null) { + "cannot tell which books near the newest one are new" + } else { + "server has ${value.total} books, expected ${watermark.itemCount} + $newBookCount new" + }, isMetered, hasCachedRows, ) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt index f5d92322..d6b1212d 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt @@ -204,26 +204,49 @@ internal fun incrementalCountsAgree( ): Boolean = previousCount + newBookCount == serverTotal /** - * How many of the fetched books the last good sync's count left out. A book - * added after the stored newest one cannot be in that count, even when the - * cache already holds it: an earlier check whose counts disagreed saved it, - * or a progress pull, or a full download that stopped short. Counting by + * How many of the fetched books the last good sync's count left out, or null + * when that cannot be told and only a full download is safe. + * + * A book added after the stored newest one cannot be in that count, even when + * the cache already holds it: an earlier check whose counts disagreed saved + * it, or a progress pull, or a full download that stopped short. Counting by * cache presence alone let the retry after "one removed, one added" read - * 100 + 0 = 100 and call the shelf current with the removed book still on - * it. A book inside the overlap window counts only when the cache lacks it. + * 100 + 0 = 100 and call the shelf current with the removed book still on it. + * + * Inside the overlap window cache presence says nothing either. A delayed + * scan can stamp a new book just before the stored newest one, and a + * progress pull can cache it before the check runs. Counting it as old then + * hides a removal made alongside it. So the watermark records which books + * in the window its count includes ([LibrarySyncWatermark.overlapItemIds]), + * and a book there is new exactly when it is not on that list. A watermark + * saved before that list existed cannot tell, so a cached book in its window + * (other than the stored newest one) makes the answer null. + * + * Books below the window are on the fetch only because they shared its last + * page, and count only when the cache lacks them. */ internal fun newBooksSinceWatermark( watermark: LibrarySyncWatermark, fetched: List, alreadyCached: Set, -): Int { - val storedNewest = watermark.newestAddedAt - return fetched.distinctBy { it.id }.count { book -> - val added = book.addedAt - book.id !in alreadyCached || - storedNewest == null || - (added != null && added > storedNewest && book.id != watermark.newestItemId) +): Int? { + val storedNewest = watermark.newestAddedAt ?: return fetched.distinctBy { it.id }.size + val cutoff = incrementalCutoff(watermark) + val counted = watermark.overlapItemIds?.toHashSet() + var newCount = 0 + for (book in fetched.distinctBy { it.id }) { + val added = book.addedAt ?: return null + val isNew = when { + added < cutoff -> book.id !in alreadyCached + counted != null -> book.id !in counted && book.id != watermark.newestItemId + book.id == watermark.newestItemId -> false + added > storedNewest -> true + book.id in alreadyCached -> return null + else -> true + } + if (isNew) newCount++ } + return newCount } /** @@ -233,6 +256,8 @@ internal fun newBooksSinceWatermark( */ internal class FullSyncTally { private val seen = HashSet() + // Books close behind the newest so far, trimmed as the newest moves. + private val recent = ArrayList>() var count: Int = 0 private set var newestAddedAt: Long? = null @@ -249,12 +274,32 @@ internal class FullSyncTally { val time = book.addedAt if (time == null) { missingAddedAt = true - } else if (newestAddedAt == null || time > newestAddedAt!!) { - newestAddedAt = time - newestItemId = book.id + } else { + if (newestAddedAt == null || time > newestAddedAt!!) { + newestAddedAt = time + newestItemId = book.id + } + if (time >= newestAddedAt!! - INCREMENTAL_OVERLAP_MS) recent += book.id to time + if (recent.size > RECENT_TRIM_AT) trimRecent() } } } + + /** Every book this download counted whose added date falls inside the next check's overlap window. */ + val overlapItemIds: List + get() { + trimRecent() + return recent.map { it.first } + } + + private fun trimRecent() { + val newest = newestAddedAt ?: return + recent.removeAll { it.second < newest - INCREMENTAL_OVERLAP_MS } + } + + private companion object { + const val RECENT_TRIM_AT = 512 + } } /** @@ -283,6 +328,7 @@ internal fun watermarkAfterFullSync( newestAddedAt = tally.newestAddedAt, newestItemId = tally.newestItemId, itemCount = tally.count, + overlapItemIds = tally.overlapItemIds, lastFullSyncAtMs = nowMs, updatedAtMs = nowMs, ) @@ -321,7 +367,10 @@ internal fun isFullSyncBackedOff(failures: FullSyncFailures?, nowMs: Long): Bool /** * The watermark after an "added since" fetch whose counts agreed. The newest * book is the first one the server listed (it sorted newest first), and the - * last full download time carries over unchanged. + * last full download time carries over unchanged. The fetch reached back to + * the old window's edge, so it holds every book in the new window too, and + * the counts agreeing means all of them are in [serverTotal]: those become + * the new [LibrarySyncWatermark.overlapItemIds]. */ internal fun watermarkAfterIncremental( previous: LibrarySyncWatermark, @@ -333,10 +382,19 @@ internal fun watermarkAfterIncremental( val firstTime = first?.addedAt val advance = first != null && firstTime != null && (previous.newestAddedAt == null || firstTime >= previous.newestAddedAt) + val newestAddedAt = if (advance) firstTime else previous.newestAddedAt + val overlapItemIds = if (newestAddedAt == null) { + previous.overlapItemIds + } else { + fetched.filter { book -> book.addedAt?.let { it >= newestAddedAt - INCREMENTAL_OVERLAP_MS } == true } + .map { it.id } + .distinct() + } return previous.copy( - newestAddedAt = if (advance) firstTime else previous.newestAddedAt, + newestAddedAt = newestAddedAt, newestItemId = if (advance) first!!.id else previous.newestItemId, itemCount = serverTotal, + overlapItemIds = overlapItemIds, updatedAtMs = nowMs, ) } 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 26a9a6d7..76976dcf 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 @@ -107,6 +107,10 @@ data class LastSyncRecord( * many books it reported, and [lastFullSyncAtMs] is the wall clock time of * the last full download, which bounds how long a metadata edit can go * unseen (the server cannot be asked for edited books, only added ones). + * [overlapItemIds] lists the books [itemCount] includes whose added date is + * within a minute of [newestAddedAt], so the next check can tell a book + * stamped just before the newest one apart from one it already counted. + * Null on a watermark saved before the list existed. */ @Serializable data class LibrarySyncWatermark( @@ -116,6 +120,7 @@ data class LibrarySyncWatermark( val newestAddedAt: Long? = null, val newestItemId: String? = null, val itemCount: Int = 0, + val overlapItemIds: List? = null, val lastFullSyncAtMs: Long = 0L, val updatedAtMs: Long = 0L, ) diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt index cc0e4b35..ecb10df5 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt @@ -158,16 +158,57 @@ class LibrarySyncPlannerTest { val fetched = listOf(AudioBook(id = "li_new", addedAt = 9_000L), AudioBook(id = "li_newest", addedAt = 5_000L)) val newCount = newBooksSinceWatermark(watermark(), fetched, alreadyCached = setOf("li_new", "li_newest")) assertEquals(1, newCount) - assertFalse(incrementalCountsAgree(previousCount = 100, newBookCount = newCount, serverTotal = 100)) + assertFalse(incrementalCountsAgree(previousCount = 100, newBookCount = newCount!!, serverTotal = 100)) } @Test - fun `a book inside the overlap window counts as new only when the cache lacks it`() { + fun `a book inside the overlap window is new unless the watermark counted it`() { val overlap = listOf(AudioBook(id = "li_late", addedAt = 4_990L)) - assertEquals(0, newBooksSinceWatermark(watermark(), overlap, alreadyCached = setOf("li_late"))) + val counted = watermark().copy(overlapItemIds = listOf("li_late", "li_newest")) + val notCounted = watermark().copy(overlapItemIds = listOf("li_newest")) + // Cache presence no longer decides it, either way round. + assertEquals(0, newBooksSinceWatermark(counted, overlap, alreadyCached = setOf("li_late"))) + assertEquals(0, newBooksSinceWatermark(counted, overlap, alreadyCached = emptySet())) + assertEquals(1, newBooksSinceWatermark(notCounted, overlap, alreadyCached = setOf("li_late"))) + assertEquals(1, newBooksSinceWatermark(notCounted, overlap, alreadyCached = emptySet())) + // A watermark from before the list cannot tell a cached one apart. + assertNull(newBooksSinceWatermark(watermark(), overlap, alreadyCached = setOf("li_late"))) assertEquals(1, newBooksSinceWatermark(watermark(), overlap, alreadyCached = emptySet())) } + @Test + fun `an already cached book stamped just before the newest cannot hide a removal`() { + // Watermark: 100 books, newest li_newest at 5_000. A delayed scan adds + // li_early stamped at 4_990 and li_new at 9_000, and removes li_gone, + // so the server has 101. A progress pull cached li_early first. + val tally = FullSyncTally().apply { + add((1..98).map { AudioBook(id = "li_$it", addedAt = it.toLong() * -100_000L) }) + add(listOf(AudioBook(id = "li_gone", addedAt = 4_000L), AudioBook(id = "li_newest", addedAt = 5_000L))) + } + val stored = watermarkAfterFullSync(key, "lib", tally, RemoteResult.Ok(100), nowMs = 1L)!! + assertEquals(setOf("li_gone", "li_newest"), stored.overlapItemIds!!.toSet()) + val fetched = listOf( + AudioBook(id = "li_new", addedAt = 9_000L), + AudioBook(id = "li_newest", addedAt = 5_000L), + AudioBook(id = "li_early", addedAt = 4_990L), + AudioBook(id = "li_1", addedAt = -100_000L), + ) + val newCount = newBooksSinceWatermark(stored, fetched, alreadyCached = setOf("li_newest", "li_early", "li_1")) + assertEquals(2, newCount) + assertFalse(incrementalCountsAgree(stored.itemCount, newCount!!, serverTotal = 101)) + } + + @Test + fun `an incremental sync records the books in its new overlap window`() { + val fetched = listOf( + AudioBook(id = "li_new", addedAt = 70_000L), + AudioBook(id = "li_near", addedAt = 30_000L), + AudioBook(id = "li_newest", addedAt = 5_000L), + ) + val next = watermarkAfterIncremental(watermark(), fetched, serverTotal = 102, nowMs = 50L) + assertEquals(listOf("li_new", "li_near"), next.overlapItemIds) + } + @Test fun `an incremental sync moves the watermark to the server's newest book`() { val fetched = listOf(AudioBook(id = "li_new", addedAt = 9_000L), AudioBook(id = "li_newest", addedAt = 5_000L)) From 9828c975cad8ee2e447fa1d2271198013ef3476d Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:32:59 -0400 Subject: [PATCH 52/69] A refresh no longer erases a completion the progress pull imported at zero A book finished on the server with no saved place lands on the shelf finished, at position zero, with progress 1. The next pull to refresh lists it again, and the library list carries no listening progress, so its copy reads unfinished at zero. The merge kept local progress only when the local position was ahead, and zero is not ahead of zero, so the book dropped out of Completed and came back when hiding finished books. The merge now also keeps local progress when the local row is finished (or at progress 1) and the server copy is not. Marking a book unfinished again stays with the progress pull, which reads the server's own record. Co-Authored-By: Claude Opus 5.5 --- .../data/repository/AudioBookRepository.kt | 13 +++++++-- .../app/data/repository/SyncMergeTest.kt | 28 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) 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 3abe20ca..395c659e 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 @@ -976,10 +976,19 @@ internal fun mergeSyncedBook(remote: AudioBook, local: SyncMergeState?, detail: ) } else remote - // Preserve local progress if it's ahead of the server (offline playback). + // Preserve local progress if it's ahead of the server (offline playback), + // or if it is a completion the server copy does not carry. The library + // list holds no listening progress, so its copy always reads unfinished + // at zero, and a book the progress pull marked finished at position zero + // (finished on the server without a saved place) has no time to be + // ahead with. Comparing times alone wiped that completion on the next + // refresh. Only the progress pull, which reads the server's own record, + // may mark a book unfinished again. val localTime = local.currentTimeSeconds val remoteTime = withDownload.currentTime.inWholeMilliseconds / 1000.0 - val merged = if (localTime > remoteTime) { + val localDone = local.isFinished == 1 || local.progress >= 1.0 + val remoteDone = withDownload.isFinished || withDownload.progress >= 1.0 + val merged = if (localTime > remoteTime || (localDone && !remoteDone)) { withDownload.copy( currentTime = localTime.seconds, progress = local.progress, diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/SyncMergeTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/SyncMergeTest.kt index eea2755f..d0d7658c 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/repository/SyncMergeTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/SyncMergeTest.kt @@ -190,6 +190,34 @@ class SyncMergeTest { assertEquals(AudioBook(id = "untouched", duration = 300.seconds), merged[2]) } + @Test + fun `a later refresh keeps a completion imported at position zero`() = kotlinx.coroutines.runBlocking { + // First sync: the pull saved "done" finished at zero, so the new row + // lands finished. Pull to refresh lists it again with the list's + // unfinished defaults, and now the row exists so the merge runs. + val listed = AudioBook(id = "done", duration = 500.seconds) + val first = mergeSyncedBooksLean( + remote = listOf(listed), + getMergeStates = { emptyList() }, + getFullRows = { emptyList() }, + getProgressRows = { listOf(PlaybackProgressEntity(audioBookId = "done", positionSeconds = 0.0, isFinished = 1)) }, + ).single() + val cached = first.toEntity() + + val refreshed = mergeSyncedBooksLean( + remote = listOf(listed), + getMergeStates = { listOf(cached.toSyncMergeState()) }, + getFullRows = { listOf(cached) }, + getProgressRows = { error("a cached book does not read pulled progress") }, + ).single() + + assertTrue(refreshed.isFinished) + assertEquals(1.0, refreshed.progress, 0.0001) + // Progress 1.0 alone counts as done too. + val doneByProgress = mergeSyncedBook(listed, localEntity(isDownloaded = 0, progress = 1.0)) + assertEquals(1.0, doneByProgress.progress, 0.0001) + } + @Test fun `the lean merge matches the whole-row merge`() { val local = localEntity(currentTimeSeconds = 90.0, progress = 0.4, isFinished = 0, archivedAt = 7L) From 9c8367748559f1076b883d0ea3979072e911056a Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:37:47 -0400 Subject: [PATCH 53/69] Opening a book reads its own bookmarks instead of the whole profile The player loaded bookmarks by downloading all of /api/me, which carries every progress row the account owns. At a big library that is a megabyte or two per book opened. Any failure came back as an empty list, so a network blip looked like every bookmark was gone. Audiobookshelf 2.36.0 added a per-book bookmark route, and the player now asks it first. An older server answers 404, and then the player reads /api/me once and reuses its bookmarks for five minutes across books. The cache belongs to one server URL and sign-in, and adding or removing a bookmark drops it. A failed load now says so in the bookmark sheet with a tap to try again, and keeps any list already on screen. ApiService gains the new route and a RemoteResult return for bookmarks. Closes #73 Co-Authored-By: Claude Opus 5.5 --- .../app/data/remote/ApiService.kt | 71 +++++++++---- .../app/data/remote/AudiobookshelfApi.kt | 4 + .../app/data/remote/BookmarkFetch.kt | 96 ++++++++++++++++++ .../app/data/remote/dto/ApiDtos.kt | 6 ++ .../app/data/repository/BookmarkRepository.kt | 9 +- .../app/ui/player/BookmarkPublication.kt | 30 +++++- .../app/ui/player/PlayerScreen.kt | 33 ++++++- .../app/ui/player/PlayerViewModel.kt | 14 ++- .../app/data/remote/BookmarkFetchTest.kt | 99 +++++++++++++++++++ .../app/ui/player/BookmarkPublicationTest.kt | 42 ++++++-- 10 files changed, 366 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/data/remote/BookmarkFetch.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/data/remote/BookmarkFetchTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt index de9ca42f..12f95f54 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt @@ -1025,32 +1025,66 @@ class ApiService @Inject constructor( // ─── Bookmarks ─────────────────────────────────────────────────────── - suspend fun getBookmarks(itemId: String): List = withContext(Dispatchers.IO) { - try { - val response = api.getMe() - if (!response.isSuccessful) return@withContext emptyList() + private val meBookmarksCache = MeBookmarksCache() - response.body()?.bookmarks - ?.filter { it.libraryItemId == itemId } - ?.sortedBy { it.time } - ?.map { b -> - Bookmark( - id = b.id, - libraryItemId = b.libraryItemId, - title = b.title, - time = b.time, - createdAt = b.createdAt, - ) - } ?: emptyList() - } catch (e: Exception) { - emptyList() + /** + * One book's bookmarks. Uses the per-book route when the server has it and + * falls back to a briefly cached /api/me on older servers, because /api/me + * carries every progress row the account owns (megabytes at a big library). + */ + suspend fun getBookmarks(itemId: String): RemoteResult> = withContext(Dispatchers.IO) { + loadItemBookmarks( + itemId = itemId, + sessionKey = "$authGeneration|${settingsManager.currentSettings.serverUrl}", + cache = meBookmarksCache, + fetchItem = ::fetchItemBookmarks, + fetchAll = ::fetchAllBookmarksFromMe, + ) + } + + private suspend fun fetchItemBookmarks(itemId: String): ItemBookmarksOutcome { + val response = try { + api.getItemBookmarks(itemId) + } catch (e: kotlinx.serialization.SerializationException) { + // A server between the route landing and its 2.36.0 shape, or a + // proxy page. Either way the /api/me fallback still works. + return ItemBookmarksOutcome.Unsupported + } + if (response.code() == 404 || response.code() == 405) { + response.errorBody()?.close() + return ItemBookmarksOutcome.Unsupported } + if (!response.isSuccessful) { + response.errorBody()?.close() + return ItemBookmarksOutcome.Failed("HTTP ${response.code()} loading bookmarks") + } + val body = response.body() ?: return ItemBookmarksOutcome.Failed("Empty bookmarks body") + return ItemBookmarksOutcome.Found(body.bookmarks.map { it.toBookmark() }) } + private suspend fun fetchAllBookmarksFromMe(): RemoteResult> { + val response = api.getMe() + if (!response.isSuccessful) { + response.errorBody()?.close() + return RemoteResult.Failed("HTTP ${response.code()} loading bookmarks") + } + val body = response.body() ?: return RemoteResult.Failed("Empty profile body") + return RemoteResult.Ok(body.bookmarks.orEmpty().map { it.toBookmark() }) + } + + private fun ApiBookmark.toBookmark(): Bookmark = Bookmark( + id = id, + libraryItemId = libraryItemId, + title = title, + time = time, + createdAt = createdAt, + ) + suspend fun createBookmark(itemId: String, title: String, time: Double): Boolean = withContext(Dispatchers.IO) { try { val response = api.createBookmark(itemId, CreateBookmarkRequest(title, time)) + if (response.isSuccessful) meBookmarksCache.invalidate() response.isSuccessful } catch (e: Exception) { false @@ -1061,6 +1095,7 @@ class ApiService @Inject constructor( withContext(Dispatchers.IO) { try { val response = api.deleteBookmark(itemId, time) + if (response.isSuccessful) meBookmarksCache.invalidate() response.isSuccessful } catch (e: Exception) { false diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/AudiobookshelfApi.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/AudiobookshelfApi.kt index 12c5e0a1..915bdabf 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/remote/AudiobookshelfApi.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/AudiobookshelfApi.kt @@ -116,6 +116,10 @@ interface AudiobookshelfApi { // ─── Bookmarks ─────────────────────────────────────────────────────── + /** One book's bookmarks. Audiobookshelf 2.36.0 and later, older servers answer 404. */ + @GET("api/me/bookmarks/{itemId}") + suspend fun getItemBookmarks(@Path("itemId") itemId: String): Response + @POST("api/me/item/{itemId}/bookmark") suspend fun createBookmark( @Path("itemId") itemId: String, diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/BookmarkFetch.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/BookmarkFetch.kt new file mode 100644 index 00000000..e545661e --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/BookmarkFetch.kt @@ -0,0 +1,96 @@ +package com.ninelivesaudio.app.data.remote + +import com.ninelivesaudio.app.domain.model.Bookmark +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * What the per-book bookmark route (GET /api/me/bookmarks/{itemId}, added in + * Audiobookshelf 2.36.0) said. [Unsupported] means an older server answered + * 404 or 405, or sent a body that is not the expected shape, so the caller + * falls back to the bookmarks inside /api/me. + */ +internal sealed interface ItemBookmarksOutcome { + data class Found(val bookmarks: List) : ItemBookmarksOutcome + data object Unsupported : ItemBookmarksOutcome + data class Failed(val reason: String) : ItemBookmarksOutcome +} + +/** How long the bookmarks pulled out of /api/me are reused across player loads. */ +internal const val ME_BOOKMARKS_TTL_MS = 5 * 60_000L + +/** + * Bookmarks read from /api/me, kept for [ttlMs] so opening books on an older + * server does not download the whole profile (every progress row the account + * has) each time. One fetch at a time, so two loads back to back share it. + * Keyed by [sessionKey] so a different server URL or account never sees the + * last one's bookmarks. A failure is never cached. + */ +internal class MeBookmarksCache( + private val ttlMs: Long = ME_BOOKMARKS_TTL_MS, + private val now: () -> Long = System::currentTimeMillis, +) { + private val mutex = Mutex() + @Volatile private var invalidations = 0L + private var cachedKey: String? = null + private var cachedAtMs = 0L + private var cached: List? = null + + suspend fun get( + sessionKey: String, + fetch: suspend () -> RemoteResult>, + ): RemoteResult> = mutex.withLock { + val hit = cached + if (hit != null && cachedKey == sessionKey && now() - cachedAtMs < ttlMs) { + return@withLock RemoteResult.Ok(hit) + } + val startedAt = invalidations + val result = fetch() + // A bookmark added or removed while this fetch was in flight makes the + // answer stale, so it is handed back once but not kept. + if (result is RemoteResult.Ok && startedAt == invalidations) { + cachedKey = sessionKey + cachedAtMs = now() + cached = result.value + } + result + } + + /** Drops the cached list, after a bookmark is added or removed. */ + fun invalidate() { + invalidations++ + cached = null + } +} + +/** + * One book's bookmarks, from the per-book route when the server has it and + * from the cached /api/me otherwise. Every failure comes back as + * [RemoteResult.Failed], never as an empty list, so a network blip cannot + * pass for "this book has no bookmarks". + */ +internal suspend fun loadItemBookmarks( + itemId: String, + sessionKey: String, + cache: MeBookmarksCache, + fetchItem: suspend (String) -> ItemBookmarksOutcome, + fetchAll: suspend () -> RemoteResult>, +): RemoteResult> = remoteResultCatching { + when (val outcome = fetchItem(itemId)) { + is ItemBookmarksOutcome.Found -> RemoteResult.Ok(sortedForBook(itemId, outcome.bookmarks)) + is ItemBookmarksOutcome.Failed -> RemoteResult.Failed(outcome.reason) + ItemBookmarksOutcome.Unsupported -> cache.get(sessionKey) { + try { + fetchAll() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + RemoteResult.Failed(describeFailure(e)) + } + }.map { sortedForBook(itemId, it) } + } +} + +private fun sortedForBook(itemId: String, bookmarks: List): List = + bookmarks.filter { it.libraryItemId == itemId }.sortedBy { it.time } diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/dto/ApiDtos.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/dto/ApiDtos.kt index 7283b0ba..f3747711 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/remote/dto/ApiDtos.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/dto/ApiDtos.kt @@ -225,6 +225,12 @@ data class ApiBookmark( val createdAt: Long = 0, ) +/** GET /api/me/bookmarks/{itemId} */ +@Serializable +data class ApiItemBookmarksResponse( + val bookmarks: List = emptyList(), +) + // ─── Bookmark Request ──────────────────────────────────────────────────── @Serializable diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/BookmarkRepository.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/BookmarkRepository.kt index 238dddda..ab212ccb 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/repository/BookmarkRepository.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/BookmarkRepository.kt @@ -3,6 +3,7 @@ package com.ninelivesaudio.app.data.repository import com.ninelivesaudio.app.data.local.dao.LocalBookmarkDao import com.ninelivesaudio.app.data.local.entity.LocalBookmarkEntity import com.ninelivesaudio.app.data.remote.ApiService +import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.Bookmark import com.ninelivesaudio.app.service.SettingsManager @@ -16,7 +17,8 @@ import javax.inject.Singleton * player when the user marks a moment in a scanned-local file. * * In AUDIOBOOKSHELF mode, delegates to ApiService which round-trips - * /api/me/item/{itemId}/bookmark. + * the per-book bookmark route (or a cached /api/me on older servers) and + * /api/me/item/{itemId}/bookmark for changes. * * The repository owns the dispatch so callers (PlayerViewModel etc.) don't * need to know which backend is live — they just call get/create/delete with @@ -28,9 +30,10 @@ class BookmarkRepository @Inject constructor( private val bookmarkDao: LocalBookmarkDao, private val settingsManager: SettingsManager, ) { - suspend fun getBookmarks(itemId: String): List { + /** A failure stays a failure, so the player can say so instead of showing no bookmarks. */ + suspend fun getBookmarks(itemId: String): RemoteResult> { return if (isLocal()) { - bookmarkDao.getByAudioBookId(itemId).map { it.toDomain() } + RemoteResult.Ok(bookmarkDao.getByAudioBookId(itemId).map { it.toDomain() }) } else { apiService.getBookmarks(itemId) } diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/player/BookmarkPublication.kt b/app/src/main/java/com/ninelivesaudio/app/ui/player/BookmarkPublication.kt index f524e1fd..8fc4e905 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/player/BookmarkPublication.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/player/BookmarkPublication.kt @@ -1,5 +1,7 @@ package com.ninelivesaudio.app.ui.player +import com.ninelivesaudio.app.data.remote.RemoteResult +import com.ninelivesaudio.app.data.remote.describeFailure import com.ninelivesaudio.app.domain.model.Bookmark import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -35,8 +37,8 @@ internal class BookmarkPublication { fun launch( scope: CoroutineScope, request: BookmarkLoadRequest, - load: suspend (String) -> List, - publish: (List) -> Unit, + load: suspend (String) -> RemoteResult>, + publish: (RemoteResult>) -> Unit, ): Job? { val itemId = request.itemId ?: return null return scope.launch { @@ -44,10 +46,30 @@ internal class BookmarkPublication { load(itemId) } catch (cancelled: CancellationException) { throw cancelled - } catch (_: Exception) { - emptyList() + } catch (e: Exception) { + RemoteResult.Failed(describeFailure(e)) } if (current == request) publish(bookmarks) }.also { job = it } } } + +/** What the player shows after a bookmark load: the list, and whether it failed. */ +internal data class BookmarkListState( + val bookmarks: List, + val loadFailed: Boolean, +) + +/** + * A failed load keeps whatever list is on screen and raises the failure flag, + * so a network blip reads as "didn't load" instead of "all your bookmarks are + * gone". + */ +internal fun bookmarkListState( + current: List, + result: RemoteResult>, +): BookmarkListState = when (result) { + is RemoteResult.Ok -> BookmarkListState(result.value, loadFailed = false) + is RemoteResult.Partial -> BookmarkListState(result.value, loadFailed = false) + is RemoteResult.Failed -> BookmarkListState(current, loadFailed = true) +} 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 cb11e4a4..c49b3a2f 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 @@ -78,6 +78,8 @@ fun PlayerScreen( if (uiState.showBookmarks) { BookmarkSheet( bookmarks = uiState.bookmarks, + loadFailed = uiState.bookmarksLoadFailed, + onRetry = { viewModel.retryBookmarks() }, positionText = uiState.positionText, onAddBookmark = { title -> viewModel.addBookmark(title) }, onSeekToBookmark = { bookmark -> viewModel.seekToBookmark(bookmark) }, @@ -1009,10 +1011,14 @@ private fun EmptyPlayerState() { // ─── Bookmark Sheet ────────────────────────────────────────────────────── +internal const val BOOKMARKS_LOAD_FAILED_COPY = "Bookmarks didn't load. Tap to try again." + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun BookmarkSheet( bookmarks: List, + loadFailed: Boolean, + onRetry: () -> Unit, positionText: String, onAddBookmark: (String) -> Unit, onSeekToBookmark: (Bookmark) -> Unit, @@ -1092,17 +1098,34 @@ private fun BookmarkSheet( Spacer(modifier = Modifier.height(16.dp)) - // Bookmark list - if (bookmarks.isEmpty()) { + if (loadFailed) { Text( - text = "No bookmarks yet", + text = BOOKMARKS_LOAD_FAILED_COPY, style = MaterialTheme.typography.bodySmall, - color = NineLivesTheme.colors.archiveTextMuted, + color = NineLivesTheme.colors.goldFilament, modifier = Modifier .fillMaxWidth() - .padding(vertical = 24.dp), + .clickable(onClick = onRetry) + .padding(vertical = 12.dp), textAlign = TextAlign.Center, ) + } + + // Bookmark list + if (bookmarks.isEmpty()) { + // After a failed load the line above already speaks. "No + // bookmarks yet" would claim the book has none. + if (!loadFailed) { + Text( + text = "No bookmarks yet", + style = MaterialTheme.typography.bodySmall, + color = NineLivesTheme.colors.archiveTextMuted, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + textAlign = TextAlign.Center, + ) + } } else { LazyColumn( modifier = Modifier.heightIn(max = 300.dp), diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/player/PlayerViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/player/PlayerViewModel.kt index da7928ae..5ffe5d92 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/player/PlayerViewModel.kt @@ -79,6 +79,7 @@ class PlayerViewModel @Inject constructor( // Bookmarks val bookmarks: List = emptyList(), + val bookmarksLoadFailed: Boolean = false, val showBookmarks: Boolean = false, val bookmarkItemId: String? = null, @@ -279,6 +280,7 @@ class PlayerViewModel @Inject constructor( seriesName = book?.seriesName, bookmarkItemId = book?.id, bookmarks = emptyList(), + bookmarksLoadFailed = false, ) } if (book != null) { @@ -388,11 +390,19 @@ class PlayerViewModel @Inject constructor( scope = viewModelScope, request = request, load = bookmarkRepository::getBookmarks, - ) { bookmarks -> - _uiState.update { it.copy(bookmarks = bookmarks) } + ) { result -> + _uiState.update { + val next = bookmarkListState(it.bookmarks, result) + it.copy(bookmarks = next.bookmarks, bookmarksLoadFailed = next.loadFailed) + } } } + fun retryBookmarks() { + val itemId = _uiState.value.bookmarkItemId ?: return + bookmarkPublication.refreshCurrent(itemId)?.let(::loadBookmarks) + } + fun addBookmark(title: String) { val itemId = _uiState.value.bookmarkItemId ?: return val currentTimeSeconds = _uiState.value.position.toDouble(kotlin.time.DurationUnit.SECONDS) diff --git a/app/src/test/java/com/ninelivesaudio/app/data/remote/BookmarkFetchTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/remote/BookmarkFetchTest.kt new file mode 100644 index 00000000..faf0914c --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/remote/BookmarkFetchTest.kt @@ -0,0 +1,99 @@ +package com.ninelivesaudio.app.data.remote + +import com.ninelivesaudio.app.domain.model.Bookmark +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * Opening a book used to download all of /api/me (every progress row the + * account owns) just to read that book's bookmarks, and any failure showed up + * as an empty list. These pin the narrow route first, a short-lived shared + * /api/me fallback for older servers, and failures that stay failures. + */ +class BookmarkFetchTest { + + private val a1 = Bookmark(id = "1", libraryItemId = "book-a", time = 90.0) + private val a2 = Bookmark(id = "2", libraryItemId = "book-a", time = 30.0) + private val b1 = Bookmark(id = "3", libraryItemId = "book-b", time = 10.0) + + private var clock = 0L + private val cache = MeBookmarksCache(ttlMs = 1_000L, now = { clock }) + private var meCalls = 0 + + private fun allFromMe(): RemoteResult> { + meCalls++ + return RemoteResult.Ok(listOf(a1, a2, b1)) + } + + private suspend fun load( + itemId: String, + session: String = "s1", + item: ItemBookmarksOutcome = ItemBookmarksOutcome.Unsupported, + all: suspend () -> RemoteResult> = ::allFromMe, + ) = loadItemBookmarks(itemId, session, cache, { item }, all) + + @Test + fun `a server with the per-book route never touches api me`() = runBlocking { + val result = load("book-a", item = ItemBookmarksOutcome.Found(listOf(a1, a2))) + assertEquals(RemoteResult.Ok(listOf(a2, a1)), result) + assertEquals(0, meCalls) + } + + @Test + fun `an older server falls back to api me once and shares it across books`() = runBlocking { + assertEquals(RemoteResult.Ok(listOf(a2, a1)), load("book-a")) + assertEquals(RemoteResult.Ok(listOf(b1)), load("book-b")) + assertEquals(1, meCalls) + } + + @Test + fun `the cached profile refreshes after the window`() = runBlocking { + load("book-a") + clock = 1_000L + load("book-a") + assertEquals(2, meCalls) + } + + @Test + fun `a new server or account does not reuse the last one's bookmarks`() = runBlocking { + load("book-a", session = "s1") + load("book-a", session = "s2") + assertEquals(2, meCalls) + } + + @Test + fun `adding or removing a bookmark drops the cached list`() = runBlocking { + load("book-a") + cache.invalidate() + load("book-a") + assertEquals(2, meCalls) + } + + @Test + fun `a change during the fetch is not cached as current`() = runBlocking { + load("book-a", all = { + cache.invalidate() + allFromMe() + }) + load("book-a") + assertEquals(2, meCalls) + } + + @Test + fun `a failed per-book call is a failure, not an empty list`() = runBlocking { + val result = load("book-a", item = ItemBookmarksOutcome.Failed("HTTP 500 loading bookmarks")) + assertEquals(RemoteResult.Failed("HTTP 500 loading bookmarks"), result) + assertEquals(0, meCalls) + } + + @Test + fun `a thrown api me fallback is a failure and is not cached`() = runBlocking { + val result = load("book-a", all = { throw IOException("timeout") }) + assertTrue(result is RemoteResult.Failed) + assertEquals(RemoteResult.Ok(listOf(a2, a1)), load("book-a")) + assertEquals(1, meCalls) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/player/BookmarkPublicationTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/player/BookmarkPublicationTest.kt index cfa75ba9..c50dfd3d 100644 --- a/app/src/test/java/com/ninelivesaudio/app/ui/player/BookmarkPublicationTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/ui/player/BookmarkPublicationTest.kt @@ -1,5 +1,6 @@ package com.ninelivesaudio.app.ui.player +import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.domain.model.Bookmark import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -10,6 +11,8 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class BookmarkPublicationTest { @@ -22,7 +25,7 @@ class BookmarkPublicationTest { val publication = BookmarkPublication() val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val aStarted = CompletableDeferred() - val releaseA = CompletableDeferred>() + val releaseA = CompletableDeferred>>() var visible = emptyList() val aRequest = publication.replace("book-a") @@ -32,17 +35,17 @@ class BookmarkPublicationTest { aStarted.complete(Unit) releaseA.await() } - }) { visible = it }) + }) { visible = bookmarkListState(visible, it).bookmarks }) aStarted.await() val bRequest = publication.replace("book-b") - val bJob = requireNotNull(publication.launch(scope, bRequest, { bBookmarks }) { visible = it }) + val bJob = requireNotNull(publication.launch(scope, bRequest, { RemoteResult.Ok(bBookmarks) }) { visible = bookmarkListState(visible, it).bookmarks }) bJob.join() assertEquals(bBookmarks, visible) // This simulates a repository that catches cancellation and still // returns its already-started A response. - releaseA.complete(aBookmarks) + releaseA.complete(RemoteResult.Ok(aBookmarks)) aJob.join() assertEquals(bBookmarks, visible) scope.cancel() @@ -63,11 +66,11 @@ class BookmarkPublicationTest { releaseA.await() } error("A failed after B became current") - }) { visible = it }) + }) { visible = bookmarkListState(visible, it).bookmarks }) aStarted.await() val bRequest = publication.replace("book-b") - val bJob = requireNotNull(publication.launch(scope, bRequest, { bBookmarks }) { visible = it }) + val bJob = requireNotNull(publication.launch(scope, bRequest, { RemoteResult.Ok(bBookmarks) }) { visible = bookmarkListState(visible, it).bookmarks }) bJob.join() assertEquals(bBookmarks, visible) @@ -76,4 +79,31 @@ class BookmarkPublicationTest { assertEquals(bBookmarks, visible) scope.cancel() } + + @Test + fun `a thrown load publishes a failure, not an empty list`() = runBlocking { + val publication = BookmarkPublication() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + var published: RemoteResult>? = null + + val request = publication.replace("book-a") + requireNotNull(publication.launch(scope, request, { error("socket closed") }) { published = it }).join() + + assertTrue(published is RemoteResult.Failed) + scope.cancel() + } + + @Test + fun `a failed refresh keeps the bookmarks on screen and flags the failure`() { + val state = bookmarkListState(aBookmarks, RemoteResult.Failed("timeout")) + assertEquals(aBookmarks, state.bookmarks) + assertTrue(state.loadFailed) + } + + @Test + fun `a good load clears the failure flag`() { + val state = bookmarkListState(aBookmarks, RemoteResult.Ok(bBookmarks)) + assertEquals(bBookmarks, state.bookmarks) + assertFalse(state.loadFailed) + } } From 61b0470960dfa7f564260955f58b4a5f4116742e Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:40:06 -0400 Subject: [PATCH 54/69] Book detail lists chapters for books the library list synced The library list sync carries no chapters, and the detail screen only asked the server when the book was missing from the cache. So the Chapters section stayed hidden for nearly every server book until it was played or downloaded. Opening a server book with no chapters while the server is reachable now fetches that one item and shows its chapters. They stay on the screen and are never written back to the row, so shelf rows stay light. A failed fetch tries again on the next resume or open, and a book the server says has no chapters is asked once. Closes #78 Co-Authored-By: Claude Opus 5.5 --- .../app/ui/bookdetail/BookDetailViewModel.kt | 62 ++++++++++++++++++- .../app/ui/bookdetail/DetailChaptersTest.kt | 62 +++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/bookdetail/DetailChaptersTest.kt 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 b3f1179b..cf2ac1a5 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 @@ -52,6 +52,27 @@ internal fun historyPresentation(result: RemoteResult>): ) } +/** + * The library list sync carries no chapters, so most server books open with an + * empty chapter list. Fetch the single item for them while the server is + * reachable. Scanned-local and archived books have nothing to ask the server. + */ +internal fun shouldFetchDetailChapters( + book: AudioBook, + appMode: AppMode, + connection: ConnectivityMonitor.ConnectionStatus, +): Boolean = + appMode == AppMode.AUDIOBOOKSHELF && + !book.isLocal && + !book.isArchived && + book.chapters.isEmpty() && + (connection == ConnectivityMonitor.ConnectionStatus.CONNECTED || + connection == ConnectivityMonitor.ConnectionStatus.SYNCING) + +/** The stored chapters when the row has them, else the ones fetched for this screen. */ +internal fun detailChapters(stored: List, fetched: List?): List = + stored.ifEmpty { fetched.orEmpty() }.sortedBy { it.start } + /** Tracks resumes for one ViewModel lifetime, never across process recreation. */ internal class BookDetailResumeTracker { private var hasResumed = false @@ -137,6 +158,12 @@ class BookDetailViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private val resumeTracker = BookDetailResumeTracker() + // Chapters from the single-item fetch, for a row the library list synced + // without them. Held for this screen only and never written back, so the + // shelf rows stay light. + private var serverChapters: List? = null + private var chapterFetchStarted = false + init { if (bookId.isNotEmpty()) { loadBook() @@ -181,6 +208,15 @@ class BookDetailViewModel @Inject constructor( sourceAccessible = access.isPlayable || access.book.isArchived, needsFolderRecovery = access.needsFolderRecovery, ) + if (!chapterFetchStarted && shouldFetchDetailChapters( + book = access.book, + appMode = settings.appMode, + connection = connectivityMonitor.connectionStatus.value, + ) + ) { + chapterFetchStarted = true + loadServerChapters() + } } catch (e: Exception) { _uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load: ${e.message}") @@ -189,6 +225,30 @@ class BookDetailViewModel @Inject constructor( } } + private fun loadServerChapters() { + viewModelScope.launch { + val fetched = try { + audioBookRepository.fetchFromServer(bookId) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + null + } + if (fetched == null) { + // The call failed, so a resume or a later open can try again. + // A book the server says has no chapters is not asked twice. + chapterFetchStarted = false + return@launch + } + if (fetched.chapters.isEmpty()) return@launch + serverChapters = fetched.chapters + _uiState.update { state -> + val held = state.book ?: return@update state + state.copy(chapters = detailChapters(held.chapters, serverChapters)) + } + } + } + private fun populateFromBook( book: AudioBook, sourceAccessible: Boolean, @@ -222,7 +282,7 @@ class BookDetailViewModel @Inject constructor( isArchived = book.isArchived, sourceAccessible = sourceAccessible, needsFolderRecovery = needsFolderRecovery, - chapters = book.chapters.sortedBy { c -> c.start }, + chapters = detailChapters(book.chapters, serverChapters), addedAt = book.addedAt, downloadState = if (book.isDownloaded) DownloadButtonState.COMPLETED else it.downloadState, ) diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/bookdetail/DetailChaptersTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/bookdetail/DetailChaptersTest.kt new file mode 100644 index 00000000..ec676fd2 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/bookdetail/DetailChaptersTest.kt @@ -0,0 +1,62 @@ +package com.ninelivesaudio.app.ui.bookdetail + +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.Chapter +import com.ninelivesaudio.app.service.ConnectivityMonitor.ConnectionStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The library list sync carries no chapters, so the detail screen hid its + * Chapters section for nearly every server book. It now asks the server for + * the single item when the row has none, and shows them without saving them. + */ +class DetailChaptersTest { + + private val listSynced = AudioBook(id = "b1", title = "Book") + private val one = Chapter(id = 0, start = 0.0, end = 60.0, title = "One") + private val two = Chapter(id = 1, start = 60.0, end = 120.0, title = "Two") + + @Test + fun `a list-synced server book asks for its chapters while connected`() { + assertTrue(shouldFetchDetailChapters(listSynced, AppMode.AUDIOBOOKSHELF, ConnectionStatus.CONNECTED)) + assertTrue(shouldFetchDetailChapters(listSynced, AppMode.AUDIOBOOKSHELF, ConnectionStatus.SYNCING)) + } + + @Test + fun `no fetch while offline or the server is unreachable`() { + assertFalse(shouldFetchDetailChapters(listSynced, AppMode.AUDIOBOOKSHELF, ConnectionStatus.OFFLINE)) + assertFalse(shouldFetchDetailChapters(listSynced, AppMode.AUDIOBOOKSHELF, ConnectionStatus.SERVER_UNREACHABLE)) + } + + @Test + fun `a row that already has chapters does not refetch`() { + val withChapters = listSynced.copy(chapters = listOf(one)) + assertFalse(shouldFetchDetailChapters(withChapters, AppMode.AUDIOBOOKSHELF, ConnectionStatus.CONNECTED)) + } + + @Test + fun `scanned-local and archived books never ask the server`() { + assertFalse(shouldFetchDetailChapters(listSynced.copy(isLocal = true), AppMode.LOCAL, ConnectionStatus.CONNECTED)) + assertFalse(shouldFetchDetailChapters(listSynced, AppMode.LOCAL, ConnectionStatus.CONNECTED)) + assertFalse(shouldFetchDetailChapters(listSynced.copy(archivedAt = 1L), AppMode.AUDIOBOOKSHELF, ConnectionStatus.CONNECTED)) + } + + @Test + fun `fetched chapters fill an empty row, in order`() { + assertEquals(listOf(one, two), detailChapters(emptyList(), listOf(two, one))) + } + + @Test + fun `stored chapters win over fetched ones`() { + assertEquals(listOf(one), detailChapters(listOf(one), listOf(one, two))) + } + + @Test + fun `nothing stored and nothing fetched shows no chapters`() { + assertEquals(emptyList(), detailChapters(emptyList(), null)) + } +} From fcdb8ffb9ee647d5d596d16eac08ea4b8ac375ac Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:42:11 -0400 Subject: [PATCH 55/69] Downloads lists every book on the device, and Clear All is gone Clear All under Completed deleted the download rows and kept the files on purpose, so every offline book dropped off the Downloads screen while it still sat on the device. That is the one screen built to find and delete them, so storage cleanup got harder the moment someone tidied the list. Completed now lists downloaded books from the book rows as well as the download rows. A book with no row left (cleared before this change, or not written yet) shows after the rest, by title, without a size. Clear All is removed, because clearing the list without deleting anything would hide the books again. Each book's Delete still removes its files. Books hidden by an earlier Clear All come back on their own. Closes #68 Co-Authored-By: Claude Opus 5.5 --- .../app/data/local/dao/DownloadItemDao.kt | 16 ++++++ .../data/local/entity/DownloadRowWithBook.kt | 17 ++++++ .../app/entitlement/DownloadSlotResolver.kt | 2 +- .../app/service/download/DownloadSlotStore.kt | 2 +- .../app/ui/downloads/DownloadLists.kt | 40 +++++++++++++- .../app/ui/downloads/DownloadsScreen.kt | 36 ++++++------- .../app/ui/downloads/DownloadsViewModel.kt | 52 ++++++++----------- .../app/ui/downloads/DownloadListsTest.kt | 40 ++++++++++++++ 8 files changed, 149 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/DownloadItemDao.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/DownloadItemDao.kt index fb67cc0a..4ad63813 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/DownloadItemDao.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/DownloadItemDao.kt @@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import com.ninelivesaudio.app.data.local.entity.DownloadItemEntity import com.ninelivesaudio.app.data.local.entity.DownloadRowWithBook +import com.ninelivesaudio.app.data.local.entity.DownloadedBookRow import kotlinx.coroutines.flow.Flow @Dao @@ -49,6 +50,21 @@ interface DownloadItemDao { ) fun observeAllWithBooks(): Flow> + /** + * Every server book on the device, from the book rows. Download rows can be + * gone (an older Clear All deleted them and kept the files), and the + * Downloads screen still has to list the book. Reads only the three columns + * the screen needs, never the chapter or file blobs. + */ + @Query( + """ + SELECT Id, Title, COALESCE(LocalCoverPath, CoverPath) AS BookCoverPath + FROM AudioBooks + WHERE IsDownloaded = 1 AND IsLocal = 0 + """ + ) + fun observeDownloadedServerBooks(): Flow> + /** * Downloadable items for the drain worker: Queued (0) or interrupted * Downloading (1). Paused/Completed/Failed/Cancelled are excluded so the diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/DownloadRowWithBook.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/DownloadRowWithBook.kt index c15bad8c..e3ef286d 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/DownloadRowWithBook.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/DownloadRowWithBook.kt @@ -19,3 +19,20 @@ data class DownloadRowWithBook( @ColumnInfo(name = "BookIsDownloaded") val bookIsDownloaded: Int, ) + +/** + * A server book whose files are on the device, read from the book row itself. + * The Downloads screen lists these whether or not a download row survived, so + * offline books never drop out of the one screen built to manage them. + */ +data class DownloadedBookRow( + @ColumnInfo(name = "Id") + val id: String, + + @ColumnInfo(name = "Title") + val title: String, + + /** The book's saved cover when it has one, otherwise its server cover. */ + @ColumnInfo(name = "BookCoverPath") + val coverPath: String?, +) diff --git a/app/src/main/java/com/ninelivesaudio/app/entitlement/DownloadSlotResolver.kt b/app/src/main/java/com/ninelivesaudio/app/entitlement/DownloadSlotResolver.kt index dd5dbdb1..2981f360 100644 --- a/app/src/main/java/com/ninelivesaudio/app/entitlement/DownloadSlotResolver.kt +++ b/app/src/main/java/com/ninelivesaudio/app/entitlement/DownloadSlotResolver.kt @@ -8,7 +8,7 @@ import com.ninelivesaudio.app.domain.model.DownloadStatus * * Assembled by the caller from three tables, because no single one of them is * sufficient. `DownloadEngine` writes the offline fields on `AudioBooks` only - * AFTER writing `Completed`, and `clearCompleted()` deletes `DownloadItems` rows + * AFTER writing `Completed`, and an older Clear All deleted `DownloadItems` rows * while leaving the files on disk. Either source alone therefore misses real * offline books. */ 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 294c9bf3..c984d7f1 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 @@ -59,7 +59,7 @@ class DownloadSlotStore @Inject constructor( * * Built from the union of live download rows and retained offline state, * because neither source alone is complete: the engine writes the offline - * fields only after writing Completed, and `clearCompleted()` deletes rows + * fields only after writing Completed, and an older Clear All deleted rows * while leaving files on disk. */ suspend fun buildCandidates(): List = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadLists.kt b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadLists.kt index c5f46828..54a9db98 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadLists.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadLists.kt @@ -10,6 +10,13 @@ internal data class DownloadRow( val bookIsDownloaded: Boolean, ) +/** A downloaded book as the screen needs it, from the book row. */ +internal data class DownloadedBook( + val audioBookId: String, + val title: String, + val coverPath: String?, +) + /** The Downloads screen's two lists, newest first. */ internal data class DownloadLists( val active: List, @@ -32,8 +39,17 @@ private val IN_PROGRESS = setOf( * made the book vanish with no way to retry. A Failed row stays out only when * it is stale: the book got downloaded anyway, or a later attempt for the same * book exists (that row speaks for the book instead). + * + * Completed lists every book on the device. A Completed row speaks for its + * book, and a downloaded book with no Completed row (its row was cleared, or + * the engine has not written it yet) is listed from [downloadedBooks] after + * them, by title. Without that, an offline book vanished from Downloads the + * moment its tracking row went. */ -internal fun splitDownloadRows(rows: List): DownloadLists { +internal fun splitDownloadRows( + rows: List, + downloadedBooks: List = emptyList(), +): DownloadLists { val byBook = rows.groupBy { it.download.audioBookId } val active = rows.filter { row -> when (row.download.status) { @@ -42,12 +58,32 @@ internal fun splitDownloadRows(rows: List): DownloadLists { else -> false } }.sortedByDescending { it.download.startedAt ?: 0L } - val completed = rows + val completedRows = rows .filter { it.download.status == DownloadStatus.Completed } .sortedByDescending { it.download.completedAt ?: 0L } + val listedBooks = completedRows.mapTo(HashSet()) { it.download.audioBookId } + val bookOnly = downloadedBooks + .filter { it.audioBookId !in listedBooks } + .sortedBy { it.title.lowercase() } + .map { book -> + DownloadRow( + download = DownloadItem( + id = "$BOOK_ONLY_ID_PREFIX${book.audioBookId}", + audioBookId = book.audioBookId, + title = book.title, + status = DownloadStatus.Completed, + ), + coverPath = book.coverPath, + bookIsDownloaded = true, + ) + } + val completed = completedRows + bookOnly return DownloadLists(active = active, completed = completed) } +/** Id prefix for a Completed entry built from a book row, never a real download id. */ +internal const val BOOK_ONLY_ID_PREFIX = "book:" + private fun DownloadRow.isStaleFailure(sameBook: List): Boolean { if (bookIsDownloaded) return true val startedAt = download.startedAt ?: 0L 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 ae737c3c..50127dd6 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 @@ -83,22 +83,12 @@ fun DownloadsScreen( // ── Completed Downloads Section ─────────────────────── if (uiState.completedDownloads.isNotEmpty()) { item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp, bottom = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + // No Clear All here. It deleted the download rows and + // kept the files, so offline books vanished from the + // one screen that manages them. Each book's Delete + // removes its files. + Box(modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)) { SectionHeader(title = "Completed") - - TextButton(onClick = { viewModel.clearCompleted() }) { - Text( - text = "Clear All", - color = NineLivesTheme.colors.goldFilament, - style = MaterialTheme.typography.labelMedium, - ) - } } } @@ -379,12 +369,16 @@ private fun CompletedDownloadCard( color = NineLivesTheme.colors.archiveSuccess, fontSize = 11.sp, ) - Text( - text = download.sizeDisplay, - style = MaterialTheme.typography.labelSmall, - color = NineLivesTheme.colors.archiveTextMuted, - fontSize = 11.sp, - ) + // A book listed from its row alone has no download + // record, so no byte count to show. + if (download.totalBytes > 0) { + Text( + text = download.sizeDisplay, + style = MaterialTheme.typography.labelSmall, + color = NineLivesTheme.colors.archiveTextMuted, + fontSize = 11.sp, + ) + } } } 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 be140b4d..f9cbf3e3 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 @@ -59,23 +59,28 @@ class DownloadsViewModel @Inject constructor( } init { - // One joined query feeds both lists. It re-runs on every download - // progress write and every book row write (playback saves position - // twice a second), so unchanged lists are dropped before they reach - // the screen. + // The download rows and the downloaded book rows feed both lists. The + // queries re-run on every download progress write and every book row + // write (playback saves its place), so unchanged lists are dropped + // before they reach the screen. viewModelScope.launch { - downloadItemDao.observeAllWithBooks() - .map { rows -> - splitDownloadRows( - rows.map { row -> - DownloadRow( - download = row.download.toDomain(), - coverPath = row.coverPath, - bookIsDownloaded = row.bookIsDownloaded == 1, - ) - } - ) - } + combine( + downloadItemDao.observeAllWithBooks(), + downloadItemDao.observeDownloadedServerBooks(), + ) { rows, books -> + splitDownloadRows( + rows = rows.map { row -> + DownloadRow( + download = row.download.toDomain(), + coverPath = row.coverPath, + bookIsDownloaded = row.bookIsDownloaded == 1, + ) + }, + downloadedBooks = books.map { book -> + DownloadedBook(audioBookId = book.id, title = book.title, coverPath = book.coverPath) + }, + ) + } .distinctUntilChanged() .collect { lists -> val active = lists.active.map { @@ -163,21 +168,6 @@ class DownloadsViewModel @Inject constructor( } } - fun clearCompleted() { - viewModelScope.launch { - val completed = _uiState.value.completedDownloads - completed.forEach { item -> - try { - // Only remove the download tracking record — keep the actual files - // on disk so the user's downloaded audiobooks remain playable. - downloadItemDao.deleteById(item.download.id) - } catch (_: Exception) { - // Continue clearing others even if one fails - } - } - } - } - /** Queue a new download for an audiobook. */ fun queueDownload(audioBook: AudioBook) { viewModelScope.launch { diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/downloads/DownloadListsTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/downloads/DownloadListsTest.kt index e3a6fba0..adf18e60 100644 --- a/app/src/test/java/com/ninelivesaudio/app/ui/downloads/DownloadListsTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/ui/downloads/DownloadListsTest.kt @@ -124,4 +124,44 @@ class DownloadListsTest { assertEquals("cover-q", lists.active.single().coverPath) } + + // Clear All used to delete the download rows and keep the files, so every + // offline book vanished from Downloads. Book rows now list them. + + private fun book(id: String, title: String = id) = + DownloadedBook(audioBookId = id, title = title, coverPath = "book-cover-$id") + + @Test + fun `a downloaded book with no download row still shows as completed`() { + val lists = splitDownloadRows(emptyList(), listOf(book("b"))) + + assertEquals(listOf("${BOOK_ONLY_ID_PREFIX}b"), lists.completedIds()) + val entry = lists.completed.single() + assertEquals("b", entry.download.audioBookId) + assertEquals("book-cover-b", entry.coverPath) + assertEquals(DownloadStatus.Completed, entry.download.status) + } + + @Test + fun `a completed row speaks for its book, so the book is listed once`() { + val lists = splitDownloadRows( + listOf(row("c", DownloadStatus.Completed, bookId = "b", completedAt = 5)), + listOf(book("b")), + ) + + assertEquals(listOf("c"), lists.completedIds()) + } + + @Test + fun `books without rows follow the completed rows, by title`() { + val lists = splitDownloadRows( + listOf(row("c", DownloadStatus.Completed, bookId = "x", completedAt = 5)), + listOf(book("z", title = "zebra"), book("a", title = "Aardvark")), + ) + + assertEquals( + listOf("c", "${BOOK_ONLY_ID_PREFIX}a", "${BOOK_ONLY_ID_PREFIX}z"), + lists.completedIds(), + ) + } } From fa13ce93e42e3f9dfa2c9aa99b1a6c37d8a56369 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:44:04 -0400 Subject: [PATCH 56/69] Retry on a failed download asks the free slot first On the free tier a failed download gives up the one offline slot, so another book can download meanwhile. Retry on the failed book then put it back in the queue without asking, and the queue only runs the book holding the slot. The retried book sat in Queued forever with no word. Resume and Retry now check the slot in the same step that requeues the row, and take the slot when they win it. When another book holds it, the row stays as it was and Downloads shows the same free tier notice the book screen uses. A paused book the slot no longer favours gets the same answer. Co-Authored-By: Claude Opus 5.5 --- .../app/entitlement/FreeTier.kt | 4 ++ .../app/service/DownloadManager.kt | 34 +++++++++--- .../app/service/download/DownloadPolicies.kt | 20 +++++++ .../app/ui/bookdetail/BookDetailViewModel.kt | 4 +- .../app/ui/downloads/DownloadsScreen.kt | 15 +++++ .../app/ui/downloads/DownloadsViewModel.kt | 20 ++++++- .../service/download/ResumeDecisionTest.kt | 55 +++++++++++++++++++ 7 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/download/ResumeDecisionTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/entitlement/FreeTier.kt b/app/src/main/java/com/ninelivesaudio/app/entitlement/FreeTier.kt index 19f509d6..942c7185 100644 --- a/app/src/main/java/com/ninelivesaudio/app/entitlement/FreeTier.kt +++ b/app/src/main/java/com/ninelivesaudio/app/entitlement/FreeTier.kt @@ -17,6 +17,10 @@ import com.ninelivesaudio.app.ui.library.ViewMode */ object FreeTier { + /** Said wherever a download is refused because the one free slot is taken. */ + const val DOWNLOAD_SLOT_NOTICE = "Free keeps one downloaded book at a time. " + + "Delete the one you have, or unlock for unlimited offline books." + /** * Sorts a free install can choose: all of them, as of 2026-08-15. * 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 64296c87..34cadc28 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt @@ -27,6 +27,8 @@ import com.ninelivesaudio.app.service.download.finishInOwnerScope import com.ninelivesaudio.app.service.download.lookUpInChunks import com.ninelivesaudio.app.service.download.pauseKeepsCompletedRow 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.writeAfterEngineStops import dagger.hilt.android.qualifiers.ApplicationContext @@ -530,17 +532,33 @@ class DownloadManager @Inject constructor( } /** Resume a paused/failed download by re-queuing it and ensuring the drain runs. */ - suspend fun resumeDownload(downloadId: String) { + /** How a Resume or Retry tap went. */ + enum class ResumeResult { RESUMED, BLOCKED_BY_FREE_SLOT, NOT_RESUMABLE } + + suspend fun resumeDownload(downloadId: String): ResumeResult { // One critical section from the read to the drain start, so a cancel // either sees the Queued row or deletes it before this reads it. rowMutex.withLock { - val entity = downloadItemDao.getById(downloadId) ?: return - val item = entity.toDomain() - if (item.status != DownloadStatus.Paused && item.status != DownloadStatus.Failed) return - - // Reset to Queued. The engine skips already-finished files on re-run. - downloadItemDao.upsert(entity.copy(status = DownloadStatus.Queued.ordinal)) - if (!downloadsPaused) enqueueDrainHoldingRowLock(replace = false) + val entity = downloadItemDao.getById(downloadId) ?: return ResumeResult.NOT_RESUMABLE + val decision = slotMutex.withLock { + val decided = decideResume(entity.toDomain().status) { + slotStore.canClaim(entity.audioBookId) + } + if (decided == ResumeDecision.REQUEUE) { + // Reset to Queued. The engine skips already-finished files on re-run. + downloadItemDao.upsert(entity.copy(status = DownloadStatus.Queued.ordinal)) + if (slotStore.slotApplies) slotStore.persistedWinner = entity.audioBookId + } + decided + } + return when (decision) { + ResumeDecision.REQUEUE -> { + if (!downloadsPaused) enqueueDrainHoldingRowLock(replace = false) + ResumeResult.RESUMED + } + ResumeDecision.BLOCKED_BY_FREE_SLOT -> ResumeResult.BLOCKED_BY_FREE_SLOT + ResumeDecision.IGNORE -> ResumeResult.NOT_RESUMABLE + } } } 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 30bec295..452fc115 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 @@ -1,6 +1,7 @@ package com.ninelivesaudio.app.service.download import com.ninelivesaudio.app.domain.model.AudioFile +import com.ninelivesaudio.app.domain.model.DownloadStatus import kotlin.math.pow // ─── Download decision logic (pure, unit-testable) ──────────────────────── @@ -90,3 +91,22 @@ internal fun retryBackoffMs(retryCount: Int): Long = /** A file is already downloaded if it exists on disk with non-zero length. */ internal fun shouldSkipDownloadedFile(exists: Boolean, length: Long): Boolean = exists && length > 0 + +/** What a Resume or Retry tap on one download row should do. */ +internal enum class ResumeDecision { REQUEUE, BLOCKED_BY_FREE_SLOT, IGNORE } + +/** + * Only a Paused or Failed row can be resumed, and only when its book may hold + * the free slot ([canClaim] is true whenever the slot does not apply). A + * Failed row gives the slot up, so another book can download meanwhile. + * Requeueing it anyway left it Queued forever: the drain only runs the slot + * winner, which was the other book. + */ +internal suspend fun decideResume( + status: DownloadStatus, + canClaim: suspend () -> Boolean, +): ResumeDecision = when { + status != DownloadStatus.Paused && status != DownloadStatus.Failed -> ResumeDecision.IGNORE + !canClaim() -> ResumeDecision.BLOCKED_BY_FREE_SLOT + else -> ResumeDecision.REQUEUE +} 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 cf2ac1a5..066e25e2 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 @@ -15,6 +15,7 @@ import com.ninelivesaudio.app.domain.model.DownloadStatus import com.ninelivesaudio.app.domain.model.ListeningSession import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.isInActiveLibrary +import com.ninelivesaudio.app.entitlement.FreeTier import com.ninelivesaudio.app.service.DownloadManager import com.ninelivesaudio.app.service.ConnectivityMonitor import com.ninelivesaudio.app.service.PlaybackManager @@ -423,8 +424,7 @@ class BookDetailViewModel @Inject constructor( it.copy( downloadState = previous, downloadProgress = 0, - downloadNotice = "Free keeps one downloaded book at a time. " + - "Delete the one you have, or unlock for unlimited offline books.", + downloadNotice = FreeTier.DOWNLOAD_SLOT_NOTICE, downloadNoticeOffersUnlock = true, ) } 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 50127dd6..72d1113c 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 @@ -1,6 +1,7 @@ package com.ninelivesaudio.app.ui.downloads import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -61,6 +62,20 @@ fun DownloadsScreen( contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + uiState.notice?.let { message -> + item { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = NineLivesTheme.colors.archiveTextMuted, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = viewModel::dismissNotice) + .padding(vertical = 8.dp), + ) + } + } + // ── Active Downloads Section ────────────────────────── if (uiState.activeDownloads.isNotEmpty()) { item { 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 f9cbf3e3..134fb610 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 @@ -7,6 +7,7 @@ import com.ninelivesaudio.app.data.local.dao.DownloadItemDao import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.DownloadItem +import com.ninelivesaudio.app.entitlement.FreeTier import com.ninelivesaudio.app.service.ConnectivityMonitor import com.ninelivesaudio.app.service.ConnectivityMonitor.ConnectionStatus import com.ninelivesaudio.app.service.DownloadManager @@ -37,6 +38,8 @@ class DownloadsViewModel @Inject constructor( val showEmptyState: Boolean = true, val connectionStatus: ConnectionStatus = ConnectionStatus.OFFLINE, val isLocalMode: Boolean = false, + /** Why the last Resume or Retry did not start, or null. */ + val notice: String? = null, ) private val _uiState = MutableStateFlow(UiState()) @@ -152,10 +155,15 @@ class DownloadsViewModel @Inject constructor( fun resumeDownload(downloadId: String) { viewModelScope.launch { - downloadManager.resumeDownload(downloadId) + val result = downloadManager.resumeDownload(downloadId) + _uiState.update { it.copy(notice = resumeNotice(result)) } } } + fun dismissNotice() { + _uiState.update { it.copy(notice = null) } + } + fun cancelDownload(downloadId: String) { viewModelScope.launch { downloadManager.cancelDownload(downloadId) @@ -175,3 +183,13 @@ class DownloadsViewModel @Inject constructor( } } } + +/** + * A Retry the free slot refuses has to say so. It used to requeue anyway and + * sit in Queued forever with no word. + */ +internal fun resumeNotice(result: DownloadManager.ResumeResult): String? = when (result) { + DownloadManager.ResumeResult.BLOCKED_BY_FREE_SLOT -> FreeTier.DOWNLOAD_SLOT_NOTICE + DownloadManager.ResumeResult.RESUMED, + DownloadManager.ResumeResult.NOT_RESUMABLE -> null +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/ResumeDecisionTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/ResumeDecisionTest.kt new file mode 100644 index 00000000..1b59bd1b --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/ResumeDecisionTest.kt @@ -0,0 +1,55 @@ +package com.ninelivesaudio.app.service.download + +import com.ninelivesaudio.app.domain.model.DownloadStatus +import com.ninelivesaudio.app.entitlement.FreeTier +import com.ninelivesaudio.app.service.DownloadManager +import com.ninelivesaudio.app.ui.downloads.resumeNotice +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * On the free tier a failed download gives up the slot, so another book can + * download meanwhile. Retry on the failed one used to requeue it without + * asking, and it sat in Queued forever because the drain only runs the slot + * winner. Retry now asks the slot first and says so when it is taken. + */ +class ResumeDecisionTest { + + @Test + fun `retrying a failed download while another book holds the slot is refused`() = runBlocking { + assertEquals(ResumeDecision.BLOCKED_BY_FREE_SLOT, decideResume(DownloadStatus.Failed) { false }) + } + + @Test + fun `retrying a failed download with the slot free requeues it`() = runBlocking { + assertEquals(ResumeDecision.REQUEUE, decideResume(DownloadStatus.Failed) { true }) + } + + @Test + fun `a paused download the slot no longer favours is refused too`() = runBlocking { + assertEquals(ResumeDecision.BLOCKED_BY_FREE_SLOT, decideResume(DownloadStatus.Paused) { false }) + assertEquals(ResumeDecision.REQUEUE, decideResume(DownloadStatus.Paused) { true }) + } + + @Test + fun `other statuses are left alone without asking the slot`() = runBlocking { + for (status in listOf( + DownloadStatus.Queued, + DownloadStatus.Downloading, + DownloadStatus.Completed, + DownloadStatus.Cancelled, + DownloadStatus.Preparing, + )) { + assertEquals(ResumeDecision.IGNORE, decideResume(status) { error("slot asked for $status") }) + } + } + + @Test + fun `a refused retry tells the user why`() { + assertEquals(FreeTier.DOWNLOAD_SLOT_NOTICE, resumeNotice(DownloadManager.ResumeResult.BLOCKED_BY_FREE_SLOT)) + assertNull(resumeNotice(DownloadManager.ResumeResult.RESUMED)) + assertNull(resumeNotice(DownloadManager.ResumeResult.NOT_RESUMABLE)) + } +} From 1ab2cddfc7dbd03d234d17d94db4e0245628d7cc Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 3 Oct 2026 23:46:21 -0400 Subject: [PATCH 57/69] Covers follow the server address after a LAN and VPN switch Book rows keep an absolute cover URL with the host of whichever server address synced them. After switching between a LAN and a VPN address, every cover pointed at the old host. That host either does not answer on the new network or gets no sign-in token, since the token only goes to the current server, so the shelf turned to placeholders until a full resync. Every item cover request now goes to the current server, rebuilt from its address and the item id when the request is made. Existing rows work as they are with no resync and no migration. The width that list rows and Android Auto ask for is kept, and covers stay cached under the stored URL, so switching back and forth does not download them all again. The rule sits in the app's HTTP client, which the cover loader, Android Auto, and the notification artwork all share. Closes #74 Co-Authored-By: Claude Opus 5.5 --- .../data/remote/ServerCoverUrlInterceptor.kt | 58 ++++++++++++ .../ninelivesaudio/app/di/NetworkModule.kt | 4 + .../app/data/remote/ServerCoverUrlTest.kt | 90 +++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 app/src/main/java/com/ninelivesaudio/app/data/remote/ServerCoverUrlInterceptor.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/data/remote/ServerCoverUrlTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/ServerCoverUrlInterceptor.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/ServerCoverUrlInterceptor.kt new file mode 100644 index 00000000..3b67a7c2 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/ServerCoverUrlInterceptor.kt @@ -0,0 +1,58 @@ +package com.ninelivesaudio.app.data.remote + +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Sends every Audiobookshelf item cover request to the server the app is using + * right now, whatever host the stored URL names. + * + * Book rows keep an absolute cover URL from whichever server address synced + * them. Switching between a LAN and a VPN address left every cover pointing at + * the old host, which either does not answer on the new network or gets no + * sign-in token (the token only goes to the current server), so the whole + * shelf fell back to placeholders until a full resync rewrote the rows. The + * URL is now rebuilt from the current server and the item id at request time, + * so existing rows work as they are and no resync is needed. + * + * Covers stay cached under the stored URL, so a switch back and forth does not + * download the library's covers again. The query (the `width` that list rows + * and Android Auto ask for) is kept. + */ +class ServerCoverUrlInterceptor( + private val serverUrl: () -> String, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val server = validatedServerBaseUrl(serverUrl()) ?: return chain.proceed(request) + val target = currentServerCoverUrl(request.url, server) + if (target == null || target == request.url) return chain.proceed(request) + return chain.proceed(request.newBuilder().url(target).build()) + } +} + +/** + * [url] moved onto [server] when it is an item cover (`.../api/items/{id}/cover`), + * with the server's own base path and the original query. Null for anything + * else: audio files, other API calls, other paths. + */ +internal fun currentServerCoverUrl(url: HttpUrl, server: HttpUrl): HttpUrl? { + val segments = url.encodedPathSegments + val n = segments.size + if (n < 4) return null + if (segments[n - 4] != "api" || segments[n - 3] != "items" || segments[n - 1] != "cover") return null + val itemId = segments[n - 2].takeIf { it.isNotEmpty() } ?: return null + + val builder = HttpUrl.Builder() + .scheme(server.scheme) + .host(server.host) + .port(server.port) + server.encodedPathSegments.filter { it.isNotEmpty() }.forEach { builder.addEncodedPathSegment(it) } + builder.addEncodedPathSegment("api") + .addEncodedPathSegment("items") + .addEncodedPathSegment(itemId) + .addEncodedPathSegment("cover") + .encodedQuery(url.encodedQuery) + return builder.build() +} diff --git a/app/src/main/java/com/ninelivesaudio/app/di/NetworkModule.kt b/app/src/main/java/com/ninelivesaudio/app/di/NetworkModule.kt index bafa7fa0..66bea297 100644 --- a/app/src/main/java/com/ninelivesaudio/app/di/NetworkModule.kt +++ b/app/src/main/java/com/ninelivesaudio/app/di/NetworkModule.kt @@ -5,6 +5,7 @@ import com.ninelivesaudio.app.BuildConfig import com.ninelivesaudio.app.data.remote.AudiobookshelfApi import com.ninelivesaudio.app.data.remote.AuthInterceptor import com.ninelivesaudio.app.data.remote.DynamicBaseUrlInterceptor +import com.ninelivesaudio.app.data.remote.ServerCoverUrlInterceptor import com.ninelivesaudio.app.data.remote.SelfSignedCertTrustManager.configureSelfSignedCerts import com.ninelivesaudio.app.service.SettingsManager import dagger.Module @@ -42,6 +43,9 @@ object NetworkModule { ): OkHttpClient { val builder = OkHttpClient.Builder() .addInterceptor(dynamicBaseUrlInterceptor) + // After the base URL step, before auth: a cover stored with an old + // server address goes to the current one and gets its token. + .addInterceptor(ServerCoverUrlInterceptor { settingsManager.currentSettings.serverUrl }) .addNetworkInterceptor(authInterceptor) // Only log HTTP requests in debug builds to prevent token/URL leakage in production diff --git a/app/src/test/java/com/ninelivesaudio/app/data/remote/ServerCoverUrlTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/remote/ServerCoverUrlTest.kt new file mode 100644 index 00000000..1edc0995 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/remote/ServerCoverUrlTest.kt @@ -0,0 +1,90 @@ +package com.ninelivesaudio.app.data.remote + +import com.ninelivesaudio.app.ui.components.THUMBNAIL_COVER_WIDTH_PX +import com.ninelivesaudio.app.ui.components.thumbnailCoverUrl +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Book rows store absolute cover URLs with the syncing server's host. After a + * switch between a LAN and a VPN address every cover pointed at the old host + * and failed until a full resync. Cover requests now go to the current server. + */ +class ServerCoverUrlTest { + + private var server = "https://vpn.example/abs" + + private fun sent(url: String): String { + val client = OkHttpClient.Builder() + .addInterceptor(DynamicBaseUrlInterceptor { server }) + .addInterceptor(ServerCoverUrlInterceptor { server }) + .addInterceptor { chain -> + Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1) + .code(200).message("fixture").body("".toResponseBody()).build() + }.build() + return client.newCall(Request.Builder().url(url).build()).execute().use { it.request.url.toString() } + } + + @Test fun `a cover stored with the LAN address goes to the current VPN address`() { + assertEquals( + "https://vpn.example/abs/api/items/li_1/cover", + sent("http://192.168.1.5:13378/api/items/li_1/cover"), + ) + } + + @Test fun `switching back to the LAN address follows too`() { + server = "http://192.168.1.5:13378" + assertEquals( + "http://192.168.1.5:13378/api/items/li_1/cover", + sent("https://vpn.example/abs/api/items/li_1/cover"), + ) + } + + @Test fun `the list row width survives the move`() { + val stored = thumbnailCoverUrl("http://192.168.1.5:13378/api/items/li_1/cover", THUMBNAIL_COVER_WIDTH_PX) + assertEquals("https://vpn.example/abs/api/items/li_1/cover?width=240", sent(stored)) + } + + @Test fun `the Android Auto width survives the move`() { + assertEquals( + "https://vpn.example/abs/api/items/li_1/cover?width=256", + sent("http://192.168.1.5:13378/api/items/li_1/cover?width=256"), + ) + } + + @Test fun `an encoded item id stays encoded`() { + assertEquals( + "https://vpn.example/abs/api/items/a%20b/cover", + sent("http://192.168.1.5:13378/api/items/a%20b/cover"), + ) + } + + @Test fun `a cover already on the current server is untouched`() { + val url = "https://vpn.example/abs/api/items/li_1/cover?width=240" + assertEquals(url, sent(url)) + } + + @Test fun `the retrofit cover call still routes through the placeholder`() { + assertEquals("https://vpn.example/abs/api/items/li_1/cover", sent("http://localhost/api/items/li_1/cover")) + } + + @Test fun `audio files and other calls are never rewritten`() { + listOf( + "http://192.168.1.5:13378/api/items/li_1/file/3", + "http://192.168.1.5:13378/api/items/li_1", + "http://192.168.1.5:13378/cover", + "https://images.example/covers/li_1.jpg", + ).forEach { assertEquals(it, sent(it)) } + } + + @Test fun `no configured server leaves the request alone`() { + server = "" + val url = "http://192.168.1.5:13378/api/items/li_1/cover" + assertEquals(url, sent(url)) + } +} From 8e85ba02bb3b2d76b533772eb5d42c4e394b9716 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 00:06:57 -0400 Subject: [PATCH 58/69] A canceled Dossier load can no longer overwrite the newer report Switching periods cancels the old load, but cancelling does not stop the aggregation it is already running on a background thread. That work used to finish and publish its totals straight from there, so a slow 30 day report could land on top of the All Time report while the All Time chip stayed selected. A refund or downgrade that resets the period could leave the same mismatch. Each load now takes a generation number. The aggregation hands its result back to the main thread, which drops it if the load was cancelled or a newer load has started. The error message follows the same rule. Co-Authored-By: Claude Opus 5.5 --- .../ui/dossier/NightwatchDossierViewModel.kt | 50 ++++++++-- .../ui/dossier/DossierSupersededLoadTest.kt | 93 +++++++++++++++++++ 2 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/dossier/DossierSupersededLoadTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierViewModel.kt index d3bae8f1..46e8fc17 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierViewModel.kt @@ -295,6 +295,10 @@ class NightwatchDossierViewModel @Inject constructor( private var loadJob: Job? = null + // Bumped by every load, read only on the main thread. A load publishes + // only while it is still the newest one. + private var loadGeneration = 0L + fun onPeriodChanged(period: DossierPeriod) { // Clamped on the way in as well as in the load, so a locked period // never lands in state even briefly. The load-side clamp is the one @@ -309,6 +313,7 @@ class NightwatchDossierViewModel @Inject constructor( // Cancel any in-flight load so rapid period switches cannot let a slower // earlier load finish last and overwrite the newest selection with stale data. loadJob?.cancel() + val generation = ++loadGeneration loadJob = viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } @@ -367,9 +372,15 @@ class NightwatchDossierViewModel @Inject constructor( val allBooks = bookMap.values // The aggregation walks every session and book, so it runs on - // Default too. Publishing from there is safe, StateFlow updates - // are thread safe. - withContext(Dispatchers.Default) { + // Default too. It publishes back on the main thread, and only if + // no newer load has started. Cancelling does not stop CPU work + // already running, so a superseded load used to finish its + // totals and write them over the newer report. + publishIfStillCurrent( + dispatcher = Dispatchers.Default, + isCurrent = { generation == loadGeneration }, + publish = { apply -> _uiState.update(apply) }, + ) { val recentSessions = dossierSessionsInActiveScope(windowSessions, bookMap.keys) // Sanitize session durations: cap timeListening at wall-clock span @@ -449,8 +460,8 @@ class NightwatchDossierViewModel @Inject constructor( val genreWhisper = generateGenreWhisper(genreStats) val temporalWhisper = generateTemporalWhisper(peakHour, peakDay) - _uiState.update { - it.copy( + val report: (DossierState) -> DossierState = { state -> + state.copy( isLoading = false, isConnected = true, error = null, @@ -478,6 +489,7 @@ class NightwatchDossierViewModel @Inject constructor( temporalWhisper = temporalWhisper, ) } + report } } catch (e: CancellationException) { // A newer period switch cancelled this stale load. Rethrow so it @@ -485,11 +497,13 @@ class NightwatchDossierViewModel @Inject constructor( // cancelled load would clobber the state the newer load is building. throw e } catch (e: Exception) { - _uiState.update { - it.copy( - isLoading = false, - error = "Failed to compile the dossier: ${e.message}", - ) + if (generation == loadGeneration) { + _uiState.update { + it.copy( + isLoading = false, + error = "Failed to compile the dossier: ${e.message}", + ) + } } } } @@ -857,6 +871,22 @@ internal suspend fun loadDossierBooks( dossierBooksInActiveScope(fetchByIds(libraryId, isLocal, ids), settings).associateBy { it.id } } +/** + * Run [compute] on [dispatcher], then hand its result to [publish] back on + * the caller's context, but only while [isCurrent] still says this load is + * the newest one. Coming back to the caller also rethrows a cancellation that + * landed while [compute] ran, so a cancelled load never reaches [publish]. + */ +internal suspend fun publishIfStillCurrent( + dispatcher: CoroutineDispatcher, + isCurrent: () -> Boolean, + publish: (T) -> Unit, + compute: () -> T, +) { + val result = withContext(dispatcher) { compute() } + if (isCurrent()) publish(result) +} + internal fun dossierSessionsInActiveScope( sessions: List, scopedBookIds: Set, diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/dossier/DossierSupersededLoadTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/dossier/DossierSupersededLoadTest.kt new file mode 100644 index 00000000..a43ca429 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/dossier/DossierSupersededLoadTest.kt @@ -0,0 +1,93 @@ +package com.ninelivesaudio.app.ui.dossier + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * Switching periods cancels the old load, but cancelling does not stop the + * CPU work it is already doing. The old load used to finish its totals and + * publish them over the newer report, with the newer period still selected. + */ +class DossierSupersededLoadTest { + + private val executor = Executors.newSingleThreadExecutor() + private val aggregation = executor.asCoroutineDispatcher() + + @After + fun tearDown() { + executor.shutdownNow() + } + + @Test + fun `a load superseded while aggregating never publishes`() = runBlocking { + var newestLoad = 1L + val published = Collections.synchronizedList(mutableListOf()) + val aggregating = CountDownLatch(1) + val finishAggregating = CountDownLatch(1) + + val oldLoad = launch(start = CoroutineStart.UNDISPATCHED) { + val mine = 1L + publishIfStillCurrent( + dispatcher = aggregation, + isCurrent = { mine == newestLoad }, + publish = { published += it }, + ) { + aggregating.countDown() + finishAggregating.await() + "30 day totals" + } + } + assertTrue(aggregating.await(10, TimeUnit.SECONDS)) + + // What a period switch does: a newer load takes over and the old job + // is cancelled while its aggregation is still running. + newestLoad = 2L + oldLoad.cancel() + finishAggregating.countDown() + oldLoad.join() + withContext(aggregation) {} + + assertEquals(emptyList(), published.toList()) + } + + @Test + fun `a load superseded without being cancelled still never publishes`() = runBlocking { + var newestLoad = 1L + val published = mutableListOf() + + publishIfStillCurrent( + dispatcher = aggregation, + isCurrent = { newestLoad == 1L }, + publish = { published += it }, + ) { + newestLoad = 2L + "30 day totals" + } + + assertEquals(emptyList(), published) + } + + @Test + fun `the newest load publishes its report`() = runBlocking { + val published = mutableListOf() + + publishIfStillCurrent( + dispatcher = aggregation, + isCurrent = { true }, + publish = { published += it }, + ) { "all time totals" } + + assertEquals(listOf("all time totals"), published) + } +} From 24f5150faf6275106b107ad59cbfbff0237204d6 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 00:06:57 -0400 Subject: [PATCH 59/69] A delayed pause save can no longer overwrite a newer paused seek Pausing queues a save of the paused position. A seek made while paused (in the app, the notification, or Android Auto) saves too, and nothing ordered the two. If the seek's save won the race, the late pause save wrote the older position over it, and the next launch resumed from the wrong place. A paused seek now retires any pause save that has not run yet, using the same snapshot generation that resuming and loading already use. A pause save already writing still finishes first and the seek writes after it. Co-Authored-By: Claude Opus 5.5 --- .../app/service/PlaybackManager.kt | 43 +++++++++++++------ .../app/service/ProgressSyncGateTest.kt | 40 +++++++++++++++++ 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt index 5b3f3142..4a0f7dcb 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt @@ -312,6 +312,27 @@ internal class PlaybackProgressOwner { } } +/** + * Save a seek made while paused. A pause save still waiting for the book's + * lock captured the position from before this seek, so it is dropped first, + * on the caller's thread, before this save is queued. Otherwise a delayed + * pause save could land after this one and put the old position back. + */ +internal fun PlaybackProgressOwner.launchPausedSeekSave( + scope: CoroutineScope, + bookId: String, + save: suspend () -> Unit, +): Job { + invalidateSnapshots(bookId) + return scope.launch { + try { + report(bookId) { save() } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) {} + } +} + internal suspend fun PlaybackProgressOwner.resolveAndSavePlaybackPosition( bookId: String, candidate: Duration, @@ -2662,20 +2683,14 @@ class PlaybackManager @Inject constructor( if (player.mediaItemCount == 0) return val position = getCurrentPosition() val duration = _duration.value - scope.launch { - try { - playbackProgressOwner.report(book.id) { - syncManager.reportPlaybackPosition( - itemId = book.id, - currentTime = position.toDouble(kotlin.time.DurationUnit.SECONDS), - duration = duration.toDouble(kotlin.time.DurationUnit.SECONDS), - isFinished = false, - force = true, - ) - } - } catch (cancellation: CancellationException) { - throw cancellation - } catch (_: Exception) {} + playbackProgressOwner.launchPausedSeekSave(scope, book.id) { + syncManager.reportPlaybackPosition( + itemId = book.id, + currentTime = position.toDouble(kotlin.time.DurationUnit.SECONDS), + duration = duration.toDouble(kotlin.time.DurationUnit.SECONDS), + isFinished = false, + force = true, + ) } } diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ProgressSyncGateTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ProgressSyncGateTest.kt index 8c888b25..b125c4f9 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/ProgressSyncGateTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/ProgressSyncGateTest.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -208,6 +209,45 @@ class PlaybackProgressOwnerTest { terminal.join() bookB.join() } + + @Test + fun `a delayed pause save cannot overwrite a newer paused seek`() = runBlocking { + val owner = PlaybackProgressOwner() + var saved: Duration? = null + + // Paused at 100 s. The pause save is queued but has not reached the lock. + val pauseToken = owner.snapshotToken("book-a") + // A seek to 50 s while still paused, and its save lands first. + owner.launchPausedSeekSave(this, "book-a") { saved = 50.seconds }.join() + // The delayed pause save finally runs. + owner.syncSnapshot(pauseToken) { saved = 100.seconds } + + assertEquals(50.seconds, saved) + } + + @Test + fun `a pause save already writing finishes before the paused seek saves`() = runBlocking { + val owner = PlaybackProgressOwner() + val writes = mutableListOf() + val pauseEntered = CompletableDeferred() + val releasePause = CompletableDeferred() + + val pauseToken = owner.snapshotToken("book-a") + val pause = launch { + owner.syncSnapshot(pauseToken) { + pauseEntered.complete(Unit) + releasePause.await() + writes += 100.seconds + } + } + pauseEntered.await() + val seek = owner.launchPausedSeekSave(this, "book-a") { writes += 50.seconds } + releasePause.complete(Unit) + pause.join() + seek.join() + + assertEquals(listOf(100.seconds, 50.seconds), writes) + } } class PendingTerminalOwnerTest { From efd370ff16f4b6fcc369e5f8db7f60d5d3fcb0aa Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 00:14:21 -0400 Subject: [PATCH 60/69] A bookmark change can no longer be undone by a fetch already in flight On an older server the app reads bookmarks out of /api/me and keeps that list for five minutes. Adding or removing a bookmark clears the list, and a fetch that started before the change is not kept. But the clear and the "is this fetch still current" check were not ordered. A change could land right after the check passed and right before the list was stored, so the old list went back in and the refresh after the change showed it for five minutes. The clear, the check, and the store now share one lock, so a list fetched before a change can never be stored after it. Co-Authored-By: Claude Opus 5.5 --- .../app/data/remote/BookmarkFetch.kt | 31 +++++++++++++------ .../app/data/remote/BookmarkFetchTest.kt | 22 +++++++++++++ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/BookmarkFetch.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/BookmarkFetch.kt index e545661e..7e3a2e59 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/remote/BookmarkFetch.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/BookmarkFetch.kt @@ -32,7 +32,12 @@ internal class MeBookmarksCache( private val now: () -> Long = System::currentTimeMillis, ) { private val mutex = Mutex() - @Volatile private var invalidations = 0L + + // Guards the fields below. [invalidate] takes it without waiting on + // [mutex], and the staleness check and the store happen inside one hold of + // it, so a list fetched before a bookmark change can never be kept after it. + private val lock = Any() + private var invalidations = 0L private var cachedKey: String? = null private var cachedAtMs = 0L private var cached: List? = null @@ -41,24 +46,30 @@ internal class MeBookmarksCache( sessionKey: String, fetch: suspend () -> RemoteResult>, ): RemoteResult> = mutex.withLock { - val hit = cached - if (hit != null && cachedKey == sessionKey && now() - cachedAtMs < ttlMs) { - return@withLock RemoteResult.Ok(hit) + val startedAt = synchronized(lock) { + val hit = cached + if (hit != null && cachedKey == sessionKey && now() - cachedAtMs < ttlMs) { + return@withLock RemoteResult.Ok(hit) + } + invalidations } - val startedAt = invalidations val result = fetch() // A bookmark added or removed while this fetch was in flight makes the // answer stale, so it is handed back once but not kept. - if (result is RemoteResult.Ok && startedAt == invalidations) { - cachedKey = sessionKey - cachedAtMs = now() - cached = result.value + if (result is RemoteResult.Ok) { + synchronized(lock) { + if (startedAt == invalidations) { + cachedKey = sessionKey + cachedAtMs = now() + cached = result.value + } + } } result } /** Drops the cached list, after a bookmark is added or removed. */ - fun invalidate() { + fun invalidate() = synchronized(lock) { invalidations++ cached = null } diff --git a/app/src/test/java/com/ninelivesaudio/app/data/remote/BookmarkFetchTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/remote/BookmarkFetchTest.kt index faf0914c..2b487b00 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/remote/BookmarkFetchTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/remote/BookmarkFetchTest.kt @@ -82,6 +82,28 @@ class BookmarkFetchTest { assertEquals(2, meCalls) } + @Test + fun `a change landing between the staleness check and the store is not cached`() = runBlocking { + // The store reads the clock right after the staleness check passes. + // A bookmark change from another thread lands at exactly that moment. + lateinit var racing: MeBookmarksCache + var change: Thread? = null + racing = MeBookmarksCache(ttlMs = 1_000L, now = { + if (change == null) { + change = Thread { racing.invalidate() }.also { + it.start() + it.join(300L) + } + } + clock + }) + loadItemBookmarks("book-a", "s1", racing, { ItemBookmarksOutcome.Unsupported }, ::allFromMe) + assertTrue(change != null) + change?.join() + loadItemBookmarks("book-a", "s1", racing, { ItemBookmarksOutcome.Unsupported }, ::allFromMe) + assertEquals(2, meCalls) + } + @Test fun `a failed per-book call is a failure, not an empty list`() = runBlocking { val result = load("book-a", item = ItemBookmarksOutcome.Failed("HTTP 500 loading bookmarks")) From b6134054065a59b547e62e43e03acb1b340e8c1a Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 00:17:23 -0400 Subject: [PATCH 61/69] A paused seek no longer throws away the listening time saved on pause Pausing saves the position and the listening time since the last heartbeat. Since the last fix, a seek made while paused dropped any pause save that had not run yet so it could not put the old position back. But that dropped the listening time with it. History came up short while paused, and if the app was killed before playing again, that time was gone. A paused seek now retires only the pause save's position. The pause save still records its listening time, against the seek's position instead of its own older one. Resuming or loading another book still retires the whole save, as before. Co-Authored-By: Claude Opus 5.5 --- .../app/service/PlaybackManager.kt | 70 +++++++++++++++---- .../app/service/ProgressSyncGateTest.kt | 22 ++++++ 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt index 4a0f7dcb..88ce7097 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt @@ -264,8 +264,15 @@ internal class PlaybackProgressOwner { private val ownerLock = Any() private val mutexes = mutableMapOf() private val snapshotGenerations = mutableMapOf() - - data class SnapshotToken(val bookId: String, val generation: Long) + // Bumped by a paused seek. A snapshot taken before it holds an older + // position, but its listening time is still good. + private val positionGenerations = mutableMapOf() + + data class SnapshotToken( + val bookId: String, + val generation: Long, + val positionGeneration: Long = 0L, + ) private fun mutexFor(bookId: String): Mutex = synchronized(ownerLock) { mutexes.getOrPut(bookId, ::Mutex) @@ -274,9 +281,21 @@ internal class PlaybackProgressOwner { fun snapshotToken(bookId: String): SnapshotToken = synchronized(ownerLock) { val generation = (snapshotGenerations[bookId] ?: 0L) + 1L snapshotGenerations[bookId] = generation - SnapshotToken(bookId, generation) + SnapshotToken(bookId, generation, positionGenerations[bookId] ?: 0L) } + fun invalidateSnapshotPositions(bookId: String) { + synchronized(ownerLock) { + positionGenerations[bookId] = (positionGenerations[bookId] ?: 0L) + 1L + } + } + + private fun isCurrent(token: SnapshotToken): Boolean = + token.generation == snapshotGenerations[token.bookId] + + private fun positionIsCurrent(token: SnapshotToken): Boolean = + token.positionGeneration == (positionGenerations[token.bookId] ?: 0L) + fun invalidateSnapshots(bookId: String) { synchronized(ownerLock) { snapshotGenerations[bookId] = (snapshotGenerations[bookId] ?: 0L) + 1L @@ -290,11 +309,23 @@ internal class PlaybackProgressOwner { suspend fun sync(bookId: String, block: suspend () -> T): T = mutexFor(bookId).withLock { block() } - suspend fun syncSnapshot(token: SnapshotToken, block: suspend () -> Unit) { + /** + * Runs [block] if nothing has retired [token]. If only a paused seek has + * (its position is older than the seek's), [onPositionSuperseded] runs + * instead, so the snapshot's listening time is still saved without its + * old position. + */ + suspend fun syncSnapshot( + token: SnapshotToken, + onPositionSuperseded: (suspend () -> Unit)? = null, + block: suspend () -> Unit, + ) { mutexFor(token.bookId).withLock { - if (synchronized(ownerLock) { token.generation == snapshotGenerations[token.bookId] }) { - block() + val (current, positionCurrent) = synchronized(ownerLock) { + isCurrent(token) to positionIsCurrent(token) } + if (!current) return@withLock + if (positionCurrent) block() else onPositionSuperseded?.invoke() } } @@ -304,7 +335,7 @@ internal class PlaybackProgressOwner { flushProgress: suspend () -> Unit, ) { mutexFor(token.bookId).withLock { - if (synchronized(ownerLock) { token.generation == snapshotGenerations[token.bookId] }) { + if (synchronized(ownerLock) { isCurrent(token) && positionIsCurrent(token) }) { flushProgress() syncTerminal() } @@ -314,16 +345,17 @@ internal class PlaybackProgressOwner { /** * Save a seek made while paused. A pause save still waiting for the book's - * lock captured the position from before this seek, so it is dropped first, - * on the caller's thread, before this save is queued. Otherwise a delayed - * pause save could land after this one and put the old position back. + * lock captured the position from before this seek, so its position write is + * retired first, on the caller's thread, before this save is queued. + * Otherwise a delayed pause save could land after this one and put the old + * position back. Its listening time still gets saved. */ internal fun PlaybackProgressOwner.launchPausedSeekSave( scope: CoroutineScope, bookId: String, save: suspend () -> Unit, ): Job { - invalidateSnapshots(bookId) + invalidateSnapshotPositions(bookId) return scope.launch { try { report(bookId) { save() } @@ -1045,7 +1077,17 @@ class PlaybackManager @Inject constructor( if (snapshot != null) { val snapshotToken = playbackProgressOwner.snapshotToken(snapshot.bookId) scope.launch(Dispatchers.IO) { - playbackProgressOwner.syncSnapshot(snapshotToken) { + playbackProgressOwner.syncSnapshot( + token = snapshotToken, + // A paused seek landed first. Save the listening + // time against the seek's position, never this + // snapshot's older one. + onPositionSuperseded = { + latestPausedSeek?.takeIf { it.first == snapshot.bookId }?.let { (_, position) -> + syncPlaybackProgress(snapshot.copy(position = position)) + } + }, + ) { syncPlaybackProgress(snapshot) } } @@ -1131,6 +1173,9 @@ class PlaybackManager @Inject constructor( private var currentLocalSessionId: Long? = null private var localSessionAccumSec: Double = 0.0 private var pendingPauseSnapshot: PlaybackProgressSnapshot? = null + // The book and position of the last seek made while paused, read by a + // pause save that the seek overtook. + @Volatile private var latestPausedSeek: Pair? = null // Cap to ignore long gaps (background, doze) between heartbeats. 60s ≫ the 12s normal interval. private val localSessionMaxTickSec: Double = 60.0 @@ -2683,6 +2728,7 @@ class PlaybackManager @Inject constructor( if (player.mediaItemCount == 0) return val position = getCurrentPosition() val duration = _duration.value + latestPausedSeek = book.id to position playbackProgressOwner.launchPausedSeekSave(scope, book.id) { syncManager.reportPlaybackPosition( itemId = book.id, diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ProgressSyncGateTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ProgressSyncGateTest.kt index b125c4f9..138e824b 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/ProgressSyncGateTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/ProgressSyncGateTest.kt @@ -225,6 +225,28 @@ class PlaybackProgressOwnerTest { assertEquals(50.seconds, saved) } + @Test + fun `a paused seek retires the pause save's position but keeps its listening time`() = runBlocking { + val owner = PlaybackProgressOwner() + val effects = mutableListOf() + + // Paused at 100 s with listening time to record. The save is queued. + val pauseToken = owner.snapshotToken("book-a") + owner.launchPausedSeekSave(this, "book-a") { effects += "seek-position" }.join() + owner.syncSnapshot(pauseToken, onPositionSuperseded = { effects += "pause-listening" }) { + effects += "pause-position" + } + // Resuming retires the whole snapshot, listening time included, since + // playback carries that time forward itself. + val resumedToken = owner.snapshotToken("book-a") + owner.invalidateSnapshots("book-a") + owner.syncSnapshot(resumedToken, onPositionSuperseded = { effects += "stale-listening" }) { + effects += "stale-position" + } + + assertEquals(listOf("seek-position", "pause-listening"), effects) + } + @Test fun `a pause save already writing finishes before the paused seek saves`() = runBlocking { val owner = PlaybackProgressOwner() From 48a8c6ccb5350fcdff88024935729fa0ac419205 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 00:21:42 -0400 Subject: [PATCH 62/69] A pause save at the very end keeps the book finished Review round 3: a pause save overtaken by a paused seek saves the seek's position, but it always wrote the book as unfinished, so a seek to the last second while paused was undone. Every local position save now uses the seek path's rule: within 1 second of the end counts as finished. Co-Authored-By: Claude Opus 5.5 --- .../app/service/PlaybackManager.kt | 10 ++++-- .../ninelivesaudio/app/service/SyncManager.kt | 15 ++++++-- .../service/PositionCountsAsFinishedTest.kt | 34 +++++++++++++++++++ 3 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/PositionCountsAsFinishedTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt index 88ce7097..e137042e 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt @@ -3079,11 +3079,17 @@ class PlaybackManager @Inject constructor( ) private suspend fun syncPlaybackProgress(snapshot: PlaybackProgressSnapshot) { + // Same rule as a seek's save, so a pause save carrying a position at + // the very end keeps the book finished instead of undoing it. + val finished = positionCountsAsFinished( + currentTimeSec = snapshot.position.toDouble(kotlin.time.DurationUnit.SECONDS), + durationSec = snapshot.duration.toDouble(kotlin.time.DurationUnit.SECONDS), + ) val terminal = terminalPlaybackSnapshot( bookId = snapshot.bookId, position = snapshot.position, duration = snapshot.duration, - isFinished = false, + isFinished = finished, serverSessionId = snapshot.serverSessionId, timeListened = snapshot.serverTimeListened, serverListening = snapshot.serverListening, @@ -3098,7 +3104,7 @@ class PlaybackManager @Inject constructor( progressRepository.savePushOrEnqueueProgress( itemId = snapshot.bookId, currentTime = posSec, - isFinished = false, + isFinished = finished, duration = snapshot.duration.toDouble(kotlin.time.DurationUnit.SECONDS), pushToServer = false, onPersisted = { diff --git a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt index 8fff6311..e29c16a2 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt @@ -550,9 +550,7 @@ class SyncManager @Inject constructor( ) { val safeCurrentTime = currentTime.coerceAtLeast(0.0) val safeDuration = duration.coerceAtLeast(0.0) - // Only auto-mark as finished if position is within 1 second of the end. - // Exact >= comparison can fire prematurely during seeks near the end. - val computedFinished = isFinished || (safeDuration > 0.0 && safeDuration - safeCurrentTime < 1.0) + val computedFinished = isFinished || positionCountsAsFinished(safeCurrentTime, safeDuration) val isLocalMode = settingsManager.currentSettings.appMode == AppMode.LOCAL val now = System.currentTimeMillis() val shouldSync = !isLocalMode && shouldPushPlaybackPosition( @@ -1096,3 +1094,14 @@ internal suspend fun readAndWriteShelfProgressIfCurrent( write(current) return true } + +/** + * A saved position within 1 second of the end counts as finished. Exact >= + * comparison can fire prematurely during seeks near the end. Every local + * position save uses this one rule, so a later save of the same position + * never marks the book unfinished again. + */ +internal fun positionCountsAsFinished(currentTimeSec: Double, durationSec: Double): Boolean { + val safeDuration = durationSec.coerceAtLeast(0.0) + return safeDuration > 0.0 && safeDuration - currentTimeSec.coerceAtLeast(0.0) < 1.0 +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/PositionCountsAsFinishedTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/PositionCountsAsFinishedTest.kt new file mode 100644 index 00000000..193d3cd4 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/PositionCountsAsFinishedTest.kt @@ -0,0 +1,34 @@ +package com.ninelivesaudio.app.service + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A pause save overtaken by a paused seek saves the seek's position. If that + * position sits at the very end, the seek's own save marked the book finished, + * and the pause save must apply the same rule or it marks the book unfinished. + */ +class PositionCountsAsFinishedTest { + + @Test + fun `within one second of the end counts as finished`() { + assertTrue(positionCountsAsFinished(currentTimeSec = 3599.6, durationSec = 3600.0)) + } + + @Test + fun `one second or more from the end does not`() { + assertFalse(positionCountsAsFinished(currentTimeSec = 3599.0, durationSec = 3600.0)) + assertFalse(positionCountsAsFinished(currentTimeSec = 50.0, durationSec = 3600.0)) + } + + @Test + fun `an unknown duration never counts as finished`() { + assertFalse(positionCountsAsFinished(currentTimeSec = 0.0, durationSec = 0.0)) + } + + @Test + fun `a position past the end still counts as finished`() { + assertTrue(positionCountsAsFinished(currentTimeSec = 3605.0, durationSec = 3600.0)) + } +} From 2bb2e45d945f73d21b218e81a94b037fe536977c Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 00:33:52 -0400 Subject: [PATCH 63/69] An oversized Android Auto page no longer skips books past the cap Astra review: a page size over 500 capped the limit but not the offset, so page 1 started at 1000 and books 501 to 1000 were never listed. An oversized page is now read as one capped list and its later pages are empty. Books past the cap stay reachable through search (#71 tracks letter folders). Co-Authored-By: Claude Opus 5.5 --- .../com/ninelivesaudio/app/service/AutoBookItem.kt | 10 ++++++++-- .../ninelivesaudio/app/service/AutoBrowsePageTest.kt | 8 ++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) 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 254549c2..4c52f8d4 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt @@ -67,10 +67,16 @@ internal suspend fun autoBrowsePage( ): List { val libraryId = settings.activeLibraryId ?: return emptyList() if (page < 0 || pageSize <= 0) return emptyList() + // A page bigger than the cap is read as one capped list. Capping the + // limit but not the offset would skip the books between the cap and the + // next page start, so later pages of an oversized size are empty instead. + if (pageSize > AUTO_BROWSE_MAX_PAGE_SIZE) { + if (page > 0) return emptyList() + return load(libraryId, settings.appMode == AppMode.LOCAL, AUTO_BROWSE_MAX_PAGE_SIZE, 0) + } val offset = page.toLong() * pageSize.toLong() if (offset > Int.MAX_VALUE) return emptyList() - val limit = minOf(pageSize, AUTO_BROWSE_MAX_PAGE_SIZE) - return load(libraryId, settings.appMode == AppMode.LOCAL, limit, offset.toInt()) + return load(libraryId, settings.appMode == AppMode.LOCAL, pageSize, offset.toInt()) } /** diff --git a/app/src/test/java/com/ninelivesaudio/app/service/AutoBrowsePageTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/AutoBrowsePageTest.kt index b4b62442..5acd9ee7 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/AutoBrowsePageTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/AutoBrowsePageTest.kt @@ -53,6 +53,14 @@ class AutoBrowsePageTest { ) } + @Test + fun `an oversized page never skips books past the cap`() = runBlocking { + // Capping the limit but not the offset used to read books 1 to 500 + // then 1001 to 1500, so 501 to 1000 were never listed. + assertEquals(Ask("lib", false, AUTO_BROWSE_MAX_PAGE_SIZE, 0), ask(server, page = 0, pageSize = 1000)) + assertNull(ask(server, page = 1, pageSize = 1000)) + } + @Test fun `nonsense paging reads nothing`() = runBlocking { assertNull(ask(server, page = -1, pageSize = 50)) From 1c28532beb77e4e31453977ea978d9b4d73b2e96 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 01:50:00 -0400 Subject: [PATCH 64/69] Coming back to the app re-reads the network instead of staying Offline A network change while the app sat in the background (Wi-Fi off and on, or Wi-Fi marked metered) left it stuck on "Offline" when reopened, with a streaming user's Library flipped to "Nothing Downloaded". Nothing healed it until another network change or a manual reconnect tap. Android blocks a backgrounded app's network, so the old Wi-Fi's onLost saw no default network and marked the app offline, and coming back never looked again. Every foreground entry now re-reads the network from the OS before the sync's entry check runs, probes the server when it finds the network again, and re-reads a few more times over 7 seconds if the OS still says no network (Android lifts the block a moment after the app is visible). The unblock callback re-reads too. An onLost no longer decides alone: online is worked out from every network the callbacks still know about plus the OS default. An entry check skipped because the app read offline runs once the server shows as connected. Nothing pings in the background. Co-Authored-By: Claude Opus 5.5 --- .../com/ninelivesaudio/app/NineLivesApp.kt | 4 +- .../app/service/ConnectivityMonitor.kt | 268 +++++++++++++++--- .../ninelivesaudio/app/service/SyncManager.kt | 36 ++- .../service/ForegroundNetworkRecoveryTest.kt | 115 ++++++++ 4 files changed, 379 insertions(+), 44 deletions(-) create mode 100644 app/src/test/java/com/ninelivesaudio/app/service/ForegroundNetworkRecoveryTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt b/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt index 3dd99b25..0851b0c5 100644 --- a/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt +++ b/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt @@ -136,8 +136,10 @@ class NineLivesApp : Application(), ImageLoaderFactory { ProcessLifecycleOwner.get().lifecycle.addObserver( object : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) { - syncManager.setAppForeground(true) + // Connectivity first: it re-reads the real network state, + // which the sync's entry check then reads. connectivityMonitor.setAppForeground(true) + syncManager.setAppForeground(true) } override fun onStop(owner: LifecycleOwner) { syncManager.setAppForeground(false) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt index a78b8efc..d62a1f06 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt @@ -5,6 +5,7 @@ import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest +import android.os.SystemClock import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.* @@ -19,6 +20,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.OkHttpClient import okhttp3.Request +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @@ -103,47 +105,115 @@ class ConnectivityMonitor @Inject constructor( // ─── Network Callback ───────────────────────────────────────────────── + // Every network the callbacks have reported, and whether it carries + // internet. The online flag is re-derived from all of them plus the OS + // default network, so one onLost (often for the old network after the + // new one is already up) cannot mark the app offline on its own. + private val knownNetworks = ConcurrentHashMap() + + // True once startMonitoring ran, so a foreground entry before settings + // load does not probe a blank server URL. + @Volatile private var monitoring = false + + // Process-level background start (SystemClock.elapsedRealtime), 0 before + // the first trip to the background. + @Volatile private var processBackgroundedAt: Long = 0L + + // Foreground-only OS re-reads while the network still reads as down + // right after the app comes back (see FOREGROUND_SETTLE_REREADS_MS). + private var settleJob: Job? = null + private val settleJobLock = Any() + private val networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { - refreshIsMetered() - _isOnline.value = true - updateConnectionStatus() + knownNetworks[network] = true + reevaluateNetwork() // Check server reachability on reconnect (deduplicated) launchReachabilityCheck() } override fun onLost(network: Network) { - refreshIsMetered() - // Check if we still have any active network - val activeNetwork = connectivityManager.activeNetwork - val capabilities = activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) } - val stillConnected = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true - - if (!stillConnected) { - _isOnline.value = false - _isServerReachable.value = false - updateConnectionStatus() - } + knownNetworks.remove(network) + // Re-derive from every network still known plus the OS default. + // A backgrounded app can be network-blocked, so the OS default + // reads null here even with Wi-Fi up. Coming back to the + // foreground re-reads it (reevaluateOnForeground). + reevaluateNetwork() } override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { // Fires when Wi-Fi is marked metered or not, too. - refreshIsMetered() - val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - if (_isOnline.value != hasInternet) { - _isOnline.value = hasInternet - updateConnectionStatus() - if (hasInternet) { - launchReachabilityCheck() - } + knownNetworks[network] = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + val (wasOnline, online) = reevaluateNetwork() + if (online && !wasOnline) launchReachabilityCheck() + } + + override fun onBlockedStatusChanged(network: Network, blocked: Boolean) { + // Android blocks a backgrounded app's network and unblocks it when + // the app returns. The unblock is the moment the OS default + // network becomes readable again. + if (blocked) return + val (wasOnline, online) = reevaluateNetwork() + if (online && (!wasOnline || !_isServerReachable.value)) { + launchReachabilityCheck() + } + } + } + + /** + * Re-derives [isOnline] from the OS default network and every network the + * callbacks know about, and returns (online before, online now). Going + * offline also clears server reachability. No network request. + */ + private fun reevaluateNetwork(): Pair = synchronized(knownNetworks) { + val active = try { + connectivityManager.activeNetwork + } catch (_: Exception) { + null + } + val activeHasInternet = active?.let { hasInternet(it) } == true + // The default network is known too, so its replayed onAvailable at + // registration does not count as a new network. + if (active != null && activeHasInternet) knownNetworks[active] = true + val online = networkStateSaysOnline( + defaultHasInternet = activeHasInternet, + knownNetworksHaveInternet = knownNetworks.values, + ) + val wasOnline = _isOnline.value + _isOnline.value = online + if (!online) _isServerReachable.value = false + refreshIsMetered() + updateConnectionStatus() + wasOnline to online + } + + private fun hasInternet(network: Network): Boolean = try { + connectivityManager.getNetworkCapabilities(network) + ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + } catch (_: Exception) { + false + } + + /** + * Drops networks the OS no longer knows (an onLost that never arrived + * while the app was frozen), so a stale entry cannot keep the app + * "online" with no network at all. + */ + private fun pruneGoneNetworks() { + val gone = knownNetworks.keys.filter { network -> + try { + connectivityManager.getNetworkCapabilities(network) == null + } catch (_: Exception) { + true } } + gone.forEach { knownNetworks.remove(it) } } /** * Cancel any in-flight reachability check and start a fresh one. * Prevents unbounded concurrent server pings during network flaps - * (e.g., WiFi → cellular handoff firing onAvailable + onCapabilitiesChanged). + * (e.g., WiFi to cellular handoff firing onAvailable + onCapabilitiesChanged). */ private fun launchReachabilityCheck(): Job = synchronized(reachabilityJobLock) { @@ -169,6 +239,7 @@ class ConnectivityMonitor @Inject constructor( } // Initial state check + monitoring = true checkCurrentConnectivity() // Start the periodic server ping, foreground only: every 60 seconds, @@ -194,6 +265,8 @@ class ConnectivityMonitor @Inject constructor( } fun stopMonitoring() { + monitoring = false + cancelSettleRereads() try { connectivityManager.unregisterNetworkCallback(networkCallback) } catch (_: Exception) {} @@ -226,11 +299,8 @@ class ConnectivityMonitor @Inject constructor( * checkServerReachable() call is left as the tap's single /ping. */ fun refreshIsOnlineFromSystem() { - val activeNetwork = connectivityManager.activeNetwork - val capabilities = activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) } - _isOnline.value = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true - refreshIsMetered() - updateConnectionStatus() + pruneGoneNetworks() + reevaluateNetwork() } /** Re-reads the default network's metered state from the OS. */ @@ -310,9 +380,67 @@ class ConnectivityMonitor @Inject constructor( * The app came to the foreground (true) or left it (false), from the * process lifecycle so it covers every activity together. The periodic * ping runs only in the foreground. + * + * Coming back re-reads the real network state first. Android blocks a + * backgrounded app's network, so a network change while it was away can + * leave [isOnline] false with Wi-Fi fine, and nothing else would ever + * re-read it (no ping runs in the background, and the ping loop cannot + * get past an offline flag). Call this before anything that reads + * [isOnline] on entry, such as SyncManager's entry check. */ fun setAppForeground(inForeground: Boolean) { - appInForeground.value = inForeground + if (inForeground) { + val backgroundedAt = processBackgroundedAt + val backgroundForMs = + if (backgroundedAt == 0L) null else SystemClock.elapsedRealtime() - backgroundedAt + appInForeground.value = true + reevaluateOnForeground(backgroundForMs) + } else { + processBackgroundedAt = SystemClock.elapsedRealtime() + appInForeground.value = false + cancelSettleRereads() + } + } + + /** + * Foreground entry: re-derive connectivity from the OS, probe the server + * when [foregroundEntryProbes] says so, and keep re-reading for a few + * seconds while the OS still reports no network (the unblock can land + * just after the app is visible). + */ + private fun reevaluateOnForeground(backgroundForMs: Long?) { + if (!monitoring) return + pruneGoneNetworks() + val (wasOnline, online) = reevaluateNetwork() + Log.d(TAG, "foreground: background=${backgroundForMs}ms online $wasOnline -> $online") + if (foregroundEntryProbes(wasOnline, online, _isServerReachable.value, backgroundForMs)) { + launchReachabilityCheck() + } + if (!online) startSettleRereads() + } + + private fun startSettleRereads() { + synchronized(settleJobLock) { + settleJob?.cancel() + settleJob = scope.launch { + rereadUntilOnline(FOREGROUND_SETTLE_REREADS_MS) { + if (!appInForeground.value) return@rereadUntilOnline true + val (wasOnline, online) = reevaluateNetwork() + if (online && !wasOnline) { + Log.d(TAG, "foreground settle: network readable again") + launchReachabilityCheck() + } + online + } + } + } + } + + private fun cancelSettleRereads() { + synchronized(settleJobLock) { + settleJob?.cancel() + settleJob = null + } } /** @@ -327,11 +455,11 @@ class ConnectivityMonitor @Inject constructor( * Called when the app returns to the foreground (Activity.onStart). * * After device sleep or extended background, TCP connections in OkHttp's - * pool are often dead but not yet detected — making all API calls fail - * until the pool cycles. Fix: evict idle connections immediately, then - * force a server reachability check so the rest of the app knows the - * connection state within seconds. Nothing pings while the app is in the - * background, so this check is what refreshes a stale state. + * pool are often dead but not yet detected, making all API calls fail + * until the pool cycles. Fix: evict idle connections immediately. The + * reachability check that follows runs from [setAppForeground], which + * the process lifecycle calls just after this, once the network state + * has been re-read. */ fun onAppForegrounded() { // Never backgrounded yet (first foreground after cold start): there are @@ -343,7 +471,7 @@ class ConnectivityMonitor @Inject constructor( val elapsed = System.currentTimeMillis() - backgroundedAt if (elapsed < MIN_BACKGROUND_DURATION_MS) return - Log.d(TAG, "onAppForegrounded: background=${elapsed}ms — evicting stale connections") + Log.d(TAG, "onAppForegrounded: background=${elapsed}ms, evicting stale connections") // Kill stale TCP connections so the next request opens a fresh socket try { @@ -352,9 +480,6 @@ class ConnectivityMonitor @Inject constructor( Log.w(TAG, "onAppForegrounded: evictAll failed: ${e.message}") } - // Force immediate server check (the periodic ping was off in the background) - launchReachabilityCheck() - // Notify observers (PlaybackManager) that we're back from background _appResumedFromBackground.tryEmit(Unit) } @@ -472,3 +597,70 @@ internal class ReachabilityCheckGate( suspend fun run(): Boolean = mutex.withLock { check() } } + +/** + * Whether the app has a network, from the OS default network and every + * network the callbacks still know about. A single onLost used to decide this + * from the default network alone, which reads null for a backgrounded app + * Android has network-blocked, so the old Wi-Fi's onLost marked the app + * offline with the new Wi-Fi already up. INTERNET, not VALIDATED: a home + * server on a LAN with no route to the internet never validates. + */ +internal fun networkStateSaysOnline( + defaultHasInternet: Boolean, + knownNetworksHaveInternet: Collection, +): Boolean = defaultHasInternet || knownNetworksHaveInternet.any { it } + +/** Minimum background time before a foreground entry re-probes a server that was answering. */ +internal const val FOREGROUND_REPROBE_AFTER_MS = 5_000L + +/** + * Whether a foreground entry probes the server, after the network state has + * just been re-read from the OS. + * + * - No network: nothing to probe. + * - The re-read found a network the stale state had missed: probe, this is + * the stuck "Offline" coming unstuck. + * - The server is not marked reachable: probe, so the screen stops lying. + * - First entry after a cold start ([backgroundForMs] null): startMonitoring + * already probes. + * - Otherwise only after a real trip to the background, not a quick flip. + */ +internal fun foregroundEntryProbes( + wasOnline: Boolean, + isOnline: Boolean, + isServerReachable: Boolean, + backgroundForMs: Long?, +): Boolean = when { + !isOnline -> false + !wasOnline -> true + !isServerReachable -> true + backgroundForMs == null -> false + else -> backgroundForMs >= FOREGROUND_REPROBE_AFTER_MS +} + +/** + * When a foreground entry still reads no network, how long after it to read + * the OS state again. Android lifts its background network block a moment + * after the app is visible. Reads only (no request), foreground only, and it + * stops at the first one that finds a network. + */ +internal val FOREGROUND_SETTLE_REREADS_MS = listOf(1_000L, 2_000L, 4_000L) + +/** + * Waits out each delay in [delaysMs] and calls [reread], stopping as soon as + * it returns true. Returns how many rereads ran. + */ +internal suspend fun rereadUntilOnline( + delaysMs: List, + sleep: suspend (Long) -> Unit = { delay(it) }, + reread: () -> Boolean, +): Int { + var reads = 0 + for (wait in delaysMs) { + sleep(wait) + reads += 1 + if (reread()) break + } + return reads +} diff --git a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt index e29c16a2..ead64bc9 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt @@ -80,6 +80,10 @@ class SyncManager @Inject constructor( // debounce. In memory on purpose: a cold start should always check. @Volatile private var lastCheckAtMs: Long? = null + // A foreground entry or timer check found the app not ready (offline) + // and ran nothing. Cleared by the next check that runs. + @Volatile private var missedCheckWhileNotReady = false + // Set from the process lifecycle (NineLivesApp). The timer only runs // while this is true. private val appInForeground = MutableStateFlow(false) @@ -192,10 +196,11 @@ class SyncManager @Inject constructor( ) { status, settings -> isServerSessionLive(status, settings.appMode) } .distinctUntilChanged() .collect { live -> - if (live && appInForeground.value && - isCheckDue(lastCheckAtMs, monotonicNowMs()) && - shouldResyncOnServerReturn( - settingsManager.currentSettings.lastSyncForCurrentServer()?.result, + if (live && shouldCheckOnServerReturn( + inForeground = appInForeground.value, + checkDue = isCheckDue(lastCheckAtMs, monotonicNowMs()), + missedCheckWhileNotReady = missedCheckWhileNotReady, + lastResult = settingsManager.currentSettings.lastSyncForCurrentServer()?.result, ) ) { checkNow() @@ -235,7 +240,11 @@ class SyncManager @Inject constructor( /** A timer tick or foreground entry: a check, unless one just ran, then the offline queue. */ private suspend fun runForegroundCheck() { - if (isCheckDue(lastCheckAtMs, monotonicNowMs())) checkNow() + if (isCheckDue(lastCheckAtMs, monotonicNowMs())) { + // Offline on entry (the network still coming back) runs nothing. + // The server-return listener above runs the check once connected. + if (checkNow().attempt == SyncAttempt.SKIPPED_NOT_READY) missedCheckWhileNotReady = true + } // Retry the offline queue too. The rising-edge flush only fires once // per reconnect, so a push that failed right after reconnect would // otherwise sit queued until the next full disconnect/reconnect cycle. @@ -290,6 +299,7 @@ class SyncManager @Inject constructor( _isSyncing.value = false connectivityMonitor.setSyncing(false) lastCheckAtMs = monotonicNowMs() + missedCheckWhileNotReady = false }, // Progress sync FIRST — this populates the home screen grid // immediately. Library sync runs after (heavier, fetches all @@ -755,6 +765,22 @@ internal fun isServerSessionLive( internal fun shouldResyncOnServerReturn(lastResult: SyncResult?): Boolean = lastResult != null && lastResult != SyncResult.SUCCESS +/** + * Whether the server coming back (status turning Connected) runs a check: + * foreground only, under the two-minute debounce, and only when the last + * attempt did not fully succeed or a foreground check was skipped because + * the app was offline at the time. The second case is the app coming back + * from the background reading "Offline" for a moment: its entry check ran + * nothing, and without this the shelf waited 15 minutes for the next timer. + */ +internal fun shouldCheckOnServerReturn( + inForeground: Boolean, + checkDue: Boolean, + missedCheckWhileNotReady: Boolean, + lastResult: SyncResult?, +): Boolean = inForeground && checkDue && + (missedCheckWhileNotReady || shouldResyncOnServerReturn(lastResult)) + internal fun shouldReconnectForModeTransition( previousMode: AppMode, newMode: AppMode, diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ForegroundNetworkRecoveryTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundNetworkRecoveryTest.kt new file mode 100644 index 00000000..2d931806 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundNetworkRecoveryTest.kt @@ -0,0 +1,115 @@ +package com.ninelivesaudio.app.service + +import com.ninelivesaudio.app.domain.model.SyncResult +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A network change while the app sat in the background (Android blocks its + * network there) used to leave it stuck "Offline" on return, with a streaming + * user's Library flipped to Downloaded only, until another network change or + * a manual reconnect tap. + */ +class ForegroundNetworkRecoveryTest { + + @Test + fun `an onLost for the old network does not mark the app offline while another is up`() { + // Backgrounded and network-blocked: the OS default reads null, but the + // new Wi-Fi the callbacks reported is still there. + assertTrue(networkStateSaysOnline(defaultHasInternet = false, knownNetworksHaveInternet = listOf(true))) + assertTrue(networkStateSaysOnline(defaultHasInternet = true, knownNetworksHaveInternet = emptyList())) + } + + @Test + fun `no network anywhere is offline`() { + assertFalse(networkStateSaysOnline(defaultHasInternet = false, knownNetworksHaveInternet = emptyList())) + assertFalse(networkStateSaysOnline(defaultHasInternet = false, knownNetworksHaveInternet = listOf(false))) + } + + @Test + fun `a foreground entry that finds the network again probes at once`() { + // The stuck case: stale offline flag, the re-read finds Wi-Fi. + assertTrue(foregroundEntryProbes(wasOnline = false, isOnline = true, isServerReachable = false, backgroundForMs = 70_000L)) + // Even after a quick flip. + assertTrue(foregroundEntryProbes(wasOnline = false, isOnline = true, isServerReachable = false, backgroundForMs = 1_000L)) + } + + @Test + fun `a foreground entry probes a server not marked reachable`() { + assertTrue(foregroundEntryProbes(wasOnline = true, isOnline = true, isServerReachable = false, backgroundForMs = 1_000L)) + } + + @Test + fun `a foreground entry with no network sends nothing`() { + assertFalse(foregroundEntryProbes(wasOnline = true, isOnline = false, isServerReachable = false, backgroundForMs = 70_000L)) + assertFalse(foregroundEntryProbes(wasOnline = false, isOnline = false, isServerReachable = false, backgroundForMs = 70_000L)) + } + + @Test + fun `a connected app re-probes only after a real trip to the background`() { + assertTrue(foregroundEntryProbes(wasOnline = true, isOnline = true, isServerReachable = true, backgroundForMs = FOREGROUND_REPROBE_AFTER_MS)) + assertFalse(foregroundEntryProbes(wasOnline = true, isOnline = true, isServerReachable = true, backgroundForMs = 1_000L)) + // Cold start: startMonitoring already probes. + assertFalse(foregroundEntryProbes(wasOnline = true, isOnline = true, isServerReachable = true, backgroundForMs = null)) + } + + @Test + fun `settle rereads stop at the first one that finds a network`() = runBlocking { + val waits = mutableListOf() + val answers = ArrayDeque(listOf(false, true, true)) + val reads = rereadUntilOnline(FOREGROUND_SETTLE_REREADS_MS, sleep = { waits += it }) { answers.removeFirst() } + assertEquals(2, reads) + assertEquals(FOREGROUND_SETTLE_REREADS_MS.take(2), waits) + } + + @Test + fun `settle rereads give up after the last delay`() = runBlocking { + val reads = rereadUntilOnline(FOREGROUND_SETTLE_REREADS_MS, sleep = {}) { false } + assertEquals(FOREGROUND_SETTLE_REREADS_MS.size, reads) + assertTrue("bounded, a few seconds at most", FOREGROUND_SETTLE_REREADS_MS.sum() <= 10_000L) + } + + @Test + fun `an entry check skipped while offline runs once the server is back`() { + assertTrue( + shouldCheckOnServerReturn( + inForeground = true, + checkDue = true, + missedCheckWhileNotReady = true, + lastResult = SyncResult.SUCCESS, + ), + ) + } + + @Test + fun `a server return after a clean sync and no missed check stays quiet`() { + assertFalse( + shouldCheckOnServerReturn( + inForeground = true, + checkDue = true, + missedCheckWhileNotReady = false, + lastResult = SyncResult.SUCCESS, + ), + ) + } + + @Test + fun `the missed check still honors the foreground rule and the debounce`() { + assertFalse( + shouldCheckOnServerReturn(inForeground = false, checkDue = true, missedCheckWhileNotReady = true, lastResult = SyncResult.SUCCESS), + ) + assertFalse( + shouldCheckOnServerReturn(inForeground = true, checkDue = false, missedCheckWhileNotReady = true, lastResult = SyncResult.SUCCESS), + ) + } + + @Test + fun `a failed last sync still resyncs on server return`() { + assertTrue( + shouldCheckOnServerReturn(inForeground = true, checkDue = true, missedCheckWhileNotReady = false, lastResult = SyncResult.FAILED), + ) + } +} From 3ef24c87c50517ffb21c90ee5a862b91744aa308 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 01:51:09 -0400 Subject: [PATCH 65/69] A server that stops answering is re-probed after 15 seconds, not 60 The periodic ping picked its delay before sleeping. When the server went unreachable (a 502 from a proxy in front of it) during a 60 second wait, the first re-probe still waited out that wait, 56 seconds in the evidence run, instead of the 15 second retry interval. The wait now restarts whenever the status changes the interval, so the first re-probe comes 15 seconds after the server is marked unreachable, and a server that answers again goes back to every 60 seconds. Still foreground only. Co-Authored-By: Claude Opus 5.5 --- .../app/service/ConnectivityMonitor.kt | 25 ++++++++--- .../app/service/ForegroundPingTest.kt | 45 ++++++++++++++++--- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt index d62a1f06..aee4d5c1 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt @@ -11,11 +11,14 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.OkHttpClient @@ -253,7 +256,12 @@ class ConnectivityMonitor @Inject constructor( pingJob = scope.launch { runForegroundPing( appInForeground = appInForeground, - nextDelayMs = { nextPingDelayMs(isOnline = _isOnline.value, isServerReachable = _isServerReachable.value) }, + // Restarts the wait whenever the status changes the interval, + // so a server that just went unreachable is re-probed 15 + // seconds later, not at the end of the 60 already running. + intervalMs = combine(_isOnline, _isServerReachable) { online, reachable -> + nextPingDelayMs(isOnline = online, isServerReachable = reachable) + }, // Route through launchReachabilityCheck so the periodic ping // shares the single-flight cancellation with the callback- and // foreground-driven checks. Calling checkServerReachable() @@ -563,18 +571,25 @@ internal suspend fun probeServerWithRetry( /** * The periodic ping loop. It runs only while [appInForeground] is true and * stops the moment it turns false, so nothing pings in the background. + * + * Each wait is [intervalMs]'s latest value, and a new value restarts the + * wait in progress. The loop used to pick its delay before sleeping, so a + * server that went unreachable during a 60 second wait was first re-probed + * up to 60 seconds later instead of 15. */ internal suspend fun runForegroundPing( appInForeground: StateFlow, - nextDelayMs: () -> Long, + intervalMs: Flow, sleep: suspend (Long) -> Unit = { delay(it) }, check: suspend () -> Unit, ) { appInForeground.collectLatest { inForeground -> if (!inForeground) return@collectLatest - while (true) { - sleep(nextDelayMs()) - check() + intervalMs.distinctUntilChanged().collectLatest { interval -> + while (true) { + sleep(interval) + check() + } } } } diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ForegroundPingTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundPingTest.kt index f833107a..3eefb984 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/ForegroundPingTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundPingTest.kt @@ -3,6 +3,7 @@ package com.ninelivesaudio.app.service import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield @@ -24,7 +25,7 @@ class ForegroundPingTest { val loop = launch(start = CoroutineStart.UNDISPATCHED) { runForegroundPing( appInForeground = foreground, - nextDelayMs = { PING_INTERVAL_MS }, + intervalMs = MutableStateFlow(PING_INTERVAL_MS), sleep = { ticks.receive() }, check = { checks += 1 }, ) @@ -62,19 +63,53 @@ class ForegroundPingTest { val foreground = MutableStateFlow(true) val ticks = Channel() val waits = mutableListOf() - var reachable = true + val reachable = MutableStateFlow(true) val loop = launch(start = CoroutineStart.UNDISPATCHED) { runForegroundPing( appInForeground = foreground, - nextDelayMs = { nextPingDelayMs(isOnline = true, isServerReachable = reachable) }, + intervalMs = reachable.map { nextPingDelayMs(isOnline = true, isServerReachable = it) }, sleep = { waits += it; ticks.receive() }, - check = { reachable = false }, + check = { reachable.value = false }, ) } settle() + assertEquals(listOf(PING_INTERVAL_MS), waits) ticks.send(Unit) settle() - assertEquals(listOf(PING_INTERVAL_MS, PING_RETRY_INTERVAL_MS), waits) + // The wait running after a failed check is the 15 s retry. A 60 s + // wait the status change cancels at once may show up before it. + assertEquals(PING_RETRY_INTERVAL_MS, waits.last()) + loop.cancel() + } + + @Test + fun `the server going unreachable mid-wait restarts the wait at the retry interval`() = runBlocking { + val foreground = MutableStateFlow(true) + val ticks = Channel() + val waits = mutableListOf() + var checks = 0 + val reachable = MutableStateFlow(true) + val loop = launch(start = CoroutineStart.UNDISPATCHED) { + runForegroundPing( + appInForeground = foreground, + intervalMs = reachable.map { nextPingDelayMs(isOnline = true, isServerReachable = it) }, + sleep = { waits += it; ticks.receive() }, + check = { checks += 1 }, + ) + } + settle() + assertEquals(listOf(PING_INTERVAL_MS), waits) + + // Another probe (a 502 from the foreground check) marks it unreachable + // while the 60 second wait is running. + reachable.value = false + settle() + assertEquals("the 60 s wait is dropped, not waited out", listOf(PING_INTERVAL_MS, PING_RETRY_INTERVAL_MS), waits) + assertEquals(0, checks) + + ticks.send(Unit) + settle() + assertEquals("the re-probe comes after the 15 s wait", 1, checks) loop.cancel() } From 362f189a2227d7bcf181dacc637505d569ad916f Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 01:54:00 -0400 Subject: [PATCH 66/69] Cold start sends one /ping, and the Library skips a check that just ran Cold start sent 3 to 4 /ping in about 1.5 seconds. Each caller (the monitor's first check, the default network's replayed callback, the sync's entry check, the Library, playback) cancelled the probe before it or queued behind it and pinged again. A probe that is already running for the same network now gets joined instead of cancelled, and an answer that finished under 3 seconds ago is shared with anyone asking about the same network and the same server. A network change, a new server, the reconnect tap and coming back to the app still get a probe of their own. Opening the Library also ran its own change check even when the app's check had just run, a second HEAD 73 seconds after the cold-start one. The Library now honors the same two minute debounce: inside it, opening the tab sends nothing and shows the saved shelf. Pull to refresh still goes to the server, and so does a library the app's check does not cover (podcasts, or nothing cached yet). Co-Authored-By: Claude Opus 5.5 --- .../data/repository/AudioBookRepository.kt | 4 + .../app/service/ConnectivityMonitor.kt | 118 +++++++++++++++--- .../ninelivesaudio/app/service/SyncManager.kt | 7 ++ .../app/ui/library/LibraryViewModel.kt | 41 +++++- .../app/service/ReachabilityCheckGateTest.kt | 60 +++++++++ .../app/ui/library/LibrarySyncGateTest.kt | 33 +++++ 6 files changed, 242 insertions(+), 21 deletions(-) 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 395c659e..3e327538 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 @@ -243,6 +243,10 @@ class AudioBookRepository @Inject constructor( }.itemCountResult() ?: RemoteResult.Failed("sync did not run") } + /** Whether this library has any server books cached, so a shelf exists to show. */ + internal suspend fun hasServerBooks(libraryId: String): Boolean = + audioBookDao.countServerBooksByLibrary(libraryId) > 0 + /** * The background check for one library: ask the server whether anything * was added or removed since the last good sync (one tiny request), and diff --git a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt index aee4d5c1..ea7b7ec0 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt @@ -25,6 +25,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton @@ -63,7 +64,10 @@ class ConnectivityMonitor @Inject constructor( // background checks also share reachabilityCheckGate, so probes cannot race. private var reachabilityJob: Job? = null private val reachabilityJobLock = Any() - private val reachabilityCheckGate = ReachabilityCheckGate { performServerReachabilityCheck() } + private val reachabilityCheckGate = ReachabilityCheckGate( + nowMs = { SystemClock.elapsedRealtime() }, + reuseWindowMs = PROBE_REUSE_WINDOW_MS, + ) { performServerReachabilityCheck() } // Track when the app went to background for debouncing foreground recovery @Volatile private var backgroundedAt: Long = 0L @@ -114,6 +118,12 @@ class ConnectivityMonitor @Inject constructor( // new one is already up) cannot mark the app offline on its own. private val knownNetworks = ConcurrentHashMap() + // Bumped whenever the path to the server may have changed: online + // flipped, a new network appeared, a known one was lost, or a caller + // asked for a fresh probe. A finished probe's answer is shared only + // within one generation (see ReachabilityCheckGate). + private val networkGeneration = AtomicLong(0L) + // True once startMonitoring ran, so a foreground entry before settings // load does not probe a blank server URL. @Volatile private var monitoring = false @@ -129,14 +139,15 @@ class ConnectivityMonitor @Inject constructor( private val networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { - knownNetworks[network] = true + val isNew = knownNetworks.put(network, true) == null + if (isNew) networkGeneration.incrementAndGet() reevaluateNetwork() // Check server reachability on reconnect (deduplicated) launchReachabilityCheck() } override fun onLost(network: Network) { - knownNetworks.remove(network) + if (knownNetworks.remove(network) != null) networkGeneration.incrementAndGet() // Re-derive from every network still known plus the OS default. // A backgrounded app can be network-blocked, so the OS default // reads null here even with Wi-Fi up. Coming back to the @@ -185,6 +196,7 @@ class ConnectivityMonitor @Inject constructor( val wasOnline = _isOnline.value _isOnline.value = online if (!online) _isServerReachable.value = false + if (online != wasOnline) networkGeneration.incrementAndGet() refreshIsMetered() updateConnectionStatus() wasOnline to online @@ -214,16 +226,35 @@ class ConnectivityMonitor @Inject constructor( } /** - * Cancel any in-flight reachability check and start a fresh one. - * Prevents unbounded concurrent server pings during network flaps - * (e.g., WiFi to cellular handoff firing onAvailable + onCapabilitiesChanged). + * Start a reachability check, or join the one already running for the + * same network generation. A check from an older generation is cancelled + * (its answer describes a path that may be gone). [fresh] starts a new + * generation first, for callers that must not reuse an earlier answer. + * Cold start used to send 3 to 4 /ping in about 1.5 seconds because each + * caller cancelled the one before and pinged again. */ - private fun launchReachabilityCheck(): Job = + private fun launchReachabilityCheck(fresh: Boolean = false): Job = synchronized(reachabilityJobLock) { - reachabilityJob?.cancel() - scope.launch { reachabilityCheckGate.run() }.also { reachabilityJob = it } + if (fresh) networkGeneration.incrementAndGet() + val generation = networkGeneration.get() + val running = reachabilityJob + if (running != null && running.isActive && reachabilityJobGeneration == generation) { + return@synchronized running + } + running?.cancel() + reachabilityJobGeneration = generation + val key = reuseKey() + scope.launch { reachabilityCheckGate.run(key) }.also { reachabilityJob = it } } + // The generation the running reachabilityJob belongs to. Guarded by reachabilityJobLock. + private var reachabilityJobGeneration = -1L + + private fun reuseKey(): ProbeReuseKey = ProbeReuseKey( + generation = networkGeneration.get(), + serverUrl = settingsManager.currentSettings.serverUrl.trim(), + ) + fun requestReachabilityCheck() { launchReachabilityCheck() } @@ -288,7 +319,10 @@ class ConnectivityMonitor @Inject constructor( // ─── Checks ─────────────────────────────────────────────────────────── private fun checkCurrentConnectivity() { - refreshIsOnlineFromSystem() + // No forced new generation here: the default network's onAvailable, + // replayed at registration, may already have started this probe. + pruneGoneNetworks() + reevaluateNetwork() // Initial server check (deduplicated) launchReachabilityCheck() @@ -304,11 +338,16 @@ class ConnectivityMonitor @Inject constructor( * Also called directly by the Home reconnect tap (no network request here, * just re-reading the OS state) so [SyncManager.syncNow]'s own shouldRunSync * pre-check sees a fresh flag instead of the stale one, and its own - * checkServerReachable() call is left as the tap's single /ping. + * checkServerReachable() call is left as the tap's single /ping. It + * starts a new network generation, so that /ping is never answered from + * an earlier probe's shared result. */ fun refreshIsOnlineFromSystem() { pruneGoneNetworks() reevaluateNetwork() + // An explicit re-read (the reconnect tap, a mode switch) wants a + // probe of its own, not one shared from just before. + networkGeneration.incrementAndGet() } /** Re-reads the default network's metered state from the OS. */ @@ -322,7 +361,11 @@ class ConnectivityMonitor @Inject constructor( return metered } - suspend fun checkServerReachable(): Boolean = reachabilityCheckGate.run() + /** + * The server's reachability, from a fresh /ping or from one that finished + * within [PROBE_REUSE_WINDOW_MS] on the same network and server. + */ + suspend fun checkServerReachable(): Boolean = reachabilityCheckGate.run(reuseKey()) suspend fun probeServerReachable(): Boolean = checkServerReachable() @@ -422,7 +465,7 @@ class ConnectivityMonitor @Inject constructor( val (wasOnline, online) = reevaluateNetwork() Log.d(TAG, "foreground: background=${backgroundForMs}ms online $wasOnline -> $online") if (foregroundEntryProbes(wasOnline, online, _isServerReachable.value, backgroundForMs)) { - launchReachabilityCheck() + launchReachabilityCheck(fresh = true) } if (!online) startSettleRereads() } @@ -436,7 +479,7 @@ class ConnectivityMonitor @Inject constructor( val (wasOnline, online) = reevaluateNetwork() if (online && !wasOnline) { Log.d(TAG, "foreground settle: network readable again") - launchReachabilityCheck() + launchReachabilityCheck(fresh = true) } online } @@ -605,12 +648,57 @@ internal fun serverAnswerMarksReachable(isOnline: Boolean, isServerReachable: Bo internal fun nextPingDelayMs(isOnline: Boolean, isServerReachable: Boolean): Long = if (isOnline && !isServerReachable) PING_RETRY_INTERVAL_MS else PING_INTERVAL_MS +/** Which probe answers can be shared: same network generation, same server. */ +internal data class ProbeReuseKey(val generation: Long, val serverUrl: String) + +/** + * How long a finished probe's answer is shared with callers asking about the + * same network and server. Cold start has the monitor, the sync's entry + * check, the Library and playback all asking within a second or two. + */ +internal const val PROBE_REUSE_WINDOW_MS = 3_000L + +/** + * Whether a probe that finished at [finishedAtMs] under [previousKey] answers + * a caller asking under [key] at [nowMs]. A null key never shares, and a + * clock reading before the finish (or the window passing) means probe again. + */ +internal fun canReuseProbe( + previousKey: Any?, + key: Any?, + finishedAtMs: Long, + nowMs: Long, + windowMs: Long, +): Boolean { + if (key == null || previousKey != key) return false + val age = nowMs - finishedAtMs + return age in 0 until windowMs +} + +/** + * Runs reachability checks one at a time. A caller whose [run] key matches + * a check that finished under [reuseWindowMs] ago (including one it waited + * on) gets that answer instead of a new /ping. + */ internal class ReachabilityCheckGate( + private val nowMs: () -> Long = { 0L }, + private val reuseWindowMs: Long = 0L, private val check: suspend () -> Boolean, ) { + private class Finished(val key: Any?, val atMs: Long, val reachable: Boolean) + private val mutex = Mutex() + private var last: Finished? = null // guarded by mutex - suspend fun run(): Boolean = mutex.withLock { check() } + suspend fun run(key: Any? = null): Boolean = mutex.withLock { + val previous = last + if (previous != null && canReuseProbe(previous.key, key, previous.atMs, nowMs(), reuseWindowMs)) { + return@withLock previous.reachable + } + val reachable = check() + last = Finished(key, nowMs(), reachable) + reachable + } } /** diff --git a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt index ead64bc9..ee90057c 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt @@ -238,6 +238,13 @@ class SyncManager @Inject constructor( // ─── Sync Operations ───────────────────────────────────────────────────── + /** + * Whether a change check is due under the shared two-minute debounce. The + * Library reads this so opening it right after the entry check (a cold + * start) does not send its own check again. + */ + internal fun isCheckDueNow(): Boolean = isCheckDue(lastCheckAtMs, monotonicNowMs()) + /** A timer tick or foreground entry: a check, unless one just ran, then the offline queue. */ private suspend fun runForegroundCheck() { if (isCheckDue(lastCheckAtMs, monotonicNowMs())) { 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 30af19e8..185cd6c1 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 @@ -29,6 +29,7 @@ import com.ninelivesaudio.app.service.SettingsManager import com.ninelivesaudio.app.service.SyncManager import com.ninelivesaudio.app.service.buildShelfSyncReport import com.ninelivesaudio.app.service.buildShelfSyncReportFromCount +import com.ninelivesaudio.app.service.isSyncedLibrary import com.ninelivesaudio.app.service.lastSyncForCurrentServer import com.ninelivesaudio.app.service.persistActiveLibrarySelection import com.ninelivesaudio.app.service.persistSyncOutcome @@ -622,11 +623,22 @@ class LibraryViewModel @Inject constructor( // Show the saved shelf before the /ping probe and the library list // fetch. A gone server costs a 5s probe per check, and the gate // runs them one after another (issue #53). - if (!isLocalMode) showSavedShelfBeforeNetwork(settings) + val saved = if (!isLocalMode) showSavedShelfBeforeNetwork(settings) else null + // The app's own change check ran under two minutes ago (the cold + // start or foreground entry check) and covered this shelf, so + // opening the Library sends nothing: no /ping, no library list, + // no change check. Pull to refresh still goes to the server. + val skipNetwork = !isLocalMode && saved != null && libraryLoadSkipsNetwork( + explicit = explicit, + checkDue = syncManager.isCheckDueNow(), + savedLibraryIsSynced = isSyncedLibrary(saved), + savedLibraryHasBooks = audioBookRepository.hasServerBooks(saved.id), + ) + if (skipNetwork) serverReachable = false val libs = if (isLocalMode) { libraryRepository.getLocalLibraries() } else { - if (shouldSyncOnLibraryLoad( + if (!skipNetwork && shouldSyncOnLibraryLoad( isLocalLibrary = false, isOnline = connectivityMonitor.isOnline.value, ) && connectivityMonitor.checkServerReachable().also { serverReachable = it } @@ -710,15 +722,15 @@ class LibraryViewModel @Inject constructor( /** * Publishes the cached shelf of the saved library selection, if it is - * cached. The load that follows still resolves and persists the real - * selection and replaces this shelf. + * cached, and returns that library. The load that follows still resolves + * and persists the real selection and replaces this shelf. */ - private suspend fun showSavedShelfBeforeNetwork(settings: AppSettings) { + private suspend fun showSavedShelfBeforeNetwork(settings: AppSettings): Library? { val cachedLibraries = visibleCachedLibraries( settings = settings, cached = libraryRepository.getAudiobookshelf(), ) - val saved = cachedLibraries.firstOrNull { it.id == settings.activeLibraryId } ?: return + val saved = cachedLibraries.firstOrNull { it.id == settings.activeLibraryId } ?: return null _uiState.update { it.withLibrarySelection( libraries = cachedLibraries, @@ -727,6 +739,7 @@ class LibraryViewModel @Inject constructor( ) } applyFilter()?.join() + return saved } /** @@ -1319,6 +1332,22 @@ internal fun selectedLibraryFetchSequence(outcome: PersistedSyncOutcome): Long? internal fun shouldSyncOnLibraryLoad(isLocalLibrary: Boolean, isOnline: Boolean): Boolean = !isLocalLibrary && isOnline +/** + * Whether opening the Library skips the server because the app's own change + * check ran under two minutes ago. It used to check again regardless, a + * second HEAD 73 seconds after the cold-start one. Only for a load the user + * did not ask for (pull to refresh and Retry always go out), and only for a + * saved library that check covers: a book library (podcast libraries are + * not checked in the background) with books already cached (a fresh + * sign-in or a new server has nothing to show yet). + */ +internal fun libraryLoadSkipsNetwork( + explicit: Boolean, + checkDue: Boolean, + savedLibraryIsSynced: Boolean, + savedLibraryHasBooks: Boolean, +): Boolean = !explicit && !checkDue && savedLibraryIsSynced && savedLibraryHasBooks + // ─── Grouping helpers (internal for testability) ────────────────────────── /** The shelf as shown: sorted books, the view mode in effect, and its groups. */ diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ReachabilityCheckGateTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ReachabilityCheckGateTest.kt index 72f343e9..4e570523 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/ReachabilityCheckGateTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/ReachabilityCheckGateTest.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -37,4 +38,63 @@ class ReachabilityCheckGateTest { assertTrue(second.await()) assertTrue(secondEntered.isCompleted) } + + @Test + fun `callers asking about the same network share a probe that just finished`() = runBlocking { + var now = 1_000L + var probes = 0 + val gate = ReachabilityCheckGate(nowMs = { now }, reuseWindowMs = PROBE_REUSE_WINDOW_MS) { probes += 1; true } + val key = ProbeReuseKey(generation = 3, serverUrl = "http://abs") + + assertTrue(gate.run(key)) + now += 1_500L + assertTrue(gate.run(key)) + now += 1_000L + assertTrue(gate.run(key)) + assertEquals("cold start's burst is one /ping", 1, probes) + } + + @Test + fun `a waiting caller gets the answer of the probe it waited on`() = runBlocking { + val firstEntered = CompletableDeferred() + val releaseFirst = CompletableDeferred() + var probes = 0 + val gate = ReachabilityCheckGate(nowMs = { 0L }, reuseWindowMs = PROBE_REUSE_WINDOW_MS) { + probes += 1 + firstEntered.complete(Unit) + releaseFirst.await() + false + } + val key = ProbeReuseKey(generation = 1, serverUrl = "http://abs") + val first = async(start = CoroutineStart.UNDISPATCHED) { gate.run(key) } + firstEntered.await() + val second = async(start = CoroutineStart.UNDISPATCHED) { gate.run(key) } + releaseFirst.complete(Unit) + assertFalse(first.await()) + assertFalse(second.await()) + assertEquals(1, probes) + } + + @Test + fun `a new network, another server, or an old answer means a new probe`() = runBlocking { + var now = 0L + var probes = 0 + val gate = ReachabilityCheckGate(nowMs = { now }, reuseWindowMs = PROBE_REUSE_WINDOW_MS) { probes += 1; true } + gate.run(ProbeReuseKey(1, "http://abs")) + gate.run(ProbeReuseKey(2, "http://abs")) + assertEquals("network changed", 2, probes) + gate.run(ProbeReuseKey(2, "http://other")) + assertEquals("server changed", 3, probes) + now += PROBE_REUSE_WINDOW_MS + gate.run(ProbeReuseKey(2, "http://other")) + assertEquals("window passed", 4, probes) + gate.run(null) + assertEquals("no key never shares", 5, probes) + } + + @Test + fun `a clock reading before the finish does not share`() { + assertFalse(canReuseProbe("k", "k", finishedAtMs = 5_000L, nowMs = 4_000L, windowMs = PROBE_REUSE_WINDOW_MS)) + assertTrue(canReuseProbe("k", "k", finishedAtMs = 5_000L, nowMs = 5_000L, windowMs = PROBE_REUSE_WINDOW_MS)) + } } diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySyncGateTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySyncGateTest.kt index 278c81ba..2d50134f 100644 --- a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySyncGateTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySyncGateTest.kt @@ -36,4 +36,37 @@ class LibrarySyncGateTest { fun `local library never syncs when offline`() { assertFalse(shouldSyncOnLibraryLoad(isLocalLibrary = true, isOnline = false)) } + + @Test + fun `opening the Library right after the app's own check sends nothing`() { + assertTrue( + libraryLoadSkipsNetwork(explicit = false, checkDue = false, savedLibraryIsSynced = true, savedLibraryHasBooks = true), + ) + } + + @Test + fun `opening the Library checks when the app's check is two minutes old or more`() { + assertFalse( + libraryLoadSkipsNetwork(explicit = false, checkDue = true, savedLibraryIsSynced = true, savedLibraryHasBooks = true), + ) + } + + @Test + fun `pull to refresh always goes to the server`() { + assertFalse( + libraryLoadSkipsNetwork(explicit = true, checkDue = false, savedLibraryIsSynced = true, savedLibraryHasBooks = true), + ) + } + + @Test + fun `a library the app's check does not cover still loads from the server`() { + // Podcast libraries are not checked in the background. + assertFalse( + libraryLoadSkipsNetwork(explicit = false, checkDue = false, savedLibraryIsSynced = false, savedLibraryHasBooks = true), + ) + // Nothing cached yet (fresh sign-in, new server). + assertFalse( + libraryLoadSkipsNetwork(explicit = false, checkDue = false, savedLibraryIsSynced = true, savedLibraryHasBooks = false), + ) + } } From d72004c11076722c43d7828fb255d6a51e91a49e Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 02:23:51 -0400 Subject: [PATCH 67/69] A VPN that went away no longer keeps the app online with no network The network callbacks never see a VPN (Android leaves VPNs out of a network request by default), but the OS default network was added to the list of known networks anyway. With Tailscale as the default, that entry stuck around after the VPN disconnected, because nothing could ever report it lost. Then turning off Wi-Fi and cell left the app showing "Server unreachable" instead of "Offline" until it was reopened. The default network now joins that list only when the callbacks can also report it lost, so a VPN never does. A connected VPN still counts as online while it is the default, since the default is read fresh every time, and the app goes offline once every real network is gone. Co-Authored-By: Claude Opus 5.5 --- .../app/service/ConnectivityMonitor.kt | 32 +++++++++++++++---- .../service/ForegroundNetworkRecoveryTest.kt | 22 +++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt index ea7b7ec0..7f3842ba 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt @@ -185,10 +185,19 @@ class ConnectivityMonitor @Inject constructor( } catch (_: Exception) { null } - val activeHasInternet = active?.let { hasInternet(it) } == true + val activeCaps = active?.let { capabilitiesOf(it) } + val activeHasInternet = + activeCaps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true // The default network is known too, so its replayed onAvailable at - // registration does not count as a new network. - if (active != null && activeHasInternet) knownNetworks[active] = true + // registration does not count as a new network. Only when the + // callbacks can also report it lost, though: they never see a VPN. + if (active != null && defaultNetworkIsRemembered( + hasInternet = activeHasInternet, + isVpn = activeCaps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) == false, + ) + ) { + knownNetworks[active] = true + } val online = networkStateSaysOnline( defaultHasInternet = activeHasInternet, knownNetworksHaveInternet = knownNetworks.values, @@ -202,11 +211,10 @@ class ConnectivityMonitor @Inject constructor( wasOnline to online } - private fun hasInternet(network: Network): Boolean = try { + private fun capabilitiesOf(network: Network): NetworkCapabilities? = try { connectivityManager.getNetworkCapabilities(network) - ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true } catch (_: Exception) { - false + null } /** @@ -714,6 +722,18 @@ internal fun networkStateSaysOnline( knownNetworksHaveInternet: Collection, ): Boolean = defaultHasInternet || knownNetworksHaveInternet.any { it } +/** + * Whether the OS default network goes into the known networks list. Only a + * network the callbacks can also report lost belongs there, and the callback + * request leaves out VPNs (NetworkRequest.Builder adds NOT_VPN by default). + * A Tailscale default used to be remembered, so after the VPN went away and + * then Wi-Fi and cell dropped, its stale entry kept the app "online" with no + * network at all. A connected VPN still counts while it is the default: the + * default network is read live on every re-evaluation. + */ +internal fun defaultNetworkIsRemembered(hasInternet: Boolean, isVpn: Boolean): Boolean = + hasInternet && !isVpn + /** Minimum background time before a foreground entry re-probes a server that was answering. */ internal const val FOREGROUND_REPROBE_AFTER_MS = 5_000L diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ForegroundNetworkRecoveryTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundNetworkRecoveryTest.kt index 2d931806..b7852212 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/ForegroundNetworkRecoveryTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundNetworkRecoveryTest.kt @@ -29,6 +29,28 @@ class ForegroundNetworkRecoveryTest { assertFalse(networkStateSaysOnline(defaultHasInternet = false, knownNetworksHaveInternet = listOf(false))) } + @Test + fun `a VPN that went away does not keep the app online after every real network drops`() { + // The callbacks never report a VPN, so they can never report it lost. + val known = mutableMapOf() + known["wifi"] = true // onAvailable + // Tailscale is the OS default while it runs. + if (defaultNetworkIsRemembered(hasInternet = true, isVpn = true)) known["tailscale"] = true + assertTrue( + "a connected VPN default still counts", + networkStateSaysOnline(defaultHasInternet = true, knownNetworksHaveInternet = known.values), + ) + // The VPN disconnects (no callback), then Wi-Fi drops (onLost). + known.remove("wifi") + assertFalse(networkStateSaysOnline(defaultHasInternet = false, knownNetworksHaveInternet = known.values)) + } + + @Test + fun `a real default network is remembered`() { + assertTrue(defaultNetworkIsRemembered(hasInternet = true, isVpn = false)) + assertFalse(defaultNetworkIsRemembered(hasInternet = false, isVpn = false)) + } + @Test fun `a foreground entry that finds the network again probes at once`() { // The stuck case: stale offline flag, the re-read finds Wi-Fi. From 70c703403b9c19fb9761f5b76b833571d5f61fc6 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 02:24:30 -0400 Subject: [PATCH 68/69] A playback push that lands clears a failed probe instead of being undone by it A failed /ping is shared for 3 seconds with anyone else asking. When a playback progress push succeeded inside that window, the app marked the server connected, and the reconnect listener's check then got the shared failure back, recorded the sync as failed, and skipped it. The status stayed connected, so no later ping made a new reconnect, and the shelf could wait 15 minutes for the next timer. Any request that reaches the server now stops a failed probe from being shared, including one that lands while that probe is still running. The next caller sends its own /ping. A successful probe is still shared. Co-Authored-By: Claude Opus 5.5 --- .../app/service/ConnectivityMonitor.kt | 28 ++++++++++-- .../app/service/ReachabilityCheckGateTest.kt | 45 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt index 7f3842ba..9895c16f 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt @@ -421,6 +421,10 @@ class ConnectivityMonitor @Inject constructor( * so there is no counterpart: only a probe marks the server unreachable. */ fun reportServerAnswered() { + // Even when already marked reachable: a probe that failed just + // before must not answer the next caller (the reconnect listener's + // check) with "unreachable" after the server just proved otherwise. + reachabilityCheckGate.serverAnswered() if (!serverAnswerMarksReachable(_isOnline.value, _isServerReachable.value)) return _isServerReachable.value = true updateConnectionStatus() @@ -686,25 +690,41 @@ internal fun canReuseProbe( /** * Runs reachability checks one at a time. A caller whose [run] key matches * a check that finished under [reuseWindowMs] ago (including one it waited - * on) gets that answer instead of a new /ping. + * on) gets that answer instead of a new /ping. A failed answer stops being + * shared once [serverAnswered] reports a real request that succeeded, even + * one that landed while that probe was still running. */ internal class ReachabilityCheckGate( private val nowMs: () -> Long = { 0L }, private val reuseWindowMs: Long = 0L, private val check: suspend () -> Boolean, ) { - private class Finished(val key: Any?, val atMs: Long, val reachable: Boolean) + private class Finished(val key: Any?, val atMs: Long, val reachable: Boolean, val answers: Long) private val mutex = Mutex() private var last: Finished? = null // guarded by mutex + // Counts successful server requests reported through serverAnswered. + private val answers = AtomicLong(0L) + + /** A real request to the server just succeeded. No suspension, no lock. */ + fun serverAnswered() { + answers.incrementAndGet() + } + suspend fun run(key: Any? = null): Boolean = mutex.withLock { val previous = last - if (previous != null && canReuseProbe(previous.key, key, previous.atMs, nowMs(), reuseWindowMs)) { + if (previous != null && + (previous.reachable || previous.answers == answers.get()) && + canReuseProbe(previous.key, key, previous.atMs, nowMs(), reuseWindowMs) + ) { return@withLock previous.reachable } + // Read before the probe, so an answer that lands mid-probe still + // keeps a failure from being shared. + val answersAtStart = answers.get() val reachable = check() - last = Finished(key, nowMs(), reachable) + last = Finished(key, nowMs(), reachable, answersAtStart) reachable } } diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ReachabilityCheckGateTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ReachabilityCheckGateTest.kt index 4e570523..93343da4 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/ReachabilityCheckGateTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/ReachabilityCheckGateTest.kt @@ -92,6 +92,51 @@ class ReachabilityCheckGateTest { assertEquals("no key never shares", 5, probes) } + @Test + fun `a server answer after a failed probe means the next caller probes again`() = runBlocking { + var probes = 0 + var answer = false + val gate = ReachabilityCheckGate(nowMs = { 0L }, reuseWindowMs = PROBE_REUSE_WINDOW_MS) { + probes += 1 + answer + } + val key = ProbeReuseKey(generation = 1, serverUrl = "http://abs") + assertFalse(gate.run(key)) + // A playback push lands inside the reuse window. + gate.serverAnswered() + answer = true + assertTrue("the failure is not shared after the server answered", gate.run(key)) + assertEquals(2, probes) + // A success is still shared. + assertTrue(gate.run(key)) + assertEquals(2, probes) + } + + @Test + fun `a server answer during a failing probe keeps that failure from being shared`() = runBlocking { + val entered = CompletableDeferred() + val release = CompletableDeferred() + var probes = 0 + val gate = ReachabilityCheckGate(nowMs = { 0L }, reuseWindowMs = PROBE_REUSE_WINDOW_MS) { + probes += 1 + if (probes == 1) { + entered.complete(Unit) + release.await() + false + } else { + true + } + } + val key = ProbeReuseKey(generation = 1, serverUrl = "http://abs") + val first = async(start = CoroutineStart.UNDISPATCHED) { gate.run(key) } + entered.await() + gate.serverAnswered() + release.complete(Unit) + assertFalse(first.await()) + assertTrue(gate.run(key)) + assertEquals(2, probes) + } + @Test fun `a clock reading before the finish does not share`() { assertFalse(canReuseProbe("k", "k", finishedAtMs = 5_000L, nowMs = 4_000L, windowMs = PROBE_REUSE_WINDOW_MS)) From 7c4ce91bdbfb70c70675c624d790fa251e748041 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 4 Oct 2026 02:25:41 -0400 Subject: [PATCH 69/69] A check skipped while offline still runs if the server came back first Coming back to the app, the entry check can read "Offline" and skip, and it flags the skip so the server-return listener runs the check once the status turns Connected. But the flag was set only after the skipped check returned. If the network came back in that gap, the listener had already handled the Connected change and found nothing to do (last sync clean, flag not set yet), and the late flag had no change left to wake it. The shelf then waited for the next 15 minute timer. The listener now watches the flag as well as the status, so a flag set while the server is already connected runs the check right away, under the same foreground and two minute rules as before. Co-Authored-By: Claude Opus 5.5 --- .../ninelivesaudio/app/service/SyncManager.kt | 61 ++++++++++++------ .../app/service/ServerReturnResyncTest.kt | 62 +++++++++++++++++++ 2 files changed, 103 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt index ee90057c..3df4fb18 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt @@ -81,8 +81,10 @@ class SyncManager @Inject constructor( @Volatile private var lastCheckAtMs: Long? = null // A foreground entry or timer check found the app not ready (offline) - // and ran nothing. Cleared by the next check that runs. - @Volatile private var missedCheckWhileNotReady = false + // and ran nothing. Cleared by the next check that runs. A flow so the + // server-return listener sees it even when it is set after the status + // already turned Connected. + private val missedCheckWhileNotReady = MutableStateFlow(false) // Set from the process lifecycle (NineLivesApp). The timer only runs // while this is true. @@ -190,22 +192,22 @@ class SyncManager @Inject constructor( // after a failure waits out the repository's backoff. // SYNCING counts as live so a sync's own status flip is not an edge. launch { - combine( - connectivityMonitor.connectionStatus, - settingsManager.settings, - ) { status, settings -> isServerSessionLive(status, settings.appMode) } - .distinctUntilChanged() - .collect { live -> - if (live && shouldCheckOnServerReturn( - inForeground = appInForeground.value, - checkDue = isCheckDue(lastCheckAtMs, monotonicNowMs()), - missedCheckWhileNotReady = missedCheckWhileNotReady, - lastResult = settingsManager.currentSettings.lastSyncForCurrentServer()?.result, - ) - ) { - checkNow() - } - } + runServerReturnChecks( + serverLive = combine( + connectivityMonitor.connectionStatus, + settingsManager.settings, + ) { status, settings -> isServerSessionLive(status, settings.appMode) }, + missedCheckWhileNotReady = missedCheckWhileNotReady, + shouldCheck = { missed -> + shouldCheckOnServerReturn( + inForeground = appInForeground.value, + checkDue = isCheckDue(lastCheckAtMs, monotonicNowMs()), + missedCheckWhileNotReady = missed, + lastResult = settingsManager.currentSettings.lastSyncForCurrentServer()?.result, + ) + }, + check = { checkNow() }, + ) } launch { @@ -250,7 +252,7 @@ class SyncManager @Inject constructor( if (isCheckDue(lastCheckAtMs, monotonicNowMs())) { // Offline on entry (the network still coming back) runs nothing. // The server-return listener above runs the check once connected. - if (checkNow().attempt == SyncAttempt.SKIPPED_NOT_READY) missedCheckWhileNotReady = true + if (checkNow().attempt == SyncAttempt.SKIPPED_NOT_READY) missedCheckWhileNotReady.value = true } // Retry the offline queue too. The rising-edge flush only fires once // per reconnect, so a push that failed right after reconnect would @@ -306,7 +308,7 @@ class SyncManager @Inject constructor( _isSyncing.value = false connectivityMonitor.setSyncing(false) lastCheckAtMs = monotonicNowMs() - missedCheckWhileNotReady = false + missedCheckWhileNotReady.value = false }, // Progress sync FIRST — this populates the home screen grid // immediately. Library sync runs after (heavier, fetches all @@ -788,6 +790,25 @@ internal fun shouldCheckOnServerReturn( ): Boolean = inForeground && checkDue && (missedCheckWhileNotReady || shouldResyncOnServerReturn(lastResult)) +/** + * The server-return listener. Runs [check] when the server turns live and + * [shouldCheck] agrees, and again when a skipped entry check sets the missed + * flag while the server is already live. The flag used to be read only on the + * live edge, so an entry check that read offline, then lost the race to the + * Connected status, set it with no edge left to consume it, and the shelf + * waited for the next timer. + */ +internal suspend fun runServerReturnChecks( + serverLive: Flow, + missedCheckWhileNotReady: Flow, + shouldCheck: (missed: Boolean) -> Boolean, + check: suspend () -> Unit, +) { + combine(serverLive, missedCheckWhileNotReady) { live, missed -> live to missed } + .distinctUntilChanged() + .collect { (live, missed) -> if (live && shouldCheck(missed)) check() } +} + internal fun shouldReconnectForModeTransition( previousMode: AppMode, newMode: AppMode, diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ServerReturnResyncTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ServerReturnResyncTest.kt index eaf16095..5a3a02b2 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/ServerReturnResyncTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/ServerReturnResyncTest.kt @@ -3,6 +3,11 @@ package com.ninelivesaudio.app.service import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.SyncResult import com.ninelivesaudio.app.service.ConnectivityMonitor.ConnectionStatus +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -10,6 +15,63 @@ import org.junit.Test class ServerReturnResyncTest { + // Lets the listener's combine pipeline catch up on the single runBlocking thread. + private suspend fun settle() = repeat(20) { yield() } + + @Test + fun `a missed check flagged after the server already turned Connected still runs`() = runBlocking { + // The entry check read offline, then the status turned Connected and + // the listener found nothing to do (last sync clean, flag not set yet). + val live = MutableStateFlow(false) + val missed = MutableStateFlow(false) + var checks = 0 + val listener = launch(start = CoroutineStart.UNDISPATCHED) { + runServerReturnChecks( + serverLive = live, + missedCheckWhileNotReady = missed, + shouldCheck = { flagged -> + shouldCheckOnServerReturn( + inForeground = true, + checkDue = checks == 0, + missedCheckWhileNotReady = flagged, + lastResult = SyncResult.SUCCESS, + ) + }, + check = { checks += 1 }, + ) + } + live.value = true + settle() + assertEquals("nothing to do on the edge itself", 0, checks) + // The skipped entry check returns and sets the flag. + missed.value = true + settle() + assertEquals(1, checks) + listener.cancel() + } + + @Test + fun `a missed check flagged while offline runs when the server returns`() = runBlocking { + val live = MutableStateFlow(false) + val missed = MutableStateFlow(false) + var checks = 0 + val listener = launch(start = CoroutineStart.UNDISPATCHED) { + runServerReturnChecks( + serverLive = live, + missedCheckWhileNotReady = missed, + shouldCheck = { flagged -> flagged }, + check = { checks += 1 }, + ) + } + missed.value = true + settle() + assertEquals("offline runs nothing", 0, checks) + live.value = true + settle() + assertEquals(1, checks) + listener.cancel() + } + @Test fun `a returning server resyncs after a failed or partial sync`() { assertTrue(shouldResyncOnServerReturn(SyncResult.FAILED))