Repository navigation
The Library shows a folder scan's books while it is open #60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
217 changes: 217 additions & 0 deletions
217
...c/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <T : ViewModel> create(modelClass: Class<T>): 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, | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
27 changes: 27 additions & 0 deletions
27
app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalCatalogEntry.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a rescan keeps the chapter count and total duration but moves a boundary between equal-width timestamps鈥攆or example, two 600-second tracks changing from a 300/300 split to 200/400鈥攖he serialized chapter JSON can retain exactly the same length. Fresh evidence after the earlier thread is this follow-up's
length(ChaptersJson)projection: every projected field then remains equal, sodistinctUntilChanged()suppresses the refilter and the open shelf continues calculatingCh N/Mfrom the old boundaries until it is reopened. Observe a collision-resistant chapter signature or the chapter data needed by the card rather than its string length.Useful? React with 馃憤聽/ 馃憥.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Declining this one. The case needs a rescan that keeps the chapter count, the total duration, and the exact serialized length while moving a boundary, and the only effect is the open card's "Ch N/M" index until the Library is reopened. Observing the chapter list itself means re-reading every local book's chapter blob on each playback progress write while the Library is open, which is the cost this watch is built to avoid, and SQLite on Android has no hash function to make a cheap signature. Length covers the realistic re-split (the chapter count changes).