Review fixes for waves 9-10; preload covers so Save stops waiting on them

Orchestrator review of b600df6 and cf82cbc (the chain's gate only proved they built).

Fixed:
- Search: "In your library" now shows the merged set (owned editions reached only via
  an online ISBN were removed from "Online" and shown nowhere); the shelf filter applies
  to it and hidden owned matches are counted.
- "Edit details" closes the online save sheet (left open, it invited a duplicate save).
- A failed remembered-shelf write after a successful insert no longer reports
  "Couldn't save" (a retry would duplicate the book).
- Library search no longer normalizes every book on every keystroke on the main thread.
- Add-by-hand duplicate check and the scan sheet's save are guarded (no crash, no
  double-tap duplicate, CancellationException rethrown).

Covers (design decided with the user): createBook used to download the cover before
writing the row, so every save waited on the image host, and a failed download silently
saved no cover file. The cover now downloads as soon as a sheet has its URL; Save waits
for it if needed and copies the file into place. A failed download shows on the sheet
with Retry, and Save becomes "Save without cover". Unsaved preloads are discarded, and
leftovers from a killed process are swept at startup.

337 tests (was 308), 2 skipped, 0 failures; assembleDebug and verifyPaparazziDebug green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Spriteandclaude committed 2026-09-16 19:01:54 +00:00
1 parent cf82cbc484
commit 3c30a20324
29 files changed
+1396 -91

No files matched your search

@@ -1,6 +1,7 @@
package org.modg.bookshelf package org.modg.bookshelf
import android.content.Context import android.content.Context
import java.io.File
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -12,6 +13,7 @@ import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import org.modg.bookshelf.data.local.BookshelfDatabase import org.modg.bookshelf.data.local.BookshelfDatabase
import org.modg.bookshelf.data.metadata.BookSearchRepository import org.modg.bookshelf.data.metadata.BookSearchRepository
import org.modg.bookshelf.data.repo.CoverDownloader
import org.modg.bookshelf.data.metadata.MetadataRepository import org.modg.bookshelf.data.metadata.MetadataRepository
import org.modg.bookshelf.diagnostics.CrashReporter import org.modg.bookshelf.diagnostics.CrashReporter
import org.modg.bookshelf.data.prefs.SettingsStore import org.modg.bookshelf.data.prefs.SettingsStore
@@ -121,6 +123,12 @@ class AppContainer(private val context: Context) {
val metadataRepository by lazy { MetadataRepository(metadataHttpClient, json, BuildConfig.GOOGLE_BOOKS_API_KEY) } val metadataRepository by lazy { MetadataRepository(metadataHttpClient, json, BuildConfig.GOOGLE_BOOKS_API_KEY) }
/**
* Cover preloads for the save sheets. [metadataHttpClient], not [okHttpClient]: covers
* come from third-party image hosts, which must never see our PocketBase token.
*/
val coverDownloader by lazy { CoverDownloader(metadataHttpClient, File(context.cacheDir, "cover-preload")) }
/** Library screen's online title/author search — shares the same client/timeouts/key as [metadataRepository]. */ /** Library screen's online title/author search — shares the same client/timeouts/key as [metadataRepository]. */
val bookSearchRepository by lazy { BookSearchRepository(metadataHttpClient, json, BuildConfig.GOOGLE_BOOKS_API_KEY) } val bookSearchRepository by lazy { BookSearchRepository(metadataHttpClient, json, BuildConfig.GOOGLE_BOOKS_API_KEY) }
@@ -16,5 +16,9 @@ class BookshelfApplication : Application() {
// See CrashReporter's KDoc: born from an on-device crash right after // See CrashReporter's KDoc: born from an on-device crash right after
// the previous commit that could never be reproduced off-device. // the previous commit that could never be reproduced off-device.
appContainer.crashReporter.install() appContainer.crashReporter.install()
// Preloaded covers from a process that died with a save sheet open. Off the main
// thread (file listing); files this process creates are newer and never swept.
val processStart = System.currentTimeMillis()
Thread { appContainer.coverDownloader.sweepStale(olderThanMillis = processStart) }.start()
} }
} }
@@ -12,6 +12,11 @@ import java.text.Normalizer
object TextNormalization { object TextNormalization {
private val leadingArticles = setOf("the", "a", "an") private val leadingArticles = setOf("the", "a", "an")
// Compiled once: the library search normalizes every owned book's fields, so an inline
// Regex(...) here was being recompiled thousands of times per keystroke.
private val combiningMarks = Regex("\\p{Mn}+")
private val whitespaceRun = Regex("\\s+")
/** /**
* Lowercase, diacritics stripped (NFD decompose + drop combining marks), every * Lowercase, diacritics stripped (NFD decompose + drop combining marks), every
* non-alphanumeric character turned into a space, whitespace collapsed. "J.R.R." * non-alphanumeric character turned into a space, whitespace collapsed. "J.R.R."
@@ -19,9 +24,9 @@ object TextNormalization {
*/ */
fun normalizeForMatching(raw: String): String { fun normalizeForMatching(raw: String): String {
val decomposed = Normalizer.normalize(raw, Normalizer.Form.NFD) val decomposed = Normalizer.normalize(raw, Normalizer.Form.NFD)
val noDiacritics = decomposed.replace(Regex("\\p{Mn}+"), "") val noDiacritics = decomposed.replace(combiningMarks, "")
val spaced = noDiacritics.map { c -> if (c.isLetterOrDigit()) c else ' ' }.joinToString("") val spaced = noDiacritics.map { c -> if (c.isLetterOrDigit()) c else ' ' }.joinToString("")
return spaced.lowercase().trim().replace(Regex("\\s+"), " ") return spaced.lowercase().trim().replace(whitespaceRun, " ")
} }
fun tokens(raw: String): List<String> { fun tokens(raw: String): List<String> {
@@ -7,14 +7,11 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import okhttp3.OkHttpClient
import okhttp3.Request
import org.modg.bookshelf.data.local.BookDao import org.modg.bookshelf.data.local.BookDao
import org.modg.bookshelf.data.local.BookEntity import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.IdGenerator import org.modg.bookshelf.data.local.IdGenerator
import org.modg.bookshelf.data.local.SyncState import org.modg.bookshelf.data.local.SyncState
import java.io.File import java.io.File
import java.io.IOException
private val authorsJson = Json { ignoreUnknownKeys = true } private val authorsJson = Json { ignoreUnknownKeys = true }
@@ -31,7 +28,6 @@ fun decodeAuthors(json: String): List<String> =
class BookRepository( class BookRepository(
private val bookDao: BookDao, private val bookDao: BookDao,
private val context: Context, private val context: Context,
private val downloadClient: OkHttpClient = OkHttpClient(),
) { ) {
fun observeAll(): Flow<List<BookEntity>> = bookDao.observeAll() fun observeAll(): Flow<List<BookEntity>> = bookDao.observeAll()
fun observeAllByRecentlyAdded(): Flow<List<BookEntity>> = bookDao.observeAllByRecentlyAdded() fun observeAllByRecentlyAdded(): Flow<List<BookEntity>> = bookDao.observeAllByRecentlyAdded()
@@ -44,10 +40,13 @@ class BookRepository(
suspend fun countByShelf(shelfId: String): Int = bookDao.countByShelf(shelfId) suspend fun countByShelf(shelfId: String): Int = bookDao.countByShelf(shelfId)
/** /**
* Creates a new book. If [coverSourceUrl] is set, attempts to download the * Creates a new book. [coverFile] is a cover the caller already downloaded (see
* cover to app-private storage now (best-effort; failures are swallowed — * [CoverPreload]); it is COPIED into app-private storage as [BookEntity.localCoverPath]
* the SyncEngine will simply have nothing to upload, and the UI still has * for SyncEngine to upload. This used to download [coverSourceUrl] itself before
* [coverSourceUrl] to fall back on for display). * writing the row, which made every save wait on the image host and silently saved
* the book with no cover file when that download failed. The caller keeps ownership
* of [coverFile] (a copy, not a move, so a failed write leaves the preload intact for
* a retry). [coverSourceUrl] is still recorded — the UI falls back to it for display.
*/ */
suspend fun createBook( suspend fun createBook(
title: String, title: String,
@@ -60,13 +59,15 @@ class BookRepository(
pageCount: Int? = null, pageCount: Int? = null,
description: String? = null, description: String? = null,
coverSourceUrl: String? = null, coverSourceUrl: String? = null,
coverFile: File? = null,
shelfId: String? = null, shelfId: String? = null,
notes: String? = null, notes: String? = null,
addedBy: String? = null, addedBy: String? = null,
): String { ): String {
val id = IdGenerator.newId() val id = IdGenerator.newId()
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val localCoverPath = coverSourceUrl?.let { downloadCoverBestEffort(id, it) } val localCover = coverFile?.let { copyCover(id, it) }
try {
bookDao.upsert( bookDao.upsert(
BookEntity( BookEntity(
id = id, id = id,
@@ -86,9 +87,14 @@ class BookRepository(
createdAt = now, createdAt = now,
updatedAt = now, updatedAt = now,
syncState = SyncState.PENDING_CREATE, syncState = SyncState.PENDING_CREATE,
localCoverPath = localCoverPath, localCoverPath = localCover?.absolutePath,
), ),
) )
} catch (e: Throwable) {
// No row points at the copy, so nothing would ever upload or delete it.
localCover?.delete()
throw e
}
return id return id
} }
@@ -131,19 +137,8 @@ class BookRepository(
) )
} }
private suspend fun downloadCoverBestEffort(id: String, url: String): String? = withContext(Dispatchers.IO) { private suspend fun copyCover(id: String, source: File): File = withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(url).build()
downloadClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext null
val bytes = response.body.bytes()
val dir = File(context.filesDir, "covers").apply { mkdirs() } val dir = File(context.filesDir, "covers").apply { mkdirs() }
val file = File(dir, "$id.jpg") source.copyTo(File(dir, "$id.jpg"), overwrite = true)
file.writeBytes(bytes)
file.absolutePath
}
} catch (e: IOException) {
null
}
} }
} }
@@ -0,0 +1,164 @@
package org.modg.bookshelf.data.repo
import java.io.File
import java.io.IOException
import java.util.UUID
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.modg.bookshelf.data.metadata.SourceResult
/** Outcome of one cover download. [Failed.reason] is safe to show the user. */
sealed interface CoverFetch {
data class Ready(val file: File) : CoverFetch
data class Failed(val reason: String) : CoverFetch
}
/**
* Downloads a cover into [preloadDir] so it can be handed to [BookRepository.createBook]
* at save time. Save used to download the cover itself, BEFORE writing the row, so every
* save sat waiting on the image host (covers.openlibrary.org redirects to archive.org and
* is often slow) even though the sheet was already showing that very cover. Downloading
* as soon as the cover URL is known means the file is usually there by the time Save is
* tapped, and a failure can be shown while the user is still on the sheet.
*
* Not Coil's disk cache: that's Coil's private performance detail (eviction, cache keys,
* format can all change), and we need a file we own to upload to the server.
*/
class CoverDownloader(
private val httpClient: OkHttpClient,
private val preloadDir: File,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/** Never throws except [CancellationException]; no file is left behind on any non-success path. */
suspend fun download(url: String): CoverFetch {
preloadDir.mkdirs()
val file = File(preloadDir, UUID.randomUUID().toString())
return try {
withContext(ioDispatcher) { fetchInto(file, url) }
} catch (e: CancellationException) {
// Also covers a cancel that lands AFTER the bytes were written: withContext then
// throws on its way out, past any catch inside the block.
file.delete()
throw e
}
}
private fun fetchInto(file: File, url: String): CoverFetch = try {
val request = Request.Builder().url(url).build()
httpClient.newCall(request).execute().use { response ->
val bytes = if (response.isSuccessful) response.body.bytes() else null
when {
bytes == null -> CoverFetch.Failed(SourceResult.fromHttpCode(response.code).reason)
bytes.isEmpty() -> CoverFetch.Failed("empty image")
else -> {
file.writeBytes(bytes)
CoverFetch.Ready(file)
}
}
}
} catch (e: IOException) {
file.delete()
CoverFetch.Failed(SourceResult.fromException(e).reason)
} catch (e: Throwable) {
file.delete()
CoverFetch.Failed("unexpected: ${e.javaClass.simpleName}")
}
/**
* Deletes preloads left behind by a previous process (killed or crashed with a sheet
* open). Only files older than [olderThanMillis] — the current process's start — so a
* preload started by this process can never be swept out from under its sheet.
*/
fun sweepStale(olderThanMillis: Long) {
preloadDir.listFiles()?.forEach { if (it.lastModified() < olderThanMillis) it.delete() }
}
}
/** What a sheet/screen shows about its cover download. Every non-[None] state carries the URL it is for. */
sealed interface CoverPreloadState {
data object None : CoverPreloadState
data class Loading(val url: String) : CoverPreloadState
data class Ready(val url: String, val file: File) : CoverPreloadState
data class Failed(val url: String, val reason: String) : CoverPreloadState
}
/**
* At most ONE cover download for one owner (a sheet or screen) — it starts when a result is
* opened, never for every row in a result list. Owns the downloaded file until [discard]:
* call it on Skip/dismiss/leaving the screen AND after a successful save (createBook copies
* the file), so nothing is left behind for books that weren't saved.
*/
class CoverPreload(
private val downloader: CoverDownloader,
private val scope: CoroutineScope,
) {
private val _state = MutableStateFlow<CoverPreloadState>(CoverPreloadState.None)
val state: StateFlow<CoverPreloadState> = _state.asStateFlow()
private var job: Job? = null
/** Starts downloading [url]; a no-op if that URL is already loading or ready. Null/blank clears. */
fun start(url: String?) {
if (url.isNullOrBlank()) {
discard()
return
}
when (val current = _state.value) {
is CoverPreloadState.Loading -> if (current.url == url) return
is CoverPreloadState.Ready -> if (current.url == url) return
else -> Unit
}
discard()
val loading = CoverPreloadState.Loading(url)
_state.value = loading
job = scope.launch {
val result = downloader.download(url)
if (_state.value != loading) {
// Superseded or discarded while downloading — the file is nobody's.
(result as? CoverFetch.Ready)?.file?.delete()
return@launch
}
_state.value = when (result) {
is CoverFetch.Ready -> CoverPreloadState.Ready(url, result.file)
is CoverFetch.Failed -> CoverPreloadState.Failed(url, result.reason)
}
}
}
fun retry() {
val failed = _state.value as? CoverPreloadState.Failed ?: return
discard()
start(failed.url)
}
/**
* Waits for any in-flight download to finish. A Ready file that has since vanished
* (the preload lives in the cache dir, which Android may clear) is downloaded again.
*/
suspend fun awaitSettled(): CoverPreloadState {
val settled = _state.first { it !is CoverPreloadState.Loading }
if (settled is CoverPreloadState.Ready && !settled.file.exists()) {
discard()
start(settled.url)
return _state.first { it !is CoverPreloadState.Loading }
}
return settled
}
fun discard() {
job?.cancel()
job = null
(_state.value as? CoverPreloadState.Ready)?.file?.delete()
_state.value = CoverPreloadState.None
}
}
@@ -37,9 +37,11 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import org.modg.bookshelf.AppContainer import org.modg.bookshelf.AppContainer
import org.modg.bookshelf.ui.components.BookshelfScaffold import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.CoverPreloadNotice
import org.modg.bookshelf.ui.components.PaperSurface import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.components.PrimaryButton import org.modg.bookshelf.ui.components.PrimaryButton
import org.modg.bookshelf.ui.components.SecondaryButton import org.modg.bookshelf.ui.components.SecondaryButton
import org.modg.bookshelf.ui.components.saveButtonLabel
import org.modg.bookshelf.ui.scan.DuplicateStatus import org.modg.bookshelf.ui.scan.DuplicateStatus
import org.modg.bookshelf.ui.scan.ShelfPicker import org.modg.bookshelf.ui.scan.ShelfPicker
@@ -71,6 +73,7 @@ fun AddBookScreen(
bookRepository = container.bookRepository, bookRepository = container.bookRepository,
locationRepository = container.locationRepository, locationRepository = container.locationRepository,
settingsStore = container.settingsStore, settingsStore = container.settingsStore,
coverDownloader = container.coverDownloader,
initialDraft = BookDraft.fromRouteArgs( initialDraft = BookDraft.fromRouteArgs(
title = initialTitle, title = initialTitle,
isbn = initialIsbn, isbn = initialIsbn,
@@ -103,6 +106,7 @@ fun AddBookScreen(
onSave = { viewModel.save(onSaved) }, onSave = { viewModel.save(onSaved) },
onSaveAndAddAnother = viewModel::saveAndAddAnother, onSaveAndAddAnother = viewModel::saveAndAddAnother,
onBack = onBack, onBack = onBack,
onRetryCover = viewModel::retryCover,
), ),
) )
} }
@@ -127,6 +131,7 @@ data class AddBookCallbacks(
val onSave: () -> Unit, val onSave: () -> Unit,
val onSaveAndAddAnother: () -> Unit, val onSaveAndAddAnother: () -> Unit,
val onBack: () -> Unit, val onBack: () -> Unit,
val onRetryCover: () -> Unit = {},
) )
@Composable @Composable
@@ -304,6 +309,8 @@ fun AddBookContent(state: AddBookUiState, callbacks: AddBookCallbacks, modifier:
onShelfSelected = callbacks.onShelfSelected, onShelfSelected = callbacks.onShelfSelected,
) )
CoverPreloadNotice(state = state.cover, onRetry = callbacks.onRetryCover)
if (state.saveError != null) { if (state.saveError != null) {
Text( Text(
text = "Couldn't save: ${state.saveError}", text = "Couldn't save: ${state.saveError}",
@@ -324,7 +331,7 @@ fun AddBookContent(state: AddBookUiState, callbacks: AddBookCallbacks, modifier:
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
PrimaryButton( PrimaryButton(
text = if (state.isSaving) "Saving…" else "Save", text = saveButtonLabel(state.isSaving, state.cover),
enabled = state.canSave, enabled = state.canSave,
onClick = callbacks.onSave, onClick = callbacks.onSave,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -16,6 +16,9 @@ import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.prefs.SettingsStore import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.CoverDownloader
import org.modg.bookshelf.data.repo.CoverPreload
import org.modg.bookshelf.data.repo.CoverPreloadState
import org.modg.bookshelf.data.repo.LocationRepository import org.modg.bookshelf.data.repo.LocationRepository
import org.modg.bookshelf.ui.scan.DuplicateCheck import org.modg.bookshelf.ui.scan.DuplicateCheck
import org.modg.bookshelf.ui.scan.DuplicateStatus import org.modg.bookshelf.ui.scan.DuplicateStatus
@@ -32,6 +35,7 @@ data class AddBookUiState(
val saveError: String? = null, val saveError: String? = null,
val savedCount: Int = 0, val savedCount: Int = 0,
val lastSavedTitle: String? = null, val lastSavedTitle: String? = null,
val cover: CoverPreloadState = CoverPreloadState.None,
) { ) {
val isbnError: String? val isbnError: String?
get() = if (validateIsbn(draft.isbn) is IsbnFieldState.Invalid) { get() = if (validateIsbn(draft.isbn) is IsbnFieldState.Invalid) {
@@ -57,6 +61,7 @@ class AddBookViewModel(
private val bookRepository: BookRepository, private val bookRepository: BookRepository,
locationRepository: LocationRepository, locationRepository: LocationRepository,
private val settingsStore: SettingsStore, private val settingsStore: SettingsStore,
coverDownloader: CoverDownloader,
initialDraft: BookDraft = BookDraft(), initialDraft: BookDraft = BookDraft(),
) : ViewModel() { ) : ViewModel() {
@@ -71,6 +76,12 @@ class AddBookViewModel(
) )
private val _formState = MutableStateFlow(FormState(draft = initialDraft)) private val _formState = MutableStateFlow(FormState(draft = initialDraft))
/**
* Only a draft carried in from an online search hit ("Edit details") has a cover URL —
* there's no cover field on this form — so this is usually [CoverPreloadState.None].
*/
private val coverPreload = CoverPreload(coverDownloader, viewModelScope)
private var duplicateCheckJob: Job? = null private var duplicateCheckJob: Job? = null
val bookcases: StateFlow<List<BookcaseEntity>> = locationRepository.observeBookcases() val bookcases: StateFlow<List<BookcaseEntity>> = locationRepository.observeBookcases()
@@ -87,8 +98,8 @@ class AddBookViewModel(
// collecting (e.g. Compose's collectAsState, or a test using .first()); a bare // collecting (e.g. Compose's collectAsState, or a test using .first()); a bare
// .value read with no live collector sees only the seed passed to stateIn. // .value read with no live collector sees only the seed passed to stateIn.
val uiState: StateFlow<AddBookUiState> = combine( val uiState: StateFlow<AddBookUiState> = combine(
_formState, bookcases, shelves, recentShelfId, _formState, bookcases, shelves, recentShelfId, coverPreload.state,
) { form, cases, shelfList, recent -> ) { form, cases, shelfList, recent, cover ->
AddBookUiState( AddBookUiState(
draft = form.draft, draft = form.draft,
bookcases = cases, bookcases = cases,
@@ -100,6 +111,7 @@ class AddBookViewModel(
saveError = form.saveError, saveError = form.saveError,
savedCount = form.savedCount, savedCount = form.savedCount,
lastSavedTitle = form.lastSavedTitle, lastSavedTitle = form.lastSavedTitle,
cover = cover,
) )
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AddBookUiState(draft = initialDraft)) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AddBookUiState(draft = initialDraft))
@@ -113,8 +125,11 @@ class AddBookViewModel(
if (remembered != null) _formState.update { it.copy(selectedShelfId = remembered) } if (remembered != null) _formState.update { it.copy(selectedShelfId = remembered) }
} }
if (initialDraft.isbn.isNotBlank()) scheduleDuplicateCheck(initialDraft.isbn) if (initialDraft.isbn.isNotBlank()) scheduleDuplicateCheck(initialDraft.isbn)
coverPreload.start(initialDraft.coverSourceUrl)
} }
fun retryCover() = coverPreload.retry()
fun onTitleChanged(value: String) = updateDraft { it.copy(title = value) } fun onTitleChanged(value: String) = updateDraft { it.copy(title = value) }
fun onSubtitleChanged(value: String) = updateDraft { it.copy(subtitle = value) } fun onSubtitleChanged(value: String) = updateDraft { it.copy(subtitle = value) }
fun onAuthorsChanged(value: String) = updateDraft { it.copy(authors = value) } fun onAuthorsChanged(value: String) = updateDraft { it.copy(authors = value) }
@@ -145,8 +160,16 @@ class AddBookViewModel(
return return
} }
duplicateCheckJob = viewModelScope.launch { duplicateCheckJob = viewModelScope.launch {
val existing = bookRepository.findByIsbn13(isbn13) // A Room failure here would otherwise crash the app from a keystroke. The check
_formState.update { it.copy(duplicate = DuplicateCheck.check(existing)) } // is only a warning, so on failure there is simply no warning.
val duplicate = try {
DuplicateCheck.check(bookRepository.findByIsbn13(isbn13))
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
DuplicateStatus.New
}
_formState.update { it.copy(duplicate = duplicate) }
} }
} }
@@ -177,17 +200,26 @@ class AddBookViewModel(
*/ */
internal suspend fun performSave(clearAfterSave: Boolean): String? { internal suspend fun performSave(clearAfterSave: Boolean): String? {
if (_formState.value.isSaving) return null if (_formState.value.isSaving) return null
val coverAtTap = coverPreload.state.value
_formState.update { it.copy(isSaving = true, saveError = null) } _formState.update { it.copy(isSaving = true, saveError = null) }
val form = _formState.value val form = _formState.value
val draft = form.draft val draft = form.draft
return try { val savedTitle = draft.title.trim()
val bookId = try {
// Same cover rule as the scan sheet (see ScanViewModel.performSave): a download
// that fails while Save waits stops the save; one that had already failed when
// Save (labelled "Save without cover") was tapped does not.
val cover = coverPreload.awaitSettled()
if (cover is CoverPreloadState.Failed && coverAtTap !is CoverPreloadState.Failed) {
_formState.update { it.copy(isSaving = false) }
return null
}
val isbnValidation = validateIsbn(draft.isbn) val isbnValidation = validateIsbn(draft.isbn)
val isbn13 = (isbnValidation as? IsbnFieldState.Valid)?.isbn13 val isbn13 = (isbnValidation as? IsbnFieldState.Valid)?.isbn13
val isbn10 = (isbnValidation as? IsbnFieldState.Valid)?.isbn10 val isbn10 = (isbnValidation as? IsbnFieldState.Valid)?.isbn10
val pageCount = (validatePages(draft.pages) as? PagesFieldState.Valid)?.pages val pageCount = (validatePages(draft.pages) as? PagesFieldState.Valid)?.pages
val authors = draft.authors.split(",").map(String::trim).filter(String::isNotEmpty) val authors = draft.authors.split(",").map(String::trim).filter(String::isNotEmpty)
val savedTitle = draft.title.trim() bookRepository.createBook(
val bookId = bookRepository.createBook(
title = savedTitle, title = savedTitle,
subtitle = draft.subtitle.trim().ifEmpty { null }, subtitle = draft.subtitle.trim().ifEmpty { null },
authors = authors, authors = authors,
@@ -198,8 +230,19 @@ class AddBookViewModel(
pageCount = pageCount, pageCount = pageCount,
description = draft.description.trim().ifEmpty { null }, description = draft.description.trim().ifEmpty { null },
coverSourceUrl = draft.coverSourceUrl.trim().ifEmpty { null }, coverSourceUrl = draft.coverSourceUrl.trim().ifEmpty { null },
coverFile = (cover as? CoverPreloadState.Ready)?.file,
shelfId = form.selectedShelfId, shelfId = form.selectedShelfId,
) )
} catch (e: CancellationException) {
_formState.update { it.copy(isSaving = false) }
throw e
} catch (e: Throwable) {
_formState.update { it.copy(isSaving = false, saveError = e.javaClass.simpleName) }
return null
}
// Everything below runs only once the book is written. None of it may report a
// failure: "couldn't save" for a saved book invites a retry that creates a duplicate.
coverPreload.discard() // createBook copied it; "add another" starts a draft with no cover
rememberShelf(form.selectedShelfId) rememberShelf(form.selectedShelfId)
_formState.update { _formState.update {
it.copy( it.copy(
@@ -211,17 +254,22 @@ class AddBookViewModel(
) )
} }
if (clearAfterSave) scheduleDuplicateCheck("") // the ISBN field just cleared; the warning must too if (clearAfterSave) scheduleDuplicateCheck("") // the ISBN field just cleared; the warning must too
bookId return bookId
}
/** A remembered-shelf write that fails is swallowed — the book is already saved (see [performSave]). */
private suspend fun rememberShelf(shelfId: String?) {
if (shelfId == null) return // "Not shelved" must never overwrite the memory — it isn't a shelf
try {
settingsStore.setLastShelfId(shelfId)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Throwable) { } catch (e: Throwable) {
_formState.update { it.copy(isSaving = false, saveError = e.javaClass.simpleName) } // Losing the "recent shelf" shortcut is not worth an error on a saved book.
null
} }
} }
/** "Not shelved" (null) must never overwrite the memory — it isn't a shelf. */ override fun onCleared() {
private suspend fun rememberShelf(shelfId: String?) { coverPreload.discard()
if (shelfId != null) settingsStore.setLastShelfId(shelfId)
} }
} }
@@ -0,0 +1,42 @@
package org.modg.bookshelf.ui.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.modg.bookshelf.data.repo.CoverPreloadState
/**
* The one visible trace of a cover preload: nothing while it loads or once it's ready (the
* sheet already shows the cover via Coil), and a failure line with Retry when it failed —
* so the user finds out while still on the sheet, not after the book is saved without one.
*/
@Composable
fun CoverPreloadNotice(state: CoverPreloadState, onRetry: () -> Unit, modifier: Modifier = Modifier) {
if (state !is CoverPreloadState.Failed) return
Row(modifier = modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Couldn't download the cover — ${state.reason}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onRetry) { Text("Retry") }
}
}
/**
* Save's label. After a failed cover download, saving goes ahead without the cover file
* (the user decided: retry OR save without it, never stuck mid-box), so the button says so.
*/
fun saveButtonLabel(isSaving: Boolean, cover: CoverPreloadState, idle: String = "Save"): String = when {
isSaving -> "Saving…"
cover is CoverPreloadState.Failed -> "$idle without cover"
else -> idle
}
@@ -68,12 +68,15 @@ sealed interface OnlineSearchState {
data class Searching(val query: String) : OnlineSearchState data class Searching(val query: String) : OnlineSearchState
/** /**
* [inLibrary] is the FULL "In your library" set once a search has run — a superset * [inLibrary] is what the "In your library" section shows once a search has run — a
* of [LocalBookMatcher]'s plain text match, since [org.modg.bookshelf.data.metadata.SearchResultMerger] * superset of [LocalBookMatcher]'s plain text match, since [org.modg.bookshelf.data.metadata.SearchResultMerger]
* also pulls in an owned book reached only via matching one of the online hits (task: * also pulls in an owned book reached only via one of the online hits (task: "an
* "an online hit that is a different edition of an owned book belongs in the library * online hit that is a different edition of an owned book belongs in the library
* section"). [openLibraryFailure]/[googleBooksFailure] are that source's * section"). The shelf/bookcase filter and sort are applied to it; owned matches the
* [org.modg.bookshelf.data.metadata.SourceResult.Failed.reason]-equivalent, null when * filter hides are counted in [inLibraryOutsideFilter] instead of silently vanishing —
* the merge still (correctly) keeps them out of the online section, so without the count
* a book you own on another shelf would appear in neither.
* [openLibraryFailure]/[googleBooksFailure] are that source's failure reason, null when
* that source answered (whether or not it had anything) — the UI keys off these to * that source answered (whether or not it had anything) — the UI keys off these to
* show a per-source failure line with Retry while still showing the other source's * show a per-source failure line with Retry while still showing the other source's
* [online] results, and to tell "both failed" (retry affordance, never "no results") * [online] results, and to tell "both failed" (retry affordance, never "no results")
@@ -85,6 +88,7 @@ sealed interface OnlineSearchState {
val online: List<OnlineBook>, val online: List<OnlineBook>,
val openLibraryFailure: String?, val openLibraryFailure: String?,
val googleBooksFailure: String?, val googleBooksFailure: String?,
val inLibraryOutsideFilter: Int = 0,
) : OnlineSearchState ) : OnlineSearchState
} }
@@ -68,13 +68,16 @@ import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.metadata.BookMetadata import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.data.metadata.OnlineBook import org.modg.bookshelf.data.metadata.OnlineBook
import org.modg.bookshelf.data.repo.decodeAuthors import org.modg.bookshelf.data.repo.decodeAuthors
import org.modg.bookshelf.data.repo.CoverPreloadState
import org.modg.bookshelf.ui.components.BookCover import org.modg.bookshelf.ui.components.BookCover
import org.modg.bookshelf.ui.components.CoverPreloadNotice
import org.modg.bookshelf.ui.components.EmptyState import org.modg.bookshelf.ui.components.EmptyState
import org.modg.bookshelf.ui.components.BookshelfScaffold import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.GoldDivider import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.components.PrimaryButton import org.modg.bookshelf.ui.components.PrimaryButton
import org.modg.bookshelf.ui.components.SecondaryButton import org.modg.bookshelf.ui.components.SecondaryButton
import org.modg.bookshelf.ui.components.saveButtonLabel
import org.modg.bookshelf.ui.components.SyncStatus import org.modg.bookshelf.ui.components.SyncStatus
import org.modg.bookshelf.ui.components.SyncStatusBar import org.modg.bookshelf.ui.components.SyncStatusBar
import org.modg.bookshelf.ui.scan.ShelfPicker import org.modg.bookshelf.ui.scan.ShelfPicker
@@ -116,6 +119,7 @@ fun LibraryScreen(
settingsStore = container.settingsStore, settingsStore = container.settingsStore,
bookSearchRepository = container.bookSearchRepository, bookSearchRepository = container.bookSearchRepository,
metadataRepository = container.metadataRepository, metadataRepository = container.metadataRepository,
coverDownloader = container.coverDownloader,
initialShelfId = shelfIdFilter, initialShelfId = shelfIdFilter,
) )
} }
@@ -129,6 +133,7 @@ fun LibraryScreen(
val onlineSearch by viewModel.onlineSearch.collectAsState() val onlineSearch by viewModel.onlineSearch.collectAsState()
val saveSheet by viewModel.saveSheet.collectAsState() val saveSheet by viewModel.saveSheet.collectAsState()
val recentShelfId by viewModel.recentShelfId.collectAsState() val recentShelfId by viewModel.recentShelfId.collectAsState()
val cover by viewModel.cover.collectAsState()
BookshelfScaffold( BookshelfScaffold(
title = "Bookshelf", title = "Bookshelf",
@@ -183,7 +188,11 @@ fun LibraryScreen(
when { when {
query.isNotBlank() -> LibrarySearchResultsContent( query.isNotBlank() -> LibrarySearchResultsContent(
query = query, query = query,
localMatches = uiState.books, // Once an online search has run, the merged set — it includes owned
// editions reached only through an online hit, which the plain text
// match can't find and the merge has already removed from "Online".
localMatches = (onlineSearch as? OnlineSearchState.Done)?.inLibrary ?: uiState.books,
inLibraryOutsideFilter = (onlineSearch as? OnlineSearchState.Done)?.inLibraryOutsideFilter ?: 0,
onlineSearch = onlineSearch, onlineSearch = onlineSearch,
onBookClick = onBookClick, onBookClick = onBookClick,
onSubmitOnlineSearch = viewModel::submitOnlineSearch, onSubmitOnlineSearch = viewModel::submitOnlineSearch,
@@ -235,8 +244,10 @@ fun LibraryScreen(
recentShelfId = recentShelfId, recentShelfId = recentShelfId,
onShelfSelected = viewModel::onSaveSheetShelfSelected, onShelfSelected = viewModel::onSaveSheetShelfSelected,
onSave = viewModel::saveOnlineResult, onSave = viewModel::saveOnlineResult,
onEditDetails = { onEditOnlineResult(sheetState.book, sheetState.enrichment) }, onEditDetails = { viewModel.takeForEditing()?.let { (book, enrichment) -> onEditOnlineResult(book, enrichment) } },
onSkip = viewModel::dismissSaveSheet, onSkip = viewModel::dismissSaveSheet,
cover = cover,
onRetryCover = viewModel::retryCover,
) )
} }
} }
@@ -408,6 +419,7 @@ internal fun LibrarySearchResultsContent(
onOnlineResultClick: (OnlineBook) -> Unit, onOnlineResultClick: (OnlineBook) -> Unit,
onEnterByHand: () -> Unit, onEnterByHand: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
inLibraryOutsideFilter: Int = 0,
) { ) {
Column( Column(
modifier = modifier modifier = modifier
@@ -439,6 +451,20 @@ internal fun LibrarySearchResultsContent(
} }
} }
if (inLibraryOutsideFilter > 0) {
Text(
text = if (inLibraryOutsideFilter == 1) {
"1 more in your library, outside the current filter"
} else {
"$inLibraryOutsideFilter more in your library, outside the current filter"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
// Extra top space: at 4dp it sat under the last card's author and read as its byline.
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
)
}
GoldDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) GoldDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
SearchSectionHeader(text = "Online") SearchSectionHeader(text = "Online")
OnlineSearchSection( OnlineSearchSection(
@@ -632,6 +658,8 @@ internal fun OnlineResultSaveSheet(
onSave: () -> Unit, onSave: () -> Unit,
onEditDetails: () -> Unit, onEditDetails: () -> Unit,
onSkip: () -> Unit, onSkip: () -> Unit,
cover: CoverPreloadState = CoverPreloadState.None,
onRetryCover: () -> Unit = {},
) { ) {
val book = state.book val book = state.book
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
@@ -674,12 +702,13 @@ internal fun OnlineResultSaveSheet(
recentShelfId = recentShelfId, recentShelfId = recentShelfId,
onShelfSelected = onShelfSelected, onShelfSelected = onShelfSelected,
) )
CoverPreloadNotice(state = cover, onRetry = onRetryCover)
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
SecondaryButton(text = "Skip", onClick = onSkip, modifier = Modifier.weight(1f)) SecondaryButton(text = "Skip", onClick = onSkip, modifier = Modifier.weight(1f))
SecondaryButton(text = "Edit details", onClick = onEditDetails, modifier = Modifier.weight(1f)) SecondaryButton(text = "Edit details", onClick = onEditDetails, modifier = Modifier.weight(1f))
} }
PrimaryButton( PrimaryButton(
text = if (state.isSaving) "Saving…" else "Save", text = saveButtonLabel(state.isSaving, cover),
enabled = !state.isSaving, enabled = !state.isSaving,
onClick = onSave, onClick = onSave,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
@@ -3,6 +3,8 @@ package org.modg.bookshelf.ui.library
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -10,6 +12,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -17,6 +20,7 @@ import kotlinx.coroutines.launch
import org.modg.bookshelf.data.local.BookEntity import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.BookcaseEntity import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.data.metadata.BookSearchRepository import org.modg.bookshelf.data.metadata.BookSearchRepository
import org.modg.bookshelf.data.metadata.IsbnUtils import org.modg.bookshelf.data.metadata.IsbnUtils
import org.modg.bookshelf.data.metadata.LookupResult import org.modg.bookshelf.data.metadata.LookupResult
@@ -27,6 +31,9 @@ import org.modg.bookshelf.data.metadata.SearchResultMerger
import org.modg.bookshelf.data.metadata.SearchSourceResult import org.modg.bookshelf.data.metadata.SearchSourceResult
import org.modg.bookshelf.data.prefs.SettingsStore import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.CoverDownloader
import org.modg.bookshelf.data.repo.CoverPreload
import org.modg.bookshelf.data.repo.CoverPreloadState
import org.modg.bookshelf.data.repo.LocationRepository import org.modg.bookshelf.data.repo.LocationRepository
import org.modg.bookshelf.data.repo.SyncEngine import org.modg.bookshelf.data.repo.SyncEngine
import org.modg.bookshelf.data.repo.SyncResult import org.modg.bookshelf.data.repo.SyncResult
@@ -49,7 +56,10 @@ class LibraryViewModel(
private val settingsStore: SettingsStore, private val settingsStore: SettingsStore,
private val bookSearchRepository: BookSearchRepository, private val bookSearchRepository: BookSearchRepository,
private val metadataRepository: MetadataRepository, private val metadataRepository: MetadataRepository,
coverDownloader: CoverDownloader,
initialShelfId: String?, initialShelfId: String?,
/** Where the per-keystroke match and the search merge run — off the main thread. Tests pass their own. */
computeDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ViewModel() { ) : ViewModel() {
private val _query = MutableStateFlow("") private val _query = MutableStateFlow("")
@@ -81,6 +91,12 @@ class LibraryViewModel(
private val allBooks: StateFlow<List<BookEntity>> = bookRepository.observeAll() private val allBooks: StateFlow<List<BookEntity>> = bookRepository.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/** [allBooks] normalized once per change, not once per keystroke — see [LocalBookMatcher.IndexedBook]. */
private val allBooksIndex: StateFlow<List<LocalBookMatcher.IndexedBook>> = allBooks
.map { LocalBookMatcher.index(it) }
.flowOn(computeDispatcher)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val hasAnyBooks: StateFlow<Boolean> = allBooks private val hasAnyBooks: StateFlow<Boolean> = allBooks
.map { it.isNotEmpty() } .map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
@@ -91,9 +107,9 @@ class LibraryViewModel(
* single whole-string LIKE for this screen, which couldn't match "hobbit tolkien" * single whole-string LIKE for this screen, which couldn't match "hobbit tolkien"
* at all. A blank query means "browse everything", not "match nothing". * at all. A blank query means "browse everything", not "match nothing".
*/ */
private val localMatches: StateFlow<List<BookEntity>> = combine(_query, allBooks) { queryValue, books -> private val localMatches: StateFlow<List<BookEntity>> = combine(_query, allBooksIndex) { queryValue, index ->
if (queryValue.isBlank()) books else LocalBookMatcher.match(books, queryValue) if (queryValue.isBlank()) index.map { it.book } else LocalBookMatcher.matchIndexed(index, queryValue)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) }.flowOn(computeDispatcher).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val filteredSortedBooks: StateFlow<List<BookEntity>> = combine( private val filteredSortedBooks: StateFlow<List<BookEntity>> = combine(
localMatches, localMatches,
@@ -129,22 +145,46 @@ class LibraryViewModel(
* sync) immediately migrates itself from "Online" into "In your library" via the * sync) immediately migrates itself from "Online" into "In your library" via the
* normal Room flow, with no hand-written "move it over" step. * normal Room flow, with no hand-written "move it over" step.
*/ */
val onlineSearch: StateFlow<OnlineSearchState> = combine(_onlineSearchPhase, allBooks) { phase, books -> val onlineSearch: StateFlow<OnlineSearchState> = combine(
_onlineSearchPhase,
allBooksIndex,
_filter,
_sortOption,
shelfIdsByBookcase,
) { phase, index, filterValue, sort, shelfMap ->
when (phase) { when (phase) {
is OnlineSearchPhase.NotSearched -> OnlineSearchState.NotSearched is OnlineSearchPhase.NotSearched -> OnlineSearchState.NotSearched
is OnlineSearchPhase.Searching -> OnlineSearchState.Searching(phase.query) is OnlineSearchPhase.Searching -> OnlineSearchState.Searching(phase.query)
is OnlineSearchPhase.Done -> buildDoneState(phase.hits, books) is OnlineSearchPhase.Done -> buildDoneState(phase.hits, index, filterValue, sort, shelfMap)
} }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), OnlineSearchState.NotSearched) }.flowOn(computeDispatcher).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), OnlineSearchState.NotSearched)
private fun buildDoneState(raw: RawHits, books: List<BookEntity>): OnlineSearchState.Done { /**
* The merge runs against the WHOLE library (ownership doesn't depend on the filter),
* then the shelf/bookcase filter and sort are applied to what "In your library" shows.
*/
private fun buildDoneState(
raw: RawHits,
index: List<LocalBookMatcher.IndexedBook>,
filterValue: LibraryFilter,
sort: LibrarySortOption,
shelfMap: Map<String, Set<String>>,
): OnlineSearchState.Done {
val onlineIsbns = (raw.openLibrary.asSequence() + raw.googleBooks.asSequence()) val onlineIsbns = (raw.openLibrary.asSequence() + raw.googleBooks.asSequence())
.flatMap { it.isbn13s } .flatMap { it.isbn13s }
.toSet() .toSet()
val isbnLinked = books.filter { book -> bookIsbn13s(book).any { it in onlineIsbns } } val isbnLinked = index.map { it.book }.filter { book -> bookIsbn13s(book).any { it in onlineIsbns } }
val candidates = (LocalBookMatcher.match(books, raw.query) + isbnLinked).distinctBy { it.id } val candidates = (LocalBookMatcher.matchIndexed(index, raw.query) + isbnLinked).distinctBy { it.id }
val merged = SearchResultMerger.merge(candidates, raw.openLibrary, raw.googleBooks) val merged = SearchResultMerger.merge(candidates, raw.openLibrary, raw.googleBooks)
return OnlineSearchState.Done(raw.query, merged.inLibrary, merged.online, raw.openLibraryFailure, raw.googleBooksFailure) val visible = LibraryFilterLogic.apply(merged.inLibrary, filterValue, shelfMap)
return OnlineSearchState.Done(
query = raw.query,
inLibrary = LibrarySort.sort(visible, sort),
online = merged.online,
openLibraryFailure = raw.openLibraryFailure,
googleBooksFailure = raw.googleBooksFailure,
inLibraryOutsideFilter = merged.inLibrary.size - visible.size,
)
} }
private fun bookIsbn13s(book: BookEntity): List<String> = private fun bookIsbn13s(book: BookEntity): List<String> =
@@ -156,6 +196,10 @@ class LibraryViewModel(
val saveSheet: StateFlow<OnlineSaveSheetState> = _saveSheet.asStateFlow() val saveSheet: StateFlow<OnlineSaveSheetState> = _saveSheet.asStateFlow()
private var enrichmentJob: Job? = null private var enrichmentJob: Job? = null
/** The save sheet's cover, downloading from the moment the sheet opens — one result at a time, never the whole list. */
private val coverPreload = CoverPreload(coverDownloader, viewModelScope)
val cover: StateFlow<CoverPreloadState> = coverPreload.state
/** Surfaced as the save sheet's shelf picker default, same as the scan/add-by-hand sheets. */ /** Surfaced as the save sheet's shelf picker default, same as the scan/add-by-hand sheets. */
val recentShelfId: StateFlow<String?> = settingsStore.lastShelfId val recentShelfId: StateFlow<String?> = settingsStore.lastShelfId
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@@ -257,6 +301,7 @@ class LibraryViewModel(
fun onOnlineResultTapped(book: OnlineBook) { fun onOnlineResultTapped(book: OnlineBook) {
enrichmentJob?.cancel() enrichmentJob?.cancel()
_saveSheet.value = OnlineSaveSheetState.Shown(book = book, selectedShelfId = recentShelfId.value) _saveSheet.value = OnlineSaveSheetState.Shown(book = book, selectedShelfId = recentShelfId.value)
coverPreload.start(resolveOnlineBookFields(book, enrichment = null).coverUrl)
val isbn13 = book.isbn13 ?: return val isbn13 = book.isbn13 ?: return
enrichmentJob = viewModelScope.launch { enrichmentJob = viewModelScope.launch {
val result = metadataRepository.lookup(isbn13) val result = metadataRepository.lookup(isbn13)
@@ -264,16 +309,33 @@ class LibraryViewModel(
_saveSheet.update { state -> _saveSheet.update { state ->
if (state is OnlineSaveSheetState.Shown && state.book == book) state.copy(enrichment = result.metadata) else state if (state is OnlineSaveSheetState.Shown && state.book == book) state.copy(enrichment = result.metadata) else state
} }
// A no-op unless enrichment supplied the only cover (the hit's own wins, see resolveOnlineBookFields).
val shown = _saveSheet.value as? OnlineSaveSheetState.Shown
if (shown?.book == book) coverPreload.start(resolveOnlineBookFields(book, shown.enrichment).coverUrl)
} }
} }
} }
fun retryCover() = coverPreload.retry()
/**
* "Edit details": closes the sheet and hands its book to the add-by-hand screen. It must
* close here — left open, the sheet was still up (Save enabled) on returning to the
* library after saving that same book from the add screen, one tap from a duplicate.
*/
fun takeForEditing(): Pair<OnlineBook, BookMetadata?>? {
val shown = _saveSheet.value as? OnlineSaveSheetState.Shown ?: return null
dismissSaveSheet()
return shown.book to shown.enrichment
}
fun onSaveSheetShelfSelected(shelfId: String?) { fun onSaveSheetShelfSelected(shelfId: String?) {
_saveSheet.update { if (it is OnlineSaveSheetState.Shown) it.copy(selectedShelfId = shelfId) else it } _saveSheet.update { if (it is OnlineSaveSheetState.Shown) it.copy(selectedShelfId = shelfId) else it }
} }
fun dismissSaveSheet() { fun dismissSaveSheet() {
enrichmentJob?.cancel() enrichmentJob?.cancel()
coverPreload.discard()
_saveSheet.value = OnlineSaveSheetState.Hidden _saveSheet.value = OnlineSaveSheetState.Hidden
} }
@@ -291,11 +353,24 @@ class LibraryViewModel(
internal suspend fun performSaveOnlineResult(): String? { internal suspend fun performSaveOnlineResult(): String? {
val state = _saveSheet.value as? OnlineSaveSheetState.Shown ?: return null val state = _saveSheet.value as? OnlineSaveSheetState.Shown ?: return null
if (state.isSaving) return null if (state.isSaving) return null
val coverAtTap = coverPreload.state.value
_saveSheet.update { if (it is OnlineSaveSheetState.Shown) it.copy(isSaving = true, saveError = null) else it } _saveSheet.update { if (it is OnlineSaveSheetState.Shown) it.copy(isSaving = true, saveError = null) else it }
var shelfId = state.selectedShelfId
val fields = resolveOnlineBookFields(state.book, state.enrichment) val id = try {
return try { // Same cover rule as the scan sheet (see ScanViewModel.performSave): a download
val id = bookRepository.createBook( // that fails while Save waits stops the save; one that had already failed when
// "Save without cover" was tapped does not.
val cover = coverPreload.awaitSettled()
if (cover is CoverPreloadState.Failed && coverAtTap !is CoverPreloadState.Failed) {
_saveSheet.update { if (it is OnlineSaveSheetState.Shown) it.copy(isSaving = false) else it }
return null
}
// Read the sheet again: enrichment may have landed during the wait.
val latest = _saveSheet.value as? OnlineSaveSheetState.Shown ?: state
val fields = resolveOnlineBookFields(latest.book, latest.enrichment)
shelfId = latest.selectedShelfId
bookRepository.createBook(
title = fields.title, title = fields.title,
subtitle = fields.subtitle, subtitle = fields.subtitle,
authors = fields.authors, authors = fields.authors,
@@ -306,23 +381,39 @@ class LibraryViewModel(
pageCount = fields.pageCount, pageCount = fields.pageCount,
description = fields.description, description = fields.description,
coverSourceUrl = fields.coverUrl, coverSourceUrl = fields.coverUrl,
shelfId = state.selectedShelfId, coverFile = (cover as? CoverPreloadState.Ready)?.takeIf { it.url == fields.coverUrl }?.file,
shelfId = shelfId,
) )
rememberShelf(state.selectedShelfId)
enrichmentJob?.cancel()
_saveSheet.value = OnlineSaveSheetState.Hidden
id
} catch (e: CancellationException) { } catch (e: CancellationException) {
_saveSheet.update { if (it is OnlineSaveSheetState.Shown) it.copy(isSaving = false) else it }
throw e throw e
} catch (e: Throwable) { } catch (e: Throwable) {
_saveSheet.update { if (it is OnlineSaveSheetState.Shown) it.copy(isSaving = false, saveError = e.javaClass.simpleName) else it } _saveSheet.update { if (it is OnlineSaveSheetState.Shown) it.copy(isSaving = false, saveError = e.javaClass.simpleName) else it }
null return null
}
// The book is written: nothing below may report a failure (a retry would duplicate it).
dismissSaveSheet() // also discards the preload — createBook copied it
rememberShelf(shelfId)
return id
}
/**
* "Not shelved" (null) must never overwrite the memory — it isn't a shelf. Runs after the
* book is written, so a failure is swallowed rather than reported (see [performSaveOnlineResult]).
*/
private suspend fun rememberShelf(shelfId: String?) {
if (shelfId == null) return
try {
settingsStore.setLastShelfId(shelfId)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
// Losing the "recent shelf" shortcut is not worth an error on a saved book.
} }
} }
/** "Not shelved" (null) must never overwrite the memory — it isn't a shelf. */ override fun onCleared() {
private suspend fun rememberShelf(shelfId: String?) { coverPreload.discard()
if (shelfId != null) settingsStore.setLastShelfId(shelfId)
} }
/** Manual trigger — app start (see init) and pull-to-refresh, per SPEC "Sync design". */ /** Manual trigger — app start (see init) and pull-to-refresh, per SPEC "Sync design". */
@@ -14,28 +14,44 @@ import org.modg.bookshelf.data.repo.decodeAuthors
object LocalBookMatcher { object LocalBookMatcher {
/** /**
* Normalizes [query] and every candidate field, splits the query on whitespace, * One book's searchable text, normalized once. Normalizing every field of every book
* and requires EVERY token to appear in at least one of title/subtitle/authors/ * on each keystroke (Unicode decomposition + regex, on the main thread) measured ~60ms
* isbn13/isbn10 (different tokens may match different fields). Matching also * per keystroke for 2,000 books on the dev server, so a phone would visibly lag;
* checks a space-stripped form of each field so an initials-heavy name like * [index] is rebuilt only when the book list changes.
* "J.R.R. Tolkien" (normalized to "j r r tolkien", per [TextNormalization]) is
* still found by the squashed query token "jrr", not just "j" or "r".
*/ */
fun match(books: List<BookEntity>, query: String): List<BookEntity> { class IndexedBook internal constructor(val book: BookEntity, private val haystacks: List<String>) {
val queryTokens = TextNormalization.tokens(query) private val squashed = haystacks.map { it.replace(" ", "") }
if (queryTokens.isEmpty()) return emptyList()
return books.filter { book -> matches(book, queryTokens) } internal fun matches(queryTokens: List<String>): Boolean =
queryTokens.all { token -> haystacks.any { it.contains(token) } || squashed.any { it.contains(token) } }
} }
private fun matches(book: BookEntity, queryTokens: List<String>): Boolean { fun index(books: List<BookEntity>): List<IndexedBook> = books.map { book ->
val haystacks = buildList { IndexedBook(
book,
buildList {
add(TextNormalization.normalizeForMatching(book.title)) add(TextNormalization.normalizeForMatching(book.title))
book.subtitle?.let { add(TextNormalization.normalizeForMatching(it)) } book.subtitle?.let { add(TextNormalization.normalizeForMatching(it)) }
decodeAuthors(book.authorsJson).forEach { add(TextNormalization.normalizeForMatching(it)) } decodeAuthors(book.authorsJson).forEach { add(TextNormalization.normalizeForMatching(it)) }
book.isbn13?.let { add(TextNormalization.normalizeForMatching(it)) } book.isbn13?.let { add(TextNormalization.normalizeForMatching(it)) }
book.isbn10?.let { add(TextNormalization.normalizeForMatching(it)) } book.isbn10?.let { add(TextNormalization.normalizeForMatching(it)) }
},
)
} }
val squashed = haystacks.map { it.replace(" ", "") }
return queryTokens.all { token -> haystacks.any { it.contains(token) } || squashed.any { it.contains(token) } } /**
* Normalizes [query], splits it on whitespace, and requires EVERY token to appear in at
* least one of title/subtitle/authors/isbn13/isbn10 (different tokens may match different
* fields). Matching also checks a space-stripped form of each field so an initials-heavy
* name like "J.R.R. Tolkien" (normalized to "j r r tolkien", per [TextNormalization]) is
* still found by the squashed query token "jrr", not just "j" or "r".
*/
fun matchIndexed(index: List<IndexedBook>, query: String): List<BookEntity> {
val queryTokens = TextNormalization.tokens(query)
if (queryTokens.isEmpty()) return emptyList()
return index.filter { it.matches(queryTokens) }.map { it.book }
} }
/** Convenience for callers without an index (tests, one-off matches). */
fun match(books: List<BookEntity>, query: String): List<BookEntity> = matchIndexed(index(books), query)
} }
@@ -55,6 +55,9 @@ object ScanMetadataOutcome {
} }
} }
/** The Found sheet's Save button: [isSaving] disables it; [error] names the exception class of a failed save. */
data class ScanSaveStatus(val isSaving: Boolean = false, val error: String? = null)
/** SPEC.md "Continuous mode: ... a running 'added this session' count." */ /** SPEC.md "Continuous mode: ... a running 'added this session' count." */
data class ScanSessionState(val savedCount: Int = 0) { data class ScanSessionState(val savedCount: Int = 0) {
fun withSave(): ScanSessionState = copy(savedCount = savedCount + 1) fun withSave(): ScanSessionState = copy(savedCount = savedCount + 1)
@@ -71,6 +71,9 @@ import org.modg.bookshelf.ui.components.EmptyState
import org.modg.bookshelf.ui.components.PrimaryButton import org.modg.bookshelf.ui.components.PrimaryButton
import org.modg.bookshelf.ui.components.SecondaryButton import org.modg.bookshelf.ui.components.SecondaryButton
import org.modg.bookshelf.ui.components.ShelfPickerSheet import org.modg.bookshelf.ui.components.ShelfPickerSheet
import org.modg.bookshelf.ui.components.CoverPreloadNotice
import org.modg.bookshelf.ui.components.saveButtonLabel
import org.modg.bookshelf.data.repo.CoverPreloadState
/** /**
* SPEC.md "scan" screen: camera + reticle, on-hit bottom sheet, continuous * SPEC.md "scan" screen: camera + reticle, on-hit bottom sheet, continuous
@@ -92,12 +95,15 @@ fun ScanScreen(
locationRepository = container.locationRepository, locationRepository = container.locationRepository,
metadataRepository = container.metadataRepository, metadataRepository = container.metadataRepository,
settingsStore = container.settingsStore, settingsStore = container.settingsStore,
coverDownloader = container.coverDownloader,
) )
} }
}, },
) )
val sheetState by viewModel.sheetState.collectAsState() val sheetState by viewModel.sheetState.collectAsState()
val cover by viewModel.cover.collectAsState()
val saveStatus by viewModel.saveStatus.collectAsState()
val sessionState by viewModel.sessionState.collectAsState() val sessionState by viewModel.sessionState.collectAsState()
val rejectedMessage by viewModel.rejectedMessage.collectAsState() val rejectedMessage by viewModel.rejectedMessage.collectAsState()
val torchEnabled by viewModel.scannerController.torchEnabled.collectAsState() val torchEnabled by viewModel.scannerController.torchEnabled.collectAsState()
@@ -171,6 +177,10 @@ fun ScanScreen(
onShelfSelected = viewModel::selectShelf, onShelfSelected = viewModel::selectShelf,
onSave = { viewModel.save(state.isbn13, state.metadata) }, onSave = { viewModel.save(state.isbn13, state.metadata) },
onSkip = { viewModel.skip() }, onSkip = { viewModel.skip() },
cover = cover,
onRetryCover = viewModel::retryCover,
isSaving = saveStatus.isSaving,
saveError = saveStatus.error,
) )
} }
is ScanSheetState.NotFound -> ModalBottomSheet( is ScanSheetState.NotFound -> ModalBottomSheet(
@@ -330,6 +340,10 @@ internal fun FoundBookSheet(
onShelfSelected: (String?) -> Unit, onShelfSelected: (String?) -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
onSkip: () -> Unit, onSkip: () -> Unit,
cover: CoverPreloadState = CoverPreloadState.None,
onRetryCover: () -> Unit = {},
isSaving: Boolean = false,
saveError: String? = null,
) { ) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Box( Box(
@@ -362,9 +376,23 @@ internal fun FoundBookSheet(
recentShelfId = recentShelfId, recentShelfId = recentShelfId,
onShelfSelected = onShelfSelected, onShelfSelected = onShelfSelected,
) )
CoverPreloadNotice(state = cover, onRetry = onRetryCover)
if (saveError != null) {
Text(
text = "Couldn't save: $saveError",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp),
)
}
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
SecondaryButton(text = "Skip", onClick = onSkip, modifier = Modifier.weight(1f)) SecondaryButton(text = "Skip", onClick = onSkip, modifier = Modifier.weight(1f))
PrimaryButton(text = "Save", onClick = onSave, modifier = Modifier.weight(1f)) PrimaryButton(
text = saveButtonLabel(isSaving, cover),
enabled = !isSaving,
onClick = onSave,
modifier = Modifier.weight(1f),
)
} }
} }
} }
@@ -18,6 +18,9 @@ import org.modg.bookshelf.data.metadata.IsbnUtils
import org.modg.bookshelf.data.metadata.MetadataRepository import org.modg.bookshelf.data.metadata.MetadataRepository
import org.modg.bookshelf.data.prefs.SettingsStore import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.CoverDownloader
import org.modg.bookshelf.data.repo.CoverPreload
import org.modg.bookshelf.data.repo.CoverPreloadState
import org.modg.bookshelf.data.repo.LocationRepository import org.modg.bookshelf.data.repo.LocationRepository
/** /**
@@ -31,12 +34,21 @@ class ScanViewModel(
locationRepository: LocationRepository, locationRepository: LocationRepository,
private val metadataRepository: MetadataRepository, private val metadataRepository: MetadataRepository,
private val settingsStore: SettingsStore, private val settingsStore: SettingsStore,
coverDownloader: CoverDownloader,
val scannerController: ScannerController = ScannerController(), val scannerController: ScannerController = ScannerController(),
) : ViewModel() { ) : ViewModel() {
private val _sheetState = MutableStateFlow<ScanSheetState>(ScanSheetState.Hidden) private val _sheetState = MutableStateFlow<ScanSheetState>(ScanSheetState.Hidden)
val sheetState: StateFlow<ScanSheetState> = _sheetState.asStateFlow() val sheetState: StateFlow<ScanSheetState> = _sheetState.asStateFlow()
/** The Found sheet's cover, downloading from the moment the lookup succeeds — see [CoverPreload]. */
private val coverPreload = CoverPreload(coverDownloader, viewModelScope)
val cover: StateFlow<CoverPreloadState> = coverPreload.state
private val _saveStatus = MutableStateFlow(ScanSaveStatus())
/** The Found sheet's Save: in flight (disables the button) and the last failure, if any. */
val saveStatus: StateFlow<ScanSaveStatus> = _saveStatus.asStateFlow()
private val _sessionState = MutableStateFlow(ScanSessionState()) private val _sessionState = MutableStateFlow(ScanSessionState())
val sessionState: StateFlow<ScanSessionState> = _sessionState.asStateFlow() val sessionState: StateFlow<ScanSessionState> = _sessionState.asStateFlow()
@@ -86,7 +98,9 @@ class ScanViewModel(
try { try {
val duplicate = DuplicateCheck.check(bookRepository.findByIsbn13(isbn13)) val duplicate = DuplicateCheck.check(bookRepository.findByIsbn13(isbn13))
val result = metadataRepository.lookup(isbn13) val result = metadataRepository.lookup(isbn13)
_sheetState.value = ScanMetadataOutcome.from(isbn13, result, duplicate) val outcome = ScanMetadataOutcome.from(isbn13, result, duplicate)
_sheetState.value = outcome
if (outcome is ScanSheetState.Found) coverPreload.start(outcome.metadata.coverUrl)
} catch (e: CancellationException) { } catch (e: CancellationException) {
// Normal control flow (sheet dismissed / screen left mid-lookup) — // Normal control flow (sheet dismissed / screen left mid-lookup) —
// must propagate, never be reported as a failed lookup. // must propagate, never be reported as a failed lookup.
@@ -131,12 +145,33 @@ class ScanViewModel(
viewModelScope.launch { performSave(isbn13, metadata) } viewModelScope.launch { performSave(isbn13, metadata) }
} }
fun retryCover() = coverPreload.retry()
/** /**
* The actual save-from-metadata logic, split out from [save] so tests can await it * The actual save-from-metadata logic, split out from [save] so tests can await it
* directly (as a plain suspend call) instead of racing [viewModelScope]'s launch. * directly (as a plain suspend call) instead of racing [viewModelScope]'s launch.
* Returns whether the book was saved.
*
* Waits for the cover preload if it's still running, so the cover file is written
* with the row. If the download fails DURING that wait the save stops and the sheet
* shows the failure (Retry, or Save again to go ahead without it); if it had already
* failed when Save was tapped, the user saw "Save without cover" and meant it.
*
* Guarded like wave 8's lookup path, which this save path wasn't: [Throwable] is
* caught so a Room failure can't crash the app, [CancellationException] rethrown
* first, and a save already in flight makes a second tap a no-op.
*/ */
internal suspend fun performSave(isbn13: String, metadata: BookMetadata) { internal suspend fun performSave(isbn13: String, metadata: BookMetadata): Boolean {
if (_saveStatus.value.isSaving) return false
val coverAtTap = coverPreload.state.value
_saveStatus.value = ScanSaveStatus(isSaving = true)
val shelfId = _selectedShelfId.value val shelfId = _selectedShelfId.value
try {
val cover = coverPreload.awaitSettled()
if (cover is CoverPreloadState.Failed && coverAtTap !is CoverPreloadState.Failed) {
_saveStatus.value = ScanSaveStatus()
return false
}
bookRepository.createBook( bookRepository.createBook(
title = metadata.title ?: "Untitled", title = metadata.title ?: "Untitled",
subtitle = metadata.subtitle, subtitle = metadata.subtitle,
@@ -148,10 +183,19 @@ class ScanViewModel(
pageCount = metadata.pageCount, pageCount = metadata.pageCount,
description = metadata.description, description = metadata.description,
coverSourceUrl = metadata.coverUrl, coverSourceUrl = metadata.coverUrl,
coverFile = (cover as? CoverPreloadState.Ready)?.file,
shelfId = shelfId, shelfId = shelfId,
) )
} catch (e: CancellationException) {
_saveStatus.value = ScanSaveStatus()
throw e
} catch (e: Throwable) {
_saveStatus.value = ScanSaveStatus(error = e.javaClass.simpleName)
return false
}
rememberShelf(shelfId) rememberShelf(shelfId)
recordSave() recordSave()
return true
} }
/** Save from the manual-entry form shown when metadata lookup misses (SPEC: pre-filled with the scanned ISBN). */ /** Save from the manual-entry form shown when metadata lookup misses (SPEC: pre-filled with the scanned ISBN). */
@@ -172,9 +216,20 @@ class ScanViewModel(
recordSave() recordSave()
} }
/** "Not shelved" (null) must never overwrite the memory — it isn't a shelf. */ /**
* "Not shelved" (null) must never overwrite the memory — it isn't a shelf. Called only
* AFTER the book is written, and a failure here is swallowed: reporting "couldn't save"
* for a book that WAS saved would invite a retry that creates a duplicate.
*/
private suspend fun rememberShelf(shelfId: String?) { private suspend fun rememberShelf(shelfId: String?) {
if (shelfId != null) settingsStore.setLastShelfId(shelfId) if (shelfId == null) return
try {
settingsStore.setLastShelfId(shelfId)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
// The book is saved; losing the "recent shelf" shortcut is not worth an error.
}
} }
private fun recordSave() { private fun recordSave() {
@@ -188,9 +243,15 @@ class ScanViewModel(
fun dismissSheet() { fun dismissSheet() {
_sheetState.value = ScanSheetState.Hidden _sheetState.value = ScanSheetState.Hidden
coverPreload.discard() // saved (createBook copied it) or skipped — either way, not needed
_saveStatus.value = ScanSaveStatus()
scannerController.resetDebounce() scannerController.resetDebounce()
} }
override fun onCleared() {
coverPreload.discard()
}
private companion object { private companion object {
/** How long a rejected-barcode message stays on screen before it auto-clears. */ /** How long a rejected-barcode message stays on screen before it auto-clears. */
const val REJECTED_MESSAGE_MILLIS = 3000L const val REJECTED_MESSAGE_MILLIS = 3000L
@@ -0,0 +1,97 @@
package org.modg.bookshelf.data.repo
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import java.io.File
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.modg.bookshelf.data.local.BookDao
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.BookshelfDatabase
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* [BookRepository.createBook] no longer downloads anything: it takes an already-preloaded
* cover file and copies it into app storage for SyncEngine to upload.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class BookRepositoryCoverTest {
// Not context.cacheDir: a file written there in @Before was observed missing by the test body.
@get:Rule
val tmp = TemporaryFolder()
private lateinit var db: BookshelfDatabase
private lateinit var context: android.content.Context
private lateinit var preloaded: File
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
.allowMainThreadQueries()
.build()
preloaded = tmp.newFile("preloaded-cover").apply { writeBytes(byteArrayOf(1, 2, 3)) }
}
@After
fun tearDown() {
db.close()
}
@Test
fun `a cover file is copied into app storage and recorded as localCoverPath`() = runTest {
val repository = BookRepository(db.bookDao(), context)
val id = repository.createBook(title = "Dune", coverSourceUrl = "https://x/c.jpg", coverFile = preloaded)
val saved = checkNotNull(repository.getById(id))
val local = File(checkNotNull(saved.localCoverPath))
assertEquals(File(context.filesDir, "covers/$id.jpg"), local)
assertArrayEquals(byteArrayOf(1, 2, 3), local.readBytes())
assertEquals("https://x/c.jpg", saved.coverSourceUrl)
assertTrue("a copy: the caller still owns the preload", preloaded.exists())
}
@Test
fun `no cover file means no localCoverPath and no download attempt`() = runTest {
val repository = BookRepository(db.bookDao(), context)
val id = repository.createBook(title = "Dune", coverSourceUrl = "https://x/c.jpg")
assertNull(checkNotNull(repository.getById(id)).localCoverPath)
}
@Test
fun `a failed write deletes the copied cover, leaving nothing unowned in app storage`() = runTest {
val repository = BookRepository(ThrowingUpsertBookDao(db.bookDao()), context)
try {
repository.createBook(title = "Dune", coverFile = preloaded)
fail("expected the write failure to propagate")
} catch (e: IllegalStateException) {
// expected
}
assertEquals(0, File(context.filesDir, "covers").listFiles()?.size ?: 0)
assertTrue(preloaded.exists())
assertTrue(db.bookDao().observeAll().first().isEmpty())
}
private class ThrowingUpsertBookDao(private val delegate: BookDao) : BookDao by delegate {
override suspend fun upsert(book: BookEntity) = throw IllegalStateException("disk full")
}
}
@@ -0,0 +1,206 @@
package org.modg.bookshelf.data.repo
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
/**
* The cover preload that replaced save-time downloading: the file must be ready (or the
* failure visible) before Save, and no file may outlive the sheet that asked for it.
*/
class CoverPreloadTest {
@get:Rule
val tmp = TemporaryFolder()
private val host = FakeCoverHost()
private lateinit var preloadDir: File
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
@Before
fun setUp() {
preloadDir = File(tmp.root, "cover-preload")
}
@After
fun tearDown() {
host.release()
scope.cancel()
}
private val url = "https://covers.example.org/b/id/42-M.jpg"
// --- CoverDownloader ---
@Test
fun `a successful download writes the bytes into the preload dir`() = runBlocking {
host.serve(url, byteArrayOf(9, 8, 7))
val result = host.downloader(preloadDir).download(url)
val file = (result as CoverFetch.Ready).file
assertEquals(preloadDir, file.parentFile)
assertArrayEquals(byteArrayOf(9, 8, 7), file.readBytes())
}
@Test
fun `an http error is Failed with the status in the reason, and leaves no file`() = runBlocking {
host.fail(url, 503)
val result = host.downloader(preloadDir).download(url)
assertTrue(result is CoverFetch.Failed)
assertTrue((result as CoverFetch.Failed).reason.contains("503"))
assertEquals(0, preloadDir.listFiles()?.size ?: 0)
}
@Test
fun `a transport failure is Failed, not a crash, and leaves no file`() = runBlocking {
host.reset(url)
val result = host.downloader(preloadDir).download(url)
assertTrue(result is CoverFetch.Failed)
assertEquals(0, preloadDir.listFiles()?.size ?: 0)
}
@Test
fun `sweepStale deletes only files older than the cutoff`() {
preloadDir.mkdirs()
val old = File(preloadDir, "old").apply { writeBytes(byteArrayOf(1)); setLastModified(1_000) }
val fresh = File(preloadDir, "fresh").apply { writeBytes(byteArrayOf(1)); setLastModified(5_000) }
host.downloader(preloadDir).sweepStale(olderThanMillis = 3_000)
assertFalse(old.exists())
assertTrue(fresh.exists())
}
// --- CoverPreload ---
@Test
fun `start downloads, and starting the same url again does not download twice`() = runBlocking {
host.serve(url)
val preload = CoverPreload(host.downloader(preloadDir), scope)
preload.start(url)
preload.start(url)
val ready = preload.awaitSettled() as CoverPreloadState.Ready
assertTrue(ready.file.exists())
assertEquals(1, host.requestCount(url))
}
@Test
fun `a different url replaces the preload and deletes the old file`() = runBlocking {
val other = "https://covers.example.org/b/id/43-M.jpg"
host.serve(url)
host.serve(other)
val preload = CoverPreload(host.downloader(preloadDir), scope)
preload.start(url)
val first = (preload.awaitSettled() as CoverPreloadState.Ready).file
preload.start(other)
val second = preload.awaitSettled() as CoverPreloadState.Ready
assertFalse(first.exists())
assertEquals(other, second.url)
assertTrue(second.file.exists())
}
@Test
fun `discard deletes the downloaded file`() = runBlocking {
host.serve(url)
val preload = CoverPreload(host.downloader(preloadDir), scope)
preload.start(url)
val file = (preload.awaitSettled() as CoverPreloadState.Ready).file
preload.discard()
assertFalse(file.exists())
assertEquals(CoverPreloadState.None, preload.state.value)
}
@Test
fun `a failed preload can be retried`() = runBlocking {
host.reset(url)
val preload = CoverPreload(host.downloader(preloadDir), scope)
preload.start(url)
assertTrue(preload.awaitSettled() is CoverPreloadState.Failed)
host.serve(url)
preload.retry()
assertTrue(preload.awaitSettled() is CoverPreloadState.Ready)
}
@Test
fun `awaitSettled waits for a download still in flight`() = runBlocking {
host.serve(url)
host.hold()
val preload = CoverPreload(host.downloader(preloadDir, Dispatchers.IO), scope)
preload.start(url)
assertTrue(preload.state.value is CoverPreloadState.Loading)
host.release()
val settled = withTimeout(10_000) { preload.awaitSettled() }
assertTrue(settled is CoverPreloadState.Ready)
}
@Test
fun `a download discarded while in flight leaves no file behind`() = runBlocking {
host.serve(url)
host.hold()
val preload = CoverPreload(host.downloader(preloadDir, Dispatchers.IO), scope)
preload.start(url)
pollUntil { host.requestCount(url) == 1 } // blocked inside the HTTP call
preload.discard()
host.release()
// The blocked call returns on its IO thread after cancellation and must clean up after itself.
pollUntil { (preloadDir.listFiles()?.size ?: 0) == 0 }
Thread.sleep(200) // and nothing lands afterwards
assertEquals(CoverPreloadState.None, preload.state.value)
assertEquals(0, preloadDir.listFiles()?.size ?: 0)
}
/** Bounded: a condition that never comes true fails the test instead of hanging the build. */
private fun pollUntil(condition: () -> Boolean) {
repeat(500) {
if (condition()) return
Thread.sleep(10)
}
fail("condition not met within 5s")
}
@Test
fun `a ready file that has vanished is downloaded again rather than handed to save`() = runBlocking {
host.serve(url)
val preload = CoverPreload(host.downloader(preloadDir), scope)
preload.start(url)
(preload.awaitSettled() as CoverPreloadState.Ready).file.delete() // e.g. Android cleared the cache dir
val settled = preload.awaitSettled() as CoverPreloadState.Ready
assertTrue(settled.file.exists())
assertEquals(2, host.requestCount(url))
assertEquals(url, preload.state.first().let { (it as CoverPreloadState.Ready).url })
}
}
@@ -0,0 +1,68 @@
package org.modg.bookshelf.data.repo
import java.io.File
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
/**
* An image host for cover-preload tests, answered by an interceptor (no sockets). Each URL
* gets bytes, an HTTP error code, or a thrown IOException; anything unregistered is a 404.
* [hold] makes downloads block until [release], to test Save waiting on a preload.
*/
class FakeCoverHost {
private sealed interface Answer {
class Bytes(val bytes: ByteArray) : Answer
class Code(val code: Int) : Answer
class Throw(val e: IOException) : Answer
}
private val answers = ConcurrentHashMap<String, Answer>()
private val requests = ConcurrentHashMap<String, Int>()
@Volatile private var gate: CountDownLatch? = null
fun serve(url: String, bytes: ByteArray = byteArrayOf(1, 2, 3, 4)) { answers[url] = Answer.Bytes(bytes) }
fun fail(url: String, code: Int) { answers[url] = Answer.Code(code) }
fun reset(url: String, message: String = "connection reset") { answers[url] = Answer.Throw(IOException(message)) }
fun requestCount(url: String): Int = requests[url] ?: 0
fun hold() { gate = CountDownLatch(1) }
fun release() { gate?.countDown() }
val client: OkHttpClient = OkHttpClient.Builder().addInterceptor { chain ->
val request = chain.request()
val url = request.url.toString()
requests.merge(url, 1, Int::plus)
gate?.await(10, TimeUnit.SECONDS)
when (val answer = answers[url]) {
is Answer.Bytes -> response(request, 200, answer.bytes)
is Answer.Code -> response(request, answer.code, ByteArray(0))
is Answer.Throw -> throw answer.e
null -> response(request, 404, ByteArray(0))
}
}.build()
/**
* Downloads run on the calling thread by default, so no IO-thread download can resume
* into a later test after this one's Dispatchers.resetMain. Tests that [hold] must pass
* a real dispatcher instead (a held download would block the test thread itself).
*/
fun downloader(preloadDir: File, dispatcher: CoroutineDispatcher = Dispatchers.Unconfined): CoverDownloader =
CoverDownloader(client, preloadDir, dispatcher)
private fun response(request: okhttp3.Request, code: Int, bytes: ByteArray) = Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(code)
.message("fake")
.body(bytes.toResponseBody("image/jpeg".toMediaType()))
.build()
}
@@ -24,6 +24,8 @@ import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.BookshelfDatabase import org.modg.bookshelf.data.local.BookshelfDatabase
import org.modg.bookshelf.data.prefs.SettingsStore import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.CoverPreloadState
import org.modg.bookshelf.data.repo.FakeCoverHost
import org.modg.bookshelf.data.repo.LocationRepository import org.modg.bookshelf.data.repo.LocationRepository
import org.modg.bookshelf.data.repo.decodeAuthors import org.modg.bookshelf.data.repo.decodeAuthors
import org.modg.bookshelf.ui.scan.DuplicateStatus import org.modg.bookshelf.ui.scan.DuplicateStatus
@@ -42,6 +44,7 @@ class AddBookViewModelTest {
private lateinit var viewModel: AddBookViewModel private lateinit var viewModel: AddBookViewModel
private lateinit var shelfId: String private lateinit var shelfId: String
private lateinit var context: android.content.Context private lateinit var context: android.content.Context
private val coverHost = FakeCoverHost()
@Before @Before
fun setUp() = runTest { fun setUp() = runTest {
@@ -64,6 +67,7 @@ class AddBookViewModelTest {
shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf") shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf")
viewModel = AddBookViewModel( viewModel = AddBookViewModel(
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
bookRepository = bookRepository, bookRepository = bookRepository,
locationRepository = locationRepository, locationRepository = locationRepository,
settingsStore = settingsStore, settingsStore = settingsStore,
@@ -267,6 +271,7 @@ class AddBookViewModelTest {
settingsStore.setLastShelfId(shelfId) settingsStore.setLastShelfId(shelfId)
val vm = AddBookViewModel( val vm = AddBookViewModel(
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
bookRepository = bookRepository, bookRepository = bookRepository,
locationRepository = locationRepository, locationRepository = locationRepository,
settingsStore = settingsStore, settingsStore = settingsStore,
@@ -276,7 +281,95 @@ class AddBookViewModelTest {
assertEquals(shelfId, preSelected.selectedShelfId) assertEquals(shelfId, preSelected.selectedShelfId)
} }
// --- review fixes (2026-09-16) ---
private val coverUrl = "https://covers.example.org/b/id/42-M.jpg"
private fun viewModelWithDraft(draft: BookDraft, dispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.Unconfined) =
AddBookViewModel(
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload"), dispatcher),
bookRepository = bookRepository,
locationRepository = locationRepository,
settingsStore = settingsStore,
initialDraft = draft,
)
@Test
fun `a cover carried in from a search hit is preloaded and saved as the book's cover file`() = runTest {
coverHost.serve(coverUrl, byteArrayOf(7, 7))
val vm = viewModelWithDraft(BookDraft(title = "Dune", coverSourceUrl = coverUrl))
val preloaded = (vm.uiState.first { it.cover is CoverPreloadState.Ready }.cover as CoverPreloadState.Ready).file
val bookId = checkNotNull(vm.performSave(clearAfterSave = false))
val saved = checkNotNull(bookRepository.getById(bookId))
assertTrue(java.io.File(checkNotNull(saved.localCoverPath)).readBytes().contentEquals(byteArrayOf(7, 7)))
assertTrue("the preload is deleted once createBook has copied it", !preloaded.exists())
}
@Test
fun `save waits for a cover download that is still running`() = runTest {
coverHost.serve(coverUrl)
coverHost.hold()
val vm = viewModelWithDraft(BookDraft(title = "Dune", coverSourceUrl = coverUrl), Dispatchers.IO)
Thread { Thread.sleep(200); coverHost.release() }.start()
val bookId = checkNotNull(vm.performSave(clearAfterSave = false))
assertTrue(checkNotNull(bookRepository.getById(bookId)).localCoverPath != null)
}
@Test
fun `a cover that already failed when Save was tapped does not block the save`() = runTest {
coverHost.reset(coverUrl)
val vm = viewModelWithDraft(BookDraft(title = "Dune", coverSourceUrl = coverUrl))
vm.uiState.first { it.cover is CoverPreloadState.Failed }
val bookId = checkNotNull(vm.performSave(clearAfterSave = false))
val saved = checkNotNull(bookRepository.getById(bookId))
assertNull(saved.localCoverPath)
assertEquals(coverUrl, saved.coverSourceUrl)
}
@Test
fun `a cover that fails while Save waits stops the save and keeps the form`() = runTest {
coverHost.fail(coverUrl, 404)
coverHost.hold()
val vm = viewModelWithDraft(BookDraft(title = "Dune", coverSourceUrl = coverUrl), Dispatchers.IO)
Thread { Thread.sleep(200); coverHost.release() }.start()
assertNull(vm.performSave(clearAfterSave = false))
val state = vm.uiState.first { it.cover is CoverPreloadState.Failed }
assertEquals("Dune", state.draft.title)
assertTrue(!state.isSaving)
assertTrue(bookRepository.observeAll().first().isEmpty())
}
@Test
fun `a failing duplicate lookup shows no warning instead of crashing`() = runTest {
val vm = AddBookViewModel(
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
bookRepository = BookRepository(ThrowingFindByIsbnBookDao(db.bookDao(), IllegalStateException("disk full")), context),
locationRepository = locationRepository,
settingsStore = settingsStore,
)
vm.onIsbnChanged("9780441013593") // must not throw out of the launched check
assertEquals(DuplicateStatus.New, vm.uiState.first().duplicate)
}
private class ThrowingFindByIsbnBookDao(
private val delegate: BookDao,
private val toThrow: Throwable,
) : BookDao by delegate {
override suspend fun findByIsbn13(isbn13: String): BookEntity? = throw toThrow
}
private fun viewModelWithThrowingCreateBook(toThrow: Throwable): AddBookViewModel = AddBookViewModel( private fun viewModelWithThrowingCreateBook(toThrow: Throwable): AddBookViewModel = AddBookViewModel(
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
bookRepository = BookRepository(ThrowingUpsertBookDao(db.bookDao(), toThrow), context), bookRepository = BookRepository(ThrowingUpsertBookDao(db.bookDao(), toThrow), context),
locationRepository = locationRepository, locationRepository = locationRepository,
settingsStore = settingsStore, settingsStore = settingsStore,
@@ -34,6 +34,9 @@ import org.modg.bookshelf.data.metadata.MetadataRepository
import org.modg.bookshelf.data.metadata.OnlineBook import org.modg.bookshelf.data.metadata.OnlineBook
import org.modg.bookshelf.data.prefs.SettingsStore import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.CoverDownloader
import org.modg.bookshelf.data.repo.CoverPreloadState
import org.modg.bookshelf.data.repo.FakeCoverHost
import org.modg.bookshelf.data.repo.LocationRepository import org.modg.bookshelf.data.repo.LocationRepository
import org.modg.bookshelf.data.repo.SyncEngine import org.modg.bookshelf.data.repo.SyncEngine
import org.modg.bookshelf.data.repo.decodeAuthors import org.modg.bookshelf.data.repo.decodeAuthors
@@ -59,6 +62,7 @@ class LibraryViewModelTest {
private lateinit var locationRepository: LocationRepository private lateinit var locationRepository: LocationRepository
private lateinit var bookRepository: BookRepository private lateinit var bookRepository: BookRepository
private lateinit var context: android.content.Context private lateinit var context: android.content.Context
private val coverHost = FakeCoverHost()
@Before @Before
fun setUp() { fun setUp() {
@@ -78,7 +82,12 @@ class LibraryViewModelTest {
Dispatchers.resetMain() Dispatchers.resetMain()
} }
private fun viewModel(searchHttpClient: OkHttpClient = OkHttpClient()): LibraryViewModel = LibraryViewModel( private fun viewModel(
searchHttpClient: OkHttpClient = OkHttpClient(),
coverDownloader: CoverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
): LibraryViewModel = LibraryViewModel(
coverDownloader = coverDownloader,
computeDispatcher = Dispatchers.Main,
bookRepository = bookRepository, bookRepository = bookRepository,
locationRepository = locationRepository, locationRepository = locationRepository,
syncEngine = SyncEngine(ApiProvider { null }, db.bookDao(), db.bookcaseDao(), db.shelfDao(), settingsStore), syncEngine = SyncEngine(ApiProvider { null }, db.bookDao(), db.bookcaseDao(), db.shelfDao(), settingsStore),
@@ -200,6 +209,7 @@ class LibraryViewModelTest {
coverUrl = "https://example.com/cover.jpg", coverUrl = "https://example.com/cover.jpg",
isbn13 = "9780441013593", isbn13 = "9780441013593",
) )
coverHost.serve("https://example.com/cover.jpg")
vm.onOnlineResultTapped(onlineBook) vm.onOnlineResultTapped(onlineBook)
vm.onSaveSheetShelfSelected(shelfId) vm.onSaveSheetShelfSelected(shelfId)
@@ -241,6 +251,8 @@ class LibraryViewModelTest {
fun `a throwing save keeps the sheet open with an error and does not throw`() = runTest { fun `a throwing save keeps the sheet open with an error and does not throw`() = runTest {
val throwingBookRepository = BookRepository(ThrowingUpsertBookDao(db.bookDao(), IllegalStateException("disk full")), context) val throwingBookRepository = BookRepository(ThrowingUpsertBookDao(db.bookDao(), IllegalStateException("disk full")), context)
val vm = LibraryViewModel( val vm = LibraryViewModel(
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
computeDispatcher = Dispatchers.Main,
bookRepository = throwingBookRepository, bookRepository = throwingBookRepository,
locationRepository = locationRepository, locationRepository = locationRepository,
syncEngine = SyncEngine(ApiProvider { null }, db.bookDao(), db.bookcaseDao(), db.shelfDao(), settingsStore), syncEngine = SyncEngine(ApiProvider { null }, db.bookDao(), db.bookcaseDao(), db.shelfDao(), settingsStore),
@@ -263,6 +275,8 @@ class LibraryViewModelTest {
@Test @Test
fun `a CancellationException during save propagates rather than being swallowed`() = runTest { fun `a CancellationException during save propagates rather than being swallowed`() = runTest {
val vm = LibraryViewModel( val vm = LibraryViewModel(
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
computeDispatcher = Dispatchers.Main,
bookRepository = BookRepository(ThrowingUpsertBookDao(db.bookDao(), CancellationException("scope cancelled")), context), bookRepository = BookRepository(ThrowingUpsertBookDao(db.bookDao(), CancellationException("scope cancelled")), context),
locationRepository = locationRepository, locationRepository = locationRepository,
syncEngine = SyncEngine(ApiProvider { null }, db.bookDao(), db.bookcaseDao(), db.shelfDao(), settingsStore), syncEngine = SyncEngine(ApiProvider { null }, db.bookDao(), db.bookcaseDao(), db.shelfDao(), settingsStore),
@@ -294,6 +308,94 @@ class LibraryViewModelTest {
assertEquals(1, bookRepository.observeAll().first().size) assertEquals(1, bookRepository.observeAll().first().size)
} }
// --- review fixes (2026-09-16) ---
@Test
fun `an owned edition reached only through an online ISBN is shown in inLibrary, not dropped`() = runTest {
// Its own text doesn't match the query at all; only the OL work's ISBN list ties it in.
bookRepository.createBook(title = "Warrior of the Hittites", isbn13 = "9781883937386")
val vm = viewModel(fakeSearchClient(olBody = olHit("Hittite Warrior", "Joanne Williamson", isbn = "1883937388"), gbBody = gbEmpty()))
vm.uiState.first { it.books.isNotEmpty() } // the owned book has loaded from Room before the search merges against it
vm.onQueryChange("hittite williamson")
vm.submitOnlineSearch()
val done = vm.onlineSearch.first { it is OnlineSearchState.Done } as OnlineSearchState.Done
assertEquals(listOf("Warrior of the Hittites"), done.inLibrary.map { it.title })
assertTrue(done.online.isEmpty())
}
@Test
fun `the shelf filter narrows inLibrary and counts what it hides instead of dropping it`() = runTest {
val bookcaseId = locationRepository.createBookcase(name = "Living Room")
val top = locationRepository.createShelf(bookcaseId, label = "Top")
val bottom = locationRepository.createShelf(bookcaseId, label = "Bottom")
bookRepository.createBook(title = "Dune", authors = listOf("Frank Herbert"), shelfId = top)
bookRepository.createBook(title = "Dune Messiah", authors = listOf("Frank Herbert"), shelfId = bottom)
val vm = viewModel(fakeSearchClient(olBody = olEmpty(), gbBody = gbEmpty()))
vm.onFilterChange(LibraryFilter.Shelf(top))
vm.onQueryChange("dune herbert")
vm.submitOnlineSearch()
val done = vm.onlineSearch.first { it is OnlineSearchState.Done && it.inLibraryOutsideFilter > 0 } as OnlineSearchState.Done
assertEquals(listOf("Dune"), done.inLibrary.map { it.title })
assertEquals(1, done.inLibraryOutsideFilter)
}
@Test
fun `Edit details closes the sheet, so returning to the library can't save the same book twice`() = runTest {
val cover = "https://covers.example.org/dune.jpg"
coverHost.serve(cover)
val vm = viewModel()
val book = OnlineBook(title = "Dune", coverUrl = cover)
vm.onOnlineResultTapped(book)
val preloaded = (vm.cover.first { it is CoverPreloadState.Ready } as CoverPreloadState.Ready).file
val taken = vm.takeForEditing()
assertEquals(book, taken?.first)
assertEquals(OnlineSaveSheetState.Hidden, vm.saveSheet.value)
assertTrue("the sheet's preloaded cover must not be left behind", !preloaded.exists())
}
@Test
fun `save waits for a cover still downloading and stores the cover file with the book`() = runTest {
val cover = "https://covers.example.org/dune.jpg"
coverHost.serve(cover, byteArrayOf(4, 2))
coverHost.hold()
val vm = viewModel(coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload"), Dispatchers.IO))
vm.onOnlineResultTapped(OnlineBook(title = "Dune", coverUrl = cover))
assertTrue(vm.cover.value is CoverPreloadState.Loading)
Thread { Thread.sleep(200); coverHost.release() }.start()
val bookId = checkNotNull(vm.performSaveOnlineResult())
val saved = checkNotNull(bookRepository.getById(bookId))
val localCover = java.io.File(checkNotNull(saved.localCoverPath))
assertTrue(localCover.readBytes().contentEquals(byteArrayOf(4, 2)))
assertEquals(CoverPreloadState.None, vm.cover.value)
}
@Test
fun `a cover that fails while save waits stops the save, and saving again goes ahead without it`() = runTest {
val cover = "https://covers.example.org/dune.jpg"
coverHost.fail(cover, 503)
coverHost.hold()
val vm = viewModel(coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload"), Dispatchers.IO))
vm.onOnlineResultTapped(OnlineBook(title = "Dune", coverUrl = cover))
Thread { Thread.sleep(200); coverHost.release() }.start()
assertNull("the user hasn't seen the failure yet — don't save past it", vm.performSaveOnlineResult())
assertTrue(vm.cover.value is CoverPreloadState.Failed)
assertTrue(bookRepository.observeAll().first().isEmpty())
val bookId = checkNotNull(vm.performSaveOnlineResult()) // tapped "Save without cover"
val saved = checkNotNull(bookRepository.getById(bookId))
assertNull(saved.localCoverPath)
assertEquals(cover, saved.coverSourceUrl)
}
private class ThrowingUpsertBookDao( private class ThrowingUpsertBookDao(
private val delegate: BookDao, private val delegate: BookDao,
private val toThrow: Throwable, private val toThrow: Throwable,
@@ -3,7 +3,12 @@ package org.modg.bookshelf.ui.scan
import androidx.room.Room import androidx.room.Room
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -21,6 +26,10 @@ import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.data.metadata.MetadataRepository import org.modg.bookshelf.data.metadata.MetadataRepository
import org.modg.bookshelf.data.prefs.SettingsStore import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.CoverPreloadState
import org.modg.bookshelf.data.repo.FakeCoverHost
import kotlinx.coroutines.async
import okhttp3.ResponseBody.Companion.toResponseBody
import org.modg.bookshelf.data.repo.LocationRepository import org.modg.bookshelf.data.repo.LocationRepository
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@@ -31,6 +40,7 @@ import org.robolectric.annotation.Config
* so it survives to the next scanning session — except "Not shelved" (null), * so it survives to the next scanning session — except "Not shelved" (null),
* which must never overwrite what's already remembered. * which must never overwrite what's already remembered.
*/ */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [34]) @Config(sdk = [34])
class ScanViewModelTest { class ScanViewModelTest {
@@ -41,9 +51,13 @@ class ScanViewModelTest {
private lateinit var locationRepository: LocationRepository private lateinit var locationRepository: LocationRepository
private lateinit var shelfId: String private lateinit var shelfId: String
private lateinit var context: android.content.Context private lateinit var context: android.content.Context
private val coverHost = FakeCoverHost()
@Before @Before
fun setUp() = runTest { fun setUp() = runTest {
// The cover preload launches on viewModelScope (Dispatchers.Main); unconfined so it
// runs without idling Robolectric's main looper.
Dispatchers.setMain(UnconfinedTestDispatcher())
context = ApplicationProvider.getApplicationContext() context = ApplicationProvider.getApplicationContext()
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java) db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
.allowMainThreadQueries() .allowMainThreadQueries()
@@ -54,6 +68,7 @@ class ScanViewModelTest {
shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf") shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf")
viewModel = ScanViewModel( viewModel = ScanViewModel(
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
bookRepository = BookRepository(db.bookDao(), context), bookRepository = BookRepository(db.bookDao(), context),
locationRepository = locationRepository, locationRepository = locationRepository,
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }), metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
@@ -64,6 +79,7 @@ class ScanViewModelTest {
@After @After
fun tearDown() { fun tearDown() {
db.close() db.close()
Dispatchers.resetMain()
} }
@Test @Test
@@ -131,7 +147,126 @@ class ScanViewModelTest {
} }
} }
// --- review fixes (2026-09-16): cover preload + a guarded save ---
@Test
fun `a successful lookup preloads its cover, and save stores it as the book's cover file`() = runTest {
val olFixture = fixture("openlibrary_success.json")
val coverUrl = "https://covers.openlibrary.org/b/id/675832-L.jpg" // the fixture's reported cover.large
coverHost.serve(coverUrl, byteArrayOf(5, 5, 5))
val vm = ScanViewModel(
bookRepository = BookRepository(db.bookDao(), context),
locationRepository = locationRepository,
metadataRepository = MetadataRepository(fakeMetadataClient(olFixture), Json { ignoreUnknownKeys = true }),
settingsStore = settingsStore,
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
)
vm.runLookup("9780201558029")
val found = vm.sheetState.value as ScanSheetState.Found
val preloaded = (vm.cover.first { it is CoverPreloadState.Ready } as CoverPreloadState.Ready).file
assertTrue(vm.performSave(found.isbn13, found.metadata))
val saved = db.bookDao().findByIsbn13("9780201558029")
checkNotNull(saved)
assertTrue(java.io.File(checkNotNull(saved.localCoverPath)).readBytes().contentEquals(byteArrayOf(5, 5, 5)))
assertTrue("the preload is discarded once saved", !preloaded.exists())
assertEquals(ScanSheetState.Hidden, vm.sheetState.value)
}
@Test
fun `a failing save is reported on the sheet instead of crashing`() = runTest {
val vm = viewModelWithThrowingUpsert(IllegalStateException("disk full"))
val saved = vm.performSave("9780765326355", BookMetadata(title = "The Way of Kings")) // must not throw
assertTrue(!saved)
assertEquals(ScanSaveStatus(isSaving = false, error = "IllegalStateException"), vm.saveStatus.value)
}
@Test
fun `a CancellationException during save propagates`() = runTest {
val vm = viewModelWithThrowingUpsert(CancellationException("scope cancelled"))
try {
vm.performSave("9780765326355", BookMetadata(title = "The Way of Kings"))
fail("expected CancellationException to propagate")
} catch (e: CancellationException) {
// expected
}
}
@Test
fun `a second tap while a save is in flight does not create a second book`() = runTest {
// Hold the first save inside its database write, so the second tap deterministically
// lands while it is in flight (not after it finished and closed the sheet).
val gate = kotlinx.coroutines.CompletableDeferred<Unit>()
val vm = ScanViewModel(
bookRepository = BookRepository(GatedUpsertBookDao(db.bookDao(), gate), context),
locationRepository = locationRepository,
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
settingsStore = settingsStore,
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
)
val first = kotlinx.coroutines.CoroutineScope(Dispatchers.Unconfined).async {
vm.performSave("9780765326355", BookMetadata(title = "The Way of Kings"))
}
assertTrue(vm.saveStatus.value.isSaving)
val secondSaved = vm.performSave("9780765326355", BookMetadata(title = "The Way of Kings"))
gate.complete(Unit)
assertTrue(!secondSaved)
assertTrue(first.await())
assertEquals(1, db.bookDao().observeAll().first().size)
}
private class GatedUpsertBookDao(
private val delegate: BookDao,
private val gate: kotlinx.coroutines.CompletableDeferred<Unit>,
) : BookDao by delegate {
override suspend fun upsert(book: BookEntity) {
gate.await()
delegate.upsert(book)
}
}
private fun viewModelWithThrowingUpsert(toThrow: Throwable): ScanViewModel = ScanViewModel(
bookRepository = BookRepository(ThrowingUpsertBookDao(db.bookDao(), toThrow), context),
locationRepository = locationRepository,
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
settingsStore = settingsStore,
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
)
private class ThrowingUpsertBookDao(
private val delegate: BookDao,
private val toThrow: Throwable,
) : BookDao by delegate {
override suspend fun upsert(book: BookEntity) = throw toThrow
}
/** Open Library answers with [olBody]; Google Books has nothing. No sockets. */
private fun fakeMetadataClient(olBody: String): OkHttpClient = OkHttpClient.Builder()
.addInterceptor { chain ->
val request = chain.request()
val (code, body) = if (request.url.host.contains("openlibrary")) 200 to olBody else 200 to """{"totalItems": 0}"""
okhttp3.Response.Builder()
.request(request)
.protocol(okhttp3.Protocol.HTTP_1_1)
.code(code)
.message("")
.body(body.toResponseBody(null))
.build()
}
.build()
private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader?.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
.bufferedReader()
.readText()
private fun viewModelWithThrowingBookDao(toThrow: Throwable): ScanViewModel = ScanViewModel( private fun viewModelWithThrowingBookDao(toThrow: Throwable): ScanViewModel = ScanViewModel(
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
bookRepository = BookRepository(ThrowingFindByIsbnBookDao(db.bookDao(), toThrow), context), bookRepository = BookRepository(ThrowingFindByIsbnBookDao(db.bookDao(), toThrow), context),
locationRepository = locationRepository, locationRepository = locationRepository,
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }), metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
@@ -32,6 +32,7 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.modg.bookshelf.data.local.BookEntity import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.metadata.OnlineBook import org.modg.bookshelf.data.metadata.OnlineBook
import org.modg.bookshelf.data.repo.CoverPreloadState
import org.modg.bookshelf.data.metadata.SearchSource import org.modg.bookshelf.data.metadata.SearchSource
import org.modg.bookshelf.ui.components.BookshelfScaffold import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.EmptyState import org.modg.bookshelf.ui.components.EmptyState
@@ -74,12 +75,15 @@ class LibraryScreenPaparazziTest {
fun librarySearchBothSectionsLight() = snapshotBoth("library-search-both-sections") { fun librarySearchBothSectionsLight() = snapshotBoth("library-search-both-sections") {
SearchShell( SearchShell(
localMatches = listOf(ScreenFixtures.books[1]), // "The Hobbit" -- already owned localMatches = listOf(ScreenFixtures.books[1]), // "The Hobbit" -- already owned
// Owned "The Hobbit" is NOT repeated online — the merge puts it in exactly one
// section. The count line is an owned match hidden by the shelf filter.
onlineSearch = OnlineSearchState.Done( onlineSearch = OnlineSearchState.Done(
query = "hobbit", query = "hobbit",
inLibrary = listOf(ScreenFixtures.books[1]), inLibrary = listOf(ScreenFixtures.books[1]),
online = onlineResults, online = onlineResults.drop(1),
openLibraryFailure = null, openLibraryFailure = null,
googleBooksFailure = null, googleBooksFailure = null,
inLibraryOutsideFilter = 1,
), ),
) )
} }
@@ -122,6 +126,26 @@ class LibraryScreenPaparazziTest {
} }
} }
@Test
fun libraryOnlineSaveSheetCoverFailedLight() = snapshotBoth("library-online-save-sheet-cover-failed") {
Surface {
OnlineResultSaveSheet(
state = OnlineSaveSheetState.Shown(
book = onlineResults[0],
selectedShelfId = ScreenFixtures.topShelf.id,
),
bookcases = ScreenFixtures.bookcases,
shelves = ScreenFixtures.shelves,
recentShelfId = ScreenFixtures.topShelf.id,
onShelfSelected = {},
onSave = {},
onEditDetails = {},
onSkip = {},
cover = CoverPreloadState.Failed("https://covers.openlibrary.org/b/id/1-M.jpg", "tls connection reset"),
)
}
}
/** Two online hits: one merged from both sources, one Open-Library-only -- the shape [SearchResultMerger] actually produces. */ /** Two online hits: one merged from both sources, one Open-Library-only -- the shape [SearchResultMerger] actually produces. */
private val onlineResults = listOf( private val onlineResults = listOf(
OnlineBook( OnlineBook(
@@ -179,6 +203,7 @@ class LibraryScreenPaparazziTest {
LibrarySearchResultsContent( LibrarySearchResultsContent(
query = "hobbit", query = "hobbit",
localMatches = localMatches, localMatches = localMatches,
inLibraryOutsideFilter = (onlineSearch as? OnlineSearchState.Done)?.inLibraryOutsideFilter ?: 0,
onlineSearch = onlineSearch, onlineSearch = onlineSearch,
onBookClick = {}, onBookClick = {},
onSubmitOnlineSearch = {}, onSubmitOnlineSearch = {},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 40 KiB

+69
View File
@@ -766,3 +766,72 @@ Logs: `logs/wave-chain.log`, `logs/gate-<task>.log`, `logs/WAVE9-DONE`, `logs/WA
**The gate is not acceptance.** Still owed by the orchestrator for BOTH waves: read each **The gate is not acceptance.** Still owed by the orchestrator for BOTH waves: read each
`.summary`, read the diff, eyeball the new Paparazzi PNGs, check CancellationException is `.summary`, read the diff, eyeball the new Paparazzi PNGs, check CancellationException is
rethrown in every new catch, and check wave 10's dedupe judgement call in its report. rethrown in every new catch, and check wave 10's dedupe judgement call in its report.
## Waves 9 + 10 — REVIEWED 2026-09-16; accepted after a review-fix round
Chain finished 2026-09-15 18:28Z: `b600df6` (wave 9, 241 tests), `cf82cbc` (wave 10, 308).
Orchestrator independently re-ran assembleDebug / testDebugUnitTest / verifyPaparazziDebug on
both — green — then read the diffs and PNGs. The gate was right that they built; it could not
see these, all fixed directly by the orchestrator in the review-fix commit that follows:
1. **"In your library" never showed the merged set.** The screen passed the plain text match
(`uiState.books`) instead of `OnlineSearchState.Done.inLibrary`, so an owned edition reached
only via an online ISBN was removed from "Online" and shown NOWHERE; wave 10's VM test
asserted `inLibrary` but nothing rendered it. Now rendered; the shelf filter applies to it
and hidden owned matches are counted ("1 more in your library, outside the current filter").
2. **"Edit details" left the save sheet open** -> save from the add screen, Back, sheet still up
with Save enabled -> duplicate book. `LibraryViewModel.takeForEditing()` closes it.
3. **`rememberShelf` inside the save `try`** (add + online save): a failed prefs write after a
successful insert showed "Couldn't save" -> retry -> duplicate. Now after, and swallowed.
4. **Per-keystroke search on the main thread**: two regexes recompiled per call, every book
re-normalized per keystroke (~60ms/keystroke at 2,000 books on this server, measured with
jshell). Precompiled regexes, `LocalBookMatcher.index` rebuilt only when books change,
`flowOn(Dispatchers.Default)`.
5. **Add-by-hand duplicate check unguarded** (a Room throw from a keystroke would crash). Guarded.
6. **Scan screen save was unguarded** (wave 8's known gap): now catch/rethrow-cancellation,
in-flight guard, error line. `performSaveManualEntry` (post-lookup NotFound sheet) is STILL
unguarded — its sheet has no error UI; left as is.
### Cover preload — design decided WITH the user, 2026-09-16
User-reported: saving had a distinct delay even with the cover already visible on the sheet.
Cause: `createBook` downloaded the cover (fresh OkHttpClient) BEFORE writing the row, and on
failure silently saved with no `localCoverPath`. User rejected "save first, fetch cover in the
background" (a kill/crash loses the upload; a failure has nowhere to be shown). Their design:
- **Preload** (`data/repo/CoverPreload.kt`): the download starts when a sheet/screen gets a
cover URL — scan Found sheet, online save sheet on open (ONE result, never the whole list —
the user asked), add screen when "Edit details" carries a cover. Into `cacheDir/cover-preload/`.
- **Save waits** for it if still running; `createBook(coverFile=)` COPIES it to
`filesDir/covers/<id>.jpg` (copy, so a failed insert leaves the preload for a retry; the copy
is deleted on insert failure).
- **Failure = Option A (user's choice)**: failure line + Retry on the sheet; Save becomes
"Save without cover". A download that fails WHILE Save waits stops the save (the user hasn't
seen it yet); one that had already failed when tapped proceeds without the file.
- **Cleanup**: preload discarded on skip/dismiss/save/onCleared; `BookshelfApplication` sweeps
files older than process start. NOT Coil's disk cache — public API, but Coil's eviction and
keys aren't a contract; the user agreed it wasn't worth depending on.
- Downloads use `metadataHttpClient` (never the PocketBase-token client).
### Still open after review (not fixed — judgement calls or out of scope)
- Wave 9 has NO worker report (worker hit its wall clock mid-verification, see hazard #11). Its
undocumented calls: small EditNote FAB above Scan; no "More fields…" on the in-scan sheet;
`imePadding` rather than the prompt's `safeDrawingPadding` — needs an on-device look.
- Dedupe (wave 10's own analysis, agreed): false merges for same-author colon series
("Star Wars: X"/"Star Wars: Y") and same-title same-surname authors; one bad edge chains via
union-find; misses translations, surname spelling variants, author-less books.
- Retry re-runs BOTH sources (an OL failure spends a Google Books request).
- Search toolbar still renders as a blank bar in Paparazzi (pre-existing), so no snapshot shows
a query. Online result rows are a non-lazy Column (≤ ~40 thumbnails per search; accepted).
### HAZARD #11 — the lease did NOT stop a freeze; the wall clock counts frozen time
Chain took the lease 2026-09-13 17:30 (expire 3600s). The sprite froze ~17:45 — BEFORE the
first renewal, lease still valid — and stayed frozen ~26.5h until the user connected 09-14
20:21. No reboot (uptime continuous); processes paused (`ps` start times shifted by the gap).
It froze again during K-manual-fix (09-14 20:37 -> 23:43 -> 09-15 11:27 log gaps).
Consequences: (a) an unattended chain only progresses while someone is connected — do not
promise the user overnight progress; (b) `run-task.sh`'s 24h `wall=` counts the freeze, so
K-manual was killed as "WALL CLOCK EXCEEDED" seconds after waking, mid-test-run, with no report.
The 8 red tests the gate then saw were its own unfinished tests. If this recurs, measure elapsed
time with the monotonic clock (`/proc/uptime`), which does not advance while frozen.
Also seen 2026-09-16: Claude Code's background-task runner killed Gradle runs "because the system
is running low on memory" even with ~6GB available (8GB box, no swap; Kotlin daemon ~2.1GB,
Gradle daemon ~1.2GB). Foreground runs of the same command succeeded.
+1
View File
@@ -0,0 +1 @@
chain complete 2026-09-15T18:28:39+00:00
+4
View File
@@ -0,0 +1,4 @@
=== WAVE 10 (L-search) passed the chain gate 2026-09-15T18:28:39+00:00 ===
commit cf82cbc (pushed); tests 308; worker $10.452535, 157 turns
Mechanical gate only. Orchestrator must still review: read logs/L-search.summary,
eyeball new Paparazzi PNGs, and read the diff (git show --stat cf82cbc).