diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/home/HomeViewModelStartupInstrumentedTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/home/HomeViewModelStartupInstrumentedTest.kt index ef889f4b..c88df092 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/ui/home/HomeViewModelStartupInstrumentedTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/home/HomeViewModelStartupInstrumentedTest.kt @@ -51,7 +51,7 @@ class HomeViewModelStartupInstrumentedTest { selectedLibraryId = FIXTURE_LIBRARY_ID, ), ) - fixtureSettings.saveAuthToken(token = "startup-fixture-token") + fixtureSettings.saveAuthToken(token = "startup-fixture-token", serverUrl = FIXTURE_SERVER_URL) instrumentation.runOnMainSync { viewModel = HomeViewModel( 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 new file mode 100644 index 00000000..aea81e05 --- /dev/null +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt @@ -0,0 +1,217 @@ +package com.ninelivesaudio.app.ui.library + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.ninelivesaudio.app.NineLivesApp +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.LibraryRepository +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.service.local.LocalFolderAccess +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * A first-run folder scan creates the folder's library row and imports its + * books only after the scan finishes. Opening the Library tab during the scan + * loaded an empty shelf, and in local mode nothing ever asked it to look + * again, so it said "The Archive Stands Empty" until the tab was re-entered. + * These drive the real [LibraryViewModel] against a real Room database and + * write rows the way the scan does, with the ViewModel already open. + */ +@RunWith(AndroidJUnit4::class) +class LibraryLocalCatalogLiveUpdateTest { + + private val app = InstrumentationRegistry.getInstrumentation() + .targetContext.applicationContext as NineLivesApp + private lateinit var database: AppDatabase + private val viewModels = ViewModelStore() + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder(app, AppDatabase::class.java).build() + } + + @After + fun tearDown() { + // Stop the ViewModel's collectors before the database they watch goes away. + InstrumentationRegistry.getInstrumentation().runOnMainSync { viewModels.clear() } + database.close() + } + + @Test + fun booksAFirstScanImportsWhileTheLibraryIsOpenAppearWithoutReenteringTheTab() = runBlocking { + withLocalMode { + val viewModel = openLibrary() + awaitState(viewModel, "the empty shelf opened mid-scan") { + !it.isLoading && it.libraries.isEmpty() && it.selectedLibrary == null + } + + // The scan's own order: the folder row once the folder reads, then the books. + database.libraryDao().upsert(folder) + database.audioBookDao().upsertAll((1..9).map { book("book-$it") }) + + val shown = awaitState(viewModel, "the nine scanned books") { + it.selectedLibrary?.id == folder.id && it.filteredBooks.size == 9 + } + assertEquals(9, shown.totalBookCount) + } + } + + @Test + fun aBookARescanAddsAppearsOnTheOpenShelf() = runBlocking { + database.libraryDao().upsert(folder) + database.audioBookDao().upsertAll(listOf(book("book-1"), book("book-2"))) + withLocalMode { + val viewModel = openLibrary() + awaitState(viewModel, "the two books already on the shelf") { + it.selectedLibrary?.id == folder.id && it.filteredBooks.size == 2 + } + + database.audioBookDao().upsert(book("book-3")) + + val shown = awaitState(viewModel, "the book the rescan added") { it.filteredBooks.size == 3 } + assertEquals(listOf("book-1", "book-2", "book-3"), shown.filteredBooks.map { it.id }.sorted()) + } + } + + @Test + fun aBookARescanArchivesLeavesTheOpenShelf() = runBlocking { + database.libraryDao().upsert(folder) + database.audioBookDao().upsertAll(listOf(book("book-1"), book("book-2"))) + withLocalMode { + val viewModel = openLibrary() + awaitState(viewModel, "the two books already on the shelf") { + it.selectedLibrary?.id == folder.id && it.filteredBooks.size == 2 + } + + database.audioBookDao().archiveByIds(listOf("book-2"), archivedAt = 1_727_000_000_000L) + + val shown = awaitState(viewModel, "the archived book to leave") { it.filteredBooks.size == 1 } + assertEquals(listOf("book-1"), shown.filteredBooks.map { it.id }) + } + } + + @Test + fun aRescanThatRetitlesABookUpdatesTheOpenShelf() = runBlocking { + database.libraryDao().upsert(folder) + database.audioBookDao().upsertAll(listOf(book("book-1"), book("book-2"))) + withLocalMode { + val viewModel = openLibrary() + awaitState(viewModel, "the two books already on the shelf") { + it.selectedLibrary?.id == folder.id && it.filteredBooks.size == 2 + } + + // Same file, same folder, new tags: only what the shelf shows changes. + database.audioBookDao().upsert(book("book-2").copy(title = "Retagged Title")) + + val shown = awaitState(viewModel, "the rescanned title") { state -> + state.filteredBooks.any { it.title == "Retagged Title" } + } + assertEquals(2, shown.filteredBooks.size) + } + } + + @Test + fun aRescanThatSplitsAChapterUpdatesTheOpenShelf() = runBlocking { + val oneChapter = """[{"Id":0,"Start":0.0,"End":600.0,"Title":"01"}]""" + val twoChapters = """[{"Id":0,"Start":0.0,"End":300.0,"Title":"01"},""" + + """{"Id":1,"Start":300.0,"End":600.0,"Title":"02"}]""" + database.libraryDao().upsert(folder) + database.audioBookDao().upsertAll(listOf(book("book-1").copy(durationSeconds = 600.0, chaptersJson = oneChapter))) + withLocalMode { + val viewModel = openLibrary() + awaitState(viewModel, "the one-chapter book on the shelf") { + it.filteredBooks.singleOrNull()?.chapters?.size == 1 + } + + // Same file length, new track layout: the card's "Ch 1/N" count changes. + database.audioBookDao().upsert(book("book-1").copy(durationSeconds = 600.0, chaptersJson = twoChapters)) + + awaitState(viewModel, "the split chapters") { it.filteredBooks.singleOrNull()?.chapters?.size == 2 } + } + } + + private fun openLibrary(): LibraryViewModel { + val audioBookRepository = AudioBookRepository( + context = app, + audioBookDao = database.audioBookDao(), + apiService = app.apiService, + localListeningSessionDao = database.localListeningSessionDao(), + localBookmarkDao = database.localBookmarkDao(), + playbackProgressDao = database.playbackProgressDao(), + database = database, + ) + val libraryRepository = LibraryRepository( + libraryDao = database.libraryDao(), + audioBookDao = database.audioBookDao(), + audioBookRepository = audioBookRepository, + apiService = app.apiService, + ) + val factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = LibraryViewModel( + libraryRepository = libraryRepository, + audioBookRepository = audioBookRepository, + apiService = app.apiService, + connectivityMonitor = app.connectivityMonitor, + settingsManager = app.settingsManager, + entitlements = app.entitlementRepository, + localFolderAccess = LocalFolderAccess(app), + ) as T + } + lateinit var viewModel: LibraryViewModel + InstrumentationRegistry.getInstrumentation().runOnMainSync { + viewModel = ViewModelProvider(viewModels, factory)[LibraryViewModel::class.java] + } + return viewModel + } + + private suspend fun withLocalMode(block: suspend () -> Unit) { + val originalSettings = app.settingsManager.currentSettings + app.settingsManager.saveSettings( + originalSettings.copy(appMode = AppMode.LOCAL, selectedLocalLibraryId = null) + ) + try { + block() + } finally { + app.settingsManager.saveSettings(originalSettings) + } + } + + private suspend fun awaitState( + viewModel: LibraryViewModel, + waitingFor: String, + predicate: (LibraryViewModel.UiState) -> Boolean, + ): LibraryViewModel.UiState = + withTimeoutOrNull(5_000) { viewModel.uiState.filter(predicate).first() } + ?: throw AssertionError( + "Timed out waiting for $waitingFor. Shelf shows " + + viewModel.uiState.value.filteredBooks.map { it.id } + + " from library " + viewModel.uiState.value.selectedLibrary?.id + ) + + private val folder = LibraryEntity(id = "audiobooks", name = "Audiobooks", isLocal = 1) + + private fun book(id: String) = AudioBookEntity( + id = id, + libraryId = folder.id, + isLocal = 1, + title = id, + author = "Author", + isDownloaded = 1, + ) +} 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 28459029..9884b77a 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 @@ -1,8 +1,10 @@ package com.ninelivesaudio.app.ui.library +import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.ninelivesaudio.app.NineLivesApp +import com.ninelivesaudio.app.data.local.AppDatabase import com.ninelivesaudio.app.data.local.dao.AudioBookDao import com.ninelivesaudio.app.data.local.dao.LibraryDao import com.ninelivesaudio.app.data.local.dao.LocalBookmarkDao @@ -10,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.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.LibraryRepository @@ -25,6 +28,7 @@ import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED import kotlinx.coroutines.runBlocking import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -96,10 +100,13 @@ class LibraryViewModelFilterRaceTest { selectedLocalLibraryId = FixtureLibraries.alpha.id, ) ) + // Only a transaction would touch it, and these tests never import. + val database = Room.inMemoryDatabaseBuilder(app, AppDatabase::class.java).build() try { - block(LibraryViewModelFixture(app)) + block(LibraryViewModelFixture(app, database)) } finally { app.settingsManager.saveSettings(originalSettings) + database.close() } } @@ -108,7 +115,7 @@ class LibraryViewModelFilterRaceTest { } } -private class LibraryViewModelFixture(app: NineLivesApp) { +private class LibraryViewModelFixture(app: NineLivesApp, database: AppDatabase) { val dao = DelayedLibraryFilterDao() private val audioBookRepository = AudioBookRepository( context = app, @@ -117,6 +124,7 @@ private class LibraryViewModelFixture(app: NineLivesApp) { localListeningSessionDao = emptyDao(), localBookmarkDao = emptyDao(), playbackProgressDao = emptyDao(), + database = database, ) private val libraryRepository = LibraryRepository( libraryDao = localLibraryDao(), @@ -157,6 +165,7 @@ private class DelayedLibraryFilterDao { "getFilteredBooks" -> filteredBooks(args) "countByLibrary" -> 2 "getDistinctSeries", "getDistinctAuthors", "getDistinctGenresJson" -> emptyList() + "observeLocalCatalog" -> flowOf(emptyList()) "toString" -> "DelayedLibraryFilterDao" "hashCode" -> System.identityHashCode(this) "equals" -> args?.singleOrNull() === this @@ -213,10 +222,8 @@ private class DelayedLibraryFilterDao { private fun localLibraryDao(): LibraryDao = proxy { method, args -> when (method.name) { - "getLocal" -> listOf( - LibraryEntity(id = FixtureLibraries.alpha.id, name = FixtureLibraries.alpha.name, isLocal = 1), - LibraryEntity(id = FixtureLibraries.beta.id, name = FixtureLibraries.beta.name, isLocal = 1), - ) + "getLocal" -> fixtureLibraryEntities() + "observeLocal" -> flowOf(fixtureLibraryEntities()) "toString" -> "FixtureLibraryDao" "hashCode" -> System.identityHashCode(method) "equals" -> args?.singleOrNull() === method @@ -224,6 +231,11 @@ private fun localLibraryDao(): LibraryDao = proxy { method, args -> } } +private fun fixtureLibraryEntities() = listOf( + LibraryEntity(id = FixtureLibraries.alpha.id, name = FixtureLibraries.alpha.name, isLocal = 1), + LibraryEntity(id = FixtureLibraries.beta.id, name = FixtureLibraries.beta.name, isLocal = 1), +) + private inline fun emptyDao(): T = proxy { method, _ -> error("Unexpected ${T::class.simpleName} call: ${method.name}") } 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 f0915868..2ca43ad0 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.LocalCatalogEntry import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.data.local.entity.RecentlyPlayedResult import kotlinx.coroutines.flow.Flow @@ -95,6 +96,25 @@ interface AudioBookDao { @Query("SELECT Id FROM AudioBooks WHERE LibraryId = :libraryId AND IsLocal = 1 AND ArchivedAt IS NULL") suspend fun getLiveLocalIdsByLibrary(libraryId: String): List + /** + * Every LOCAL book's library, archived flag, and shelf fields, re-emitted + * whenever the table changes. Progress columns stay out so a caller that + * drops repeats hears about imports, archives, and rescans, not position + * saves. + */ + @Query( + """ + SELECT Id AS id, LibraryId AS libraryId, ArchivedAt IS NOT NULL AS isArchived, + Title AS title, Author AS author, Narrator AS narrator, + CoverPath AS coverPath, LocalCoverPath AS localCoverPath, + DurationSeconds AS durationSeconds, AddedAt AS addedAt, + SeriesName AS seriesName, SeriesSequence AS seriesSequence, GenresJson AS genresJson, + length(ChaptersJson) AS chaptersJsonLength + FROM AudioBooks WHERE IsLocal = 1 + """ + ) + fun observeLocalCatalog(): Flow> + /** Ids of the archived (soft-deleted) LOCAL books in a library. */ @Query("SELECT Id FROM AudioBooks WHERE LibraryId = :libraryId AND IsLocal = 1 AND ArchivedAt IS NOT NULL") suspend fun getArchivedLocalIdsByLibrary(libraryId: String): List diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalCatalogEntry.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalCatalogEntry.kt new file mode 100644 index 00000000..4114b56f --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalCatalogEntry.kt @@ -0,0 +1,27 @@ +package com.ninelivesaudio.app.data.local.entity + +/** + * Result type for the local catalog query: which LOCAL book exists, in which + * library, whether it is archived, and the fields the Library shelf shows, + * searches, sorts, or groups on. Progress and position stay out, so playback + * saves leave it unchanged while a rescan that retags a book does not. + */ +data class LocalCatalogEntry( + val id: String, + val libraryId: String?, + val isArchived: Boolean, + val title: String = "", + val author: String? = null, + val narrator: String? = null, + val coverPath: String? = null, + val localCoverPath: String? = null, + val durationSeconds: Double = 0.0, + val addedAt: String? = null, + val seriesName: String? = null, + val seriesSequence: String? = null, + val genresJson: String? = null, + // The card shows "Ch N/M". The length stands in for the chapter list, which + // can be large, so a re-split track layout still changes the entry without + // reading every chapter blob on each playback save. + val chaptersJsonLength: Int? = 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 50c46498..f75ccc5b 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.LocalCatalogEntry import com.ninelivesaudio.app.data.remote.ApiService import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.domain.model.AudioBook @@ -54,6 +55,10 @@ class AudioBookRepository @Inject constructor( fun observeLocalBooks(): Flow> = audioBookDao.observeBySource(isLocal = 1).map { entities -> entities.map { it.toDomain() } } + /** Observe which local books exist, which are archived, and their shelf fields (not progress). */ + fun observeLocalCatalog(): Flow> = + audioBookDao.observeLocalCatalog() + /** Observe a single audiobook. */ fun observeById(id: String): Flow = audioBookDao.observeById(id).map { it?.toDomain() } 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 c2564a07..bbfafdf3 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 @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ninelivesaudio.app.entitlement.EntitlementRepository import com.ninelivesaudio.app.entitlement.FreeTier +import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry import com.ninelivesaudio.app.data.remote.ApiService import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.data.remote.describeFailure @@ -116,6 +117,67 @@ internal fun shouldReloadLibrariesAfterSync( ): Boolean = (selectedLibrary == null && cachedLibraryIds.isNotEmpty()) || cachedLibraryIds.toSet() != shownLibraryIds.toSet() +/** + * 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 + * during playback would re-query the shelf. + */ +internal data class LocalCatalogSnapshot( + val libraryIds: Set, + val books: Set, +) + +internal enum class LocalCatalogChange { NONE, REFILTER, RELOAD } + +/** + * A folder scan writes its library row and books from Settings, and LOCAL + * mode has no sync record to announce them, so a Library opened mid-scan + * stayed empty until the tab was re-entered. The ViewModel's very first + * snapshot is only a baseline, which is safe because every load waits for + * it before reading, so anything written after it arrives as a later + * snapshot. A watch that starts later (a switch into LOCAL mode) has no + * such guarantee, so its first snapshot is checked like any other. A + * changed library list (or libraries with nothing selected) reloads exactly + * as a sync does, and any other change re-filters the shelf. + */ +internal fun decideLocalCatalogChange( + previous: LocalCatalogSnapshot?, + current: LocalCatalogSnapshot, + selectedLibrary: Library?, + shownLibraryIds: List, + isLoadBaseline: Boolean, +): LocalCatalogChange = when { + previous == null && isLoadBaseline -> LocalCatalogChange.NONE + previous == current -> LocalCatalogChange.NONE + shouldReloadLibrariesAfterSync( + selectedLibrary = selectedLibrary, + shownLibraryIds = shownLibraryIds, + cachedLibraryIds = current.libraryIds.toList(), + ) -> LocalCatalogChange.RELOAD + else -> LocalCatalogChange.REFILTER +} + +/** + * The settings a catalog reload saves for a library the user picked on this + * screen whose own settings write had not finished. The reload cancels that + * write along with the rest of the load lane, and would otherwise resolve + * the older saved choice and switch the shelf back. Only an unfinished pick + * counts, so a choice saved since (Settings has its own picker) wins. + * Null when there is no such pick, it is already saved, or it is gone. + */ +internal fun settingsKeepingPendingPick( + pickedLibraryId: String?, + libraries: List, + settings: AppSettings, +): AppSettings? { + if (pickedLibraryId == null || pickedLibraryId == settings.activeLibraryId) return null + val picked = libraries.firstOrNull { it.id == pickedLibraryId } ?: return null + return when (settings.appMode) { + AppMode.LOCAL -> settings.copy(selectedLocalLibraryId = picked.id).takeIf { picked.isLocal } + AppMode.AUDIOBOOKSHELF -> settings.copy(selectedLibraryId = picked.id).takeIf { !picked.isLocal } + } +} + internal fun decideDownloadedOnlyFilter( previousStatus: ConnectionStatus, newStatus: ConnectionStatus, @@ -241,6 +303,14 @@ class LibraryViewModel @Inject constructor( // Keep them in one lane so an older operation cannot finish last. private val libraryLoadLaunch = ExclusiveLaunch() + // Completed by the catalog watch's first snapshot (null outside LOCAL + // mode). Loads wait for it, so a scan that writes between a load's reads + // and the watch's first query cannot hide inside that baseline. + private val localCatalogBaseline = CompletableDeferred() + + // A library picked on this screen whose settings write has not finished. + private var pendingLibraryPickId: String? = null + init { // Observe connectivity and auto-filter to downloaded when offline viewModelScope.launch { @@ -311,16 +381,66 @@ class LibraryViewModel @Inject constructor( } } + // 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. + viewModelScope.launch { + var previous: LocalCatalogSnapshot? = null + observeLocalCatalogWhileLocal().collect { snapshot -> + val state = _uiState.value + val change = snapshot?.let { + decideLocalCatalogChange( + previous = previous, + current = it, + selectedLibrary = state.selectedLibrary, + shownLibraryIds = state.libraries.map { library -> library.id }, + isLoadBaseline = !localCatalogBaseline.isCompleted, + ) + } + previous = snapshot + localCatalogBaseline.complete(Unit) + when (change) { + // Same lane cleanup as the sync reload above. + LocalCatalogChange.RELOAD -> libraryLoadLaunch.launch(viewModelScope) { + loadLibrariesOwningRefresh(keepPendingPick = true) + } + LocalCatalogChange.REFILTER -> applyFilter() + LocalCatalogChange.NONE, null -> Unit + } + } + } + // Initial load libraryLoadLaunch.launch(viewModelScope) { loadLibraries() } } + /** The local catalog while in LOCAL mode, and null (no Room watch) otherwise. */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun observeLocalCatalogWhileLocal(): Flow = + settingsManager.settings + .map { it.appMode == AppMode.LOCAL } + .distinctUntilChanged() + .flatMapLatest { isLocal -> + if (!isLocal) { + flowOf(null) + } else { + combine( + libraryRepository.observeLocalLibraries() + .map { libraries -> libraries.mapTo(mutableSetOf()) { it.id } }, + audioBookRepository.observeLocalCatalog().map { it.toSet() }, + ::LocalCatalogSnapshot, + ) + } + } + .distinctUntilChanged() + // ─── Loading ────────────────────────────────────────────────────────── - private suspend fun loadLibraries() { + private suspend fun loadLibraries(keepPendingPick: Boolean = false) { _uiState.update { it.copy(isLoading = true, errorMessage = null) } + localCatalogBaseline.await() try { val settings = settingsManager.currentSettings @@ -355,6 +475,15 @@ class LibraryViewModel @Inject constructor( } } + val pick = pendingLibraryPickId.takeIf { keepPendingPick } + if (settingsKeepingPendingPick(pick, libs, settingsManager.currentSettings) != null) { + settingsManager.updateSettings { latest -> + settingsKeepingPendingPick(pick, libs, latest) ?: latest + } + } + // Any pick left here came from a lane this load cancelled. It is + // saved above or, for other loads, superseded by what resolves next. + pendingLibraryPickId = null val selection = persistActiveLibrarySelection( libraries = libs, settings = settingsManager.currentSettings, @@ -511,6 +640,7 @@ class LibraryViewModel @Inject constructor( selectedLibraryFetchPersisted = false, ) } + pendingLibraryPickId = library.id libraryLoadLaunch.launch(viewModelScope) { // Persist selection so the whole app picks it up settingsManager.updateSettings { @@ -520,6 +650,7 @@ class LibraryViewModel @Inject constructor( it.copy(selectedLibraryId = library.id) } } + if (pendingLibraryPickId == library.id) pendingLibraryPickId = null // Full resync for the newly selected library loadAudioBooks(library.id) _uiState.update { it.copy(isLoading = false) } @@ -595,9 +726,9 @@ class LibraryViewModel @Inject constructor( } /** Load, then clear the refresh spinner unless a newer load took over the lane. */ - private suspend fun loadLibrariesOwningRefresh() { + private suspend fun loadLibrariesOwningRefresh(keepPendingPick: Boolean = false) { try { - loadLibraries() + loadLibraries(keepPendingPick) } finally { updateLibraryLoadStateIfActive { _uiState.update { it.copy(isRefreshing = false) } diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogChangeTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogChangeTest.kt new file mode 100644 index 00000000..07d5b1a5 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogChangeTest.kt @@ -0,0 +1,153 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry +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.Assert.assertNull +import org.junit.Test + +/** + * A folder scan writes its library row and books from Settings, and LOCAL + * mode has no sync record to announce them. The Library tab opened during a + * first-run scan stayed on "The Archive Stands Empty" until it was re-entered. + * It now compares each local catalog snapshot with the last one. + */ +class LibraryLocalCatalogChangeTest { + + private val audiobooks = Library(id = "audiobooks", isLocal = true) + private val podcasts = Library(id = "podcasts", isLocal = true) + private val nothing = LocalCatalogSnapshot(libraryIds = emptySet(), books = emptySet()) + private val folderOnly = LocalCatalogSnapshot(libraryIds = setOf("audiobooks"), books = emptySet()) + private val folderWithBooks = folderOnly.copy( + books = setOf(live("book-1"), live("book-2")), + ) + + @Test + fun `the first snapshot is the baseline every load waits for`() { + // Loads read only after it arrives, so it can never hold a write that + // the shelf missed. Acting on it would reload every time the tab opens. + assertEquals( + LocalCatalogChange.NONE, + decideLocalCatalogChange( + null, folderWithBooks, selectedLibrary = null, shownLibraryIds = emptyList(), isLoadBaseline = true, + ), + ) + } + + @Test + fun `a watch that starts after a switch into LOCAL mode checks its first snapshot`() { + // No load waited for this one. The shelf still shows what the server + // mode loaded, so the local folders have to be loaded now. + assertEquals( + LocalCatalogChange.RELOAD, + decideLocalCatalogChange(null, folderWithBooks, Library(id = "server"), listOf("server"), isLoadBaseline = false), + ) + } + + @Test + fun `an unchanged catalog does nothing`() { + assertEquals( + LocalCatalogChange.NONE, + decideLocalCatalogChange(folderWithBooks, folderWithBooks, audiobooks, listOf("audiobooks"), isLoadBaseline = false), + ) + } + + @Test + fun `a first scan's folder appearing reloads the library list`() { + assertEquals( + LocalCatalogChange.RELOAD, + decideLocalCatalogChange(nothing, folderOnly, selectedLibrary = null, shownLibraryIds = emptyList(), isLoadBaseline = false), + ) + } + + @Test + fun `books arriving in the shown folder refilter the shelf`() { + assertEquals( + LocalCatalogChange.REFILTER, + decideLocalCatalogChange(folderOnly, folderWithBooks, audiobooks, listOf("audiobooks"), isLoadBaseline = false), + ) + } + + @Test + fun `books arriving while nothing is selected reload the library list`() { + assertEquals( + LocalCatalogChange.RELOAD, + decideLocalCatalogChange(folderOnly, folderWithBooks, selectedLibrary = null, shownLibraryIds = emptyList(), isLoadBaseline = false), + ) + } + + @Test + fun `a book archived by a rescan refilters the shelf`() { + val archived = folderOnly.copy(books = setOf(live("book-1"), archived("book-2"))) + assertEquals( + LocalCatalogChange.REFILTER, + decideLocalCatalogChange(folderWithBooks, archived, audiobooks, listOf("audiobooks"), isLoadBaseline = false), + ) + } + + @Test + fun `a rescan that retags a book refilters the shelf`() { + val retagged = folderOnly.copy(books = setOf(live("book-1"), live("book-2").copy(title = "Retagged"))) + assertEquals( + LocalCatalogChange.REFILTER, + decideLocalCatalogChange(folderWithBooks, retagged, audiobooks, listOf("audiobooks"), isLoadBaseline = false), + ) + } + + @Test + fun `a second folder being added reloads the library list`() { + val twoFolders = folderWithBooks.copy(libraryIds = setOf("audiobooks", "podcasts")) + assertEquals( + LocalCatalogChange.RELOAD, + decideLocalCatalogChange(folderWithBooks, twoFolders, audiobooks, listOf("audiobooks"), isLoadBaseline = false), + ) + } + + @Test + fun `a reload keeps a library pick whose save has not landed`() { + // The user picked podcasts, and a catalog reload cancelled that pick + // before its settings write finished. The reload saves the pick + // instead of switching the shelf back to audiobooks. + val saved = AppSettings(appMode = AppMode.LOCAL, selectedLocalLibraryId = "audiobooks") + val kept = settingsKeepingPendingPick("podcasts", listOf(audiobooks, podcasts), saved) + assertEquals("podcasts", kept?.selectedLocalLibraryId) + } + + @Test + fun `a reload with the pick already saved writes nothing`() { + val saved = AppSettings(appMode = AppMode.LOCAL, selectedLocalLibraryId = "audiobooks") + assertNull(settingsKeepingPendingPick("audiobooks", listOf(audiobooks, podcasts), saved)) + } + + @Test + fun `a reload does not keep a library that is gone`() { + val saved = AppSettings(appMode = AppMode.LOCAL, selectedLocalLibraryId = "audiobooks") + assertNull(settingsKeepingPendingPick("podcasts", listOf(audiobooks), saved)) + } + + @Test + fun `a reload with no unfinished pick leaves the saved choice alone`() { + // Settings saved podcasts since the Library last picked anything. + val saved = AppSettings(appMode = AppMode.LOCAL, selectedLocalLibraryId = "podcasts") + assertNull(settingsKeepingPendingPick(null, listOf(audiobooks, podcasts), saved)) + } + + @Test + fun `a reload with nothing picked leaves the saved choice to the normal rules`() { + val saved = AppSettings(appMode = AppMode.LOCAL, selectedLocalLibraryId = null) + assertNull(settingsKeepingPendingPick(null, listOf(audiobooks), saved)) + } + + @Test + fun `a reload never saves a server library as the local choice`() { + val server = Library(id = "server", isLocal = false) + val saved = AppSettings(appMode = AppMode.LOCAL, selectedLocalLibraryId = "audiobooks") + assertNull(settingsKeepingPendingPick("server", listOf(audiobooks, server), saved)) + } + + private fun live(id: String) = LocalCatalogEntry(id = id, libraryId = "audiobooks", isArchived = false) + + private fun archived(id: String) = LocalCatalogEntry(id = id, libraryId = "audiobooks", isArchived = true) +}