Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
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,
)
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
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
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
Expand All @@ -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
Expand Down Expand Up @@ -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()
}
}

Expand All @@ -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,
Expand All @@ -117,6 +124,7 @@ private class LibraryViewModelFixture(app: NineLivesApp) {
localListeningSessionDao = emptyDao(),
localBookmarkDao = emptyDao(),
playbackProgressDao = emptyDao(),
database = database,
)
private val libraryRepository = LibraryRepository(
libraryDao = localLibraryDao(),
Expand Down Expand Up @@ -157,6 +165,7 @@ private class DelayedLibraryFilterDao {
"getFilteredBooks" -> filteredBooks(args)
"countByLibrary" -> 2
"getDistinctSeries", "getDistinctAuthors", "getDistinctGenresJson" -> emptyList<String>()
"observeLocalCatalog" -> flowOf(emptyList<LocalCatalogEntry>())
"toString" -> "DelayedLibraryFilterDao"
"hashCode" -> System.identityHashCode(this)
"equals" -> args?.singleOrNull() === this
Expand Down Expand Up @@ -213,17 +222,20 @@ 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
else -> error("Unexpected LibraryDao call: ${method.name}")
}
}

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 <reified T> emptyDao(): T = proxy { method, _ ->
error("Unexpected ${T::class.simpleName} call: ${method.name}")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>

/**
* 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare chapter content instead of only its length

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, so distinctUntilChanged() suppresses the refilter and the open shelf continues calculating Ch N/M from 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 馃憤聽/ 馃憥.

Copy link
Copy Markdown
Owner Author

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).

FROM AudioBooks WHERE IsLocal = 1
"""
)
fun observeLocalCatalog(): Flow<List<LocalCatalogEntry>>

/** 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<String>
Expand Down
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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -54,6 +55,10 @@ class AudioBookRepository @Inject constructor(
fun observeLocalBooks(): Flow<List<AudioBook>> =
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<List<LocalCatalogEntry>> =
audioBookDao.observeLocalCatalog()

/** Observe a single audiobook. */
fun observeById(id: String): Flow<AudioBook?> =
audioBookDao.observeById(id).map { it?.toDomain() }
Expand Down
Loading
Loading