diff --git a/app/app/src/main/java/org/modg/bookshelf/AppContainer.kt b/app/app/src/main/java/org/modg/bookshelf/AppContainer.kt index c9505a0..7186524 100644 --- a/app/app/src/main/java/org/modg/bookshelf/AppContainer.kt +++ b/app/app/src/main/java/org/modg/bookshelf/AppContainer.kt @@ -12,6 +12,7 @@ import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import org.modg.bookshelf.data.local.BookshelfDatabase import org.modg.bookshelf.data.metadata.MetadataRepository +import org.modg.bookshelf.diagnostics.CrashReporter import org.modg.bookshelf.data.prefs.SettingsStore import org.modg.bookshelf.data.remote.ApiProvider import org.modg.bookshelf.data.remote.PbAuthInterceptor @@ -41,6 +42,9 @@ class AppContainer(private val context: Context) { val settingsStore = SettingsStore(context) + /** Installed by [BookshelfApplication.onCreate]; read by the settings screen's Diagnostics section. */ + val crashReporter by lazy { CrashReporter(context) } + private val database by lazy { BookshelfDatabase.build(context) } val bookRepository by lazy { BookRepository(database.bookDao(), context) } diff --git a/app/app/src/main/java/org/modg/bookshelf/BookshelfApplication.kt b/app/app/src/main/java/org/modg/bookshelf/BookshelfApplication.kt index cde3259..b7e220b 100644 --- a/app/app/src/main/java/org/modg/bookshelf/BookshelfApplication.kt +++ b/app/app/src/main/java/org/modg/bookshelf/BookshelfApplication.kt @@ -13,5 +13,8 @@ class BookshelfApplication : Application() { override fun onCreate() { super.onCreate() appContainer = AppContainer(this) + // See CrashReporter's KDoc: born from an on-device crash right after + // the previous commit that could never be reproduced off-device. + appContainer.crashReporter.install() } } diff --git a/app/app/src/main/java/org/modg/bookshelf/data/metadata/GoogleBooksClient.kt b/app/app/src/main/java/org/modg/bookshelf/data/metadata/GoogleBooksClient.kt index cca949c..8ce5877 100644 --- a/app/app/src/main/java/org/modg/bookshelf/data/metadata/GoogleBooksClient.kt +++ b/app/app/src/main/java/org/modg/bookshelf/data/metadata/GoogleBooksClient.kt @@ -1,6 +1,7 @@ package org.modg.bookshelf.data.metadata import java.io.IOException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.SerializationException @@ -17,8 +18,17 @@ import okhttp3.Request * key (the default) sends the request keyless, unchanged from before — this * matters for a fresh clone with no key configured, which must still build a * working app rather than a broken one. - * Never throws: every outcome, including transport failure, comes back as a - * [SourceResult] rather than a swallowed null. + * + * Genuinely never throws (verified 2026-09-12): every outcome, including + * transport failure, comes back as a [SourceResult] rather than a swallowed + * null or an exception reaching the caller. That claim used to be true only + * for [IOException] — the first barcode scanned on a real phone right after + * the Google Books key landed (commit 486f6eb) crashed the process a few + * seconds into the lookup, and this path had never once executed in + * production before that commit (a keyless Google Books call was a + * guaranteed 429). Nobody knows what actually threw; [fetch]'s + * [FailureKind.UNEXPECTED] arm exists so the next one, whatever it is, + * becomes a shown-and-retryable-by-the-user failure instead of a crash. */ class GoogleBooksClient( private val httpClient: OkHttpClient, @@ -48,8 +58,20 @@ class GoogleBooksClient( httpClient.newCall(request).execute().use { response -> classify(response.code, response.body.string(), response.header("Retry-After")) } + } catch (e: CancellationException) { + // Cancellation is normal control flow (the user dismissed the sheet + // or left the screen), not a lookup failure — it must propagate, + // never be reported as a Failed result. Checked first so the + // Throwable arm below can never swallow it. + throw e } catch (e: IOException) { SourceResult.fromException(e) + } catch (e: Throwable) { + // Whatever this is, [fetch] wasn't written to expect it — see the + // class KDoc on the 2026-09-12 crash this exists for. The message + // is deliberately omitted: some exceptions embed the request URL + // (and therefore the API key), same reasoning as [redact] below. + SourceResult.Failed("unexpected: ${e.javaClass.simpleName}", FailureKind.UNEXPECTED) }, ) diff --git a/app/app/src/main/java/org/modg/bookshelf/data/metadata/OpenLibraryClient.kt b/app/app/src/main/java/org/modg/bookshelf/data/metadata/OpenLibraryClient.kt index 51c0018..33ea193 100644 --- a/app/app/src/main/java/org/modg/bookshelf/data/metadata/OpenLibraryClient.kt +++ b/app/app/src/main/java/org/modg/bookshelf/data/metadata/OpenLibraryClient.kt @@ -1,6 +1,7 @@ package org.modg.bookshelf.data.metadata import java.io.IOException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.SerializationException @@ -12,8 +13,12 @@ import okhttp3.Request /** * Open Library lookup — SPEC.md "Book metadata lookup" primary source. - * Never throws: every outcome, including transport failure, comes back as a - * [SourceResult] rather than a swallowed null. + * + * Genuinely never throws (verified 2026-09-12): every outcome, including + * transport failure, comes back as a [SourceResult] rather than a swallowed + * null or an exception reaching the caller. See [GoogleBooksClient]'s KDoc + * for why that claim is dated — the same [FailureKind.UNEXPECTED] reasoning + * applies here. */ class OpenLibraryClient( private val httpClient: OkHttpClient, @@ -41,8 +46,17 @@ class OpenLibraryClient( httpClient.newCall(request).execute().use { response -> classify(response.code, response.body.string(), isbn13) } + } catch (e: CancellationException) { + // See GoogleBooksClient.fetch's identical arm: cancellation is normal + // control flow and must propagate, not become a Failed result. + throw e } catch (e: IOException) { SourceResult.fromException(e) + } catch (e: Throwable) { + // See GoogleBooksClient.fetch and the class KDoc — the 2026-09-12 + // on-device crash this arm exists for. No key to leak here, but the + // message is still omitted for consistency with that reasoning. + SourceResult.Failed("unexpected: ${e.javaClass.simpleName}", FailureKind.UNEXPECTED) } /** diff --git a/app/app/src/main/java/org/modg/bookshelf/data/metadata/RetryPolicy.kt b/app/app/src/main/java/org/modg/bookshelf/data/metadata/RetryPolicy.kt index 66bcae4..c2a3ad4 100644 --- a/app/app/src/main/java/org/modg/bookshelf/data/metadata/RetryPolicy.kt +++ b/app/app/src/main/java/org/modg/bookshelf/data/metadata/RetryPolicy.kt @@ -63,6 +63,8 @@ object RetryPolicy { * changing this blanket answer. * - CLIENT_ERROR — an identical request gets an identical answer. * - MALFORMED — same bytes, same parse failure. + * - UNEXPECTED — we don't know what broke, so we don't know it's safe + * to repeat; see its KDoc. */ fun isRetryable(kind: FailureKind): Boolean = kind == FailureKind.TRANSPORT || kind == FailureKind.SERVER_ERROR diff --git a/app/app/src/main/java/org/modg/bookshelf/data/metadata/SourceResult.kt b/app/app/src/main/java/org/modg/bookshelf/data/metadata/SourceResult.kt index 9327cf1..16f1941 100644 --- a/app/app/src/main/java/org/modg/bookshelf/data/metadata/SourceResult.kt +++ b/app/app/src/main/java/org/modg/bookshelf/data/metadata/SourceResult.kt @@ -32,6 +32,17 @@ enum class FailureKind { /** 2xx whose body we could not parse. Deterministic: the same bytes will fail again. */ MALFORMED, + + /** + * Something neither client's `fetch` was written to expect — not an + * [IOException], not an HTTP status, not a parse failure. Added 2026-09-12 + * after the first barcode scanned on a real phone (right after the Google + * Books key landed, commit 486f6eb) crashed the process; the JVM here has + * never reproduced it, so what actually threw is still unknown. NOT + * retryable: an unknown failure is as likely to repeat whatever damage it + * did as to clear up, so the safer move is to surface it once and stop. + */ + UNEXPECTED, } /** diff --git a/app/app/src/main/java/org/modg/bookshelf/diagnostics/CrashReporter.kt b/app/app/src/main/java/org/modg/bookshelf/diagnostics/CrashReporter.kt new file mode 100644 index 0000000..384e13a --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/diagnostics/CrashReporter.kt @@ -0,0 +1,150 @@ +package org.modg.bookshelf.diagnostics + +import android.content.Context +import android.os.Build +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone +import java.util.concurrent.atomic.AtomicLong + +/** One crash report read back off disk, ready for the settings screen. */ +data class CrashReport( + val file: File, + /** ISO-8601, as written at crash time. */ + val timestamp: String, + /** The stack trace's first line, e.g. "java.lang.RuntimeException: boom". */ + val headline: String, + val fullText: String, +) + +/** + * Installs a process-wide uncaught-exception handler that writes a crash + * report to [Context.filesDir] before the process dies, so a crash on a + * phone this box cannot reach leaves evidence instead of nothing. + * + * Born 2026-09-12: the first barcode scanned after the Google Books key + * landed (commit 486f6eb) crashed the app on the user's phone. It has never + * reproduced since, on-device or off, and static review found nothing — + * see [org.modg.bookshelf.data.metadata.GoogleBooksClient]'s KDoc and + * docs/METADATA-SOURCES.md. That crash is why this class exists: it can't + * tell us what threw this time, but the next one won't vanish the same way. + */ +class CrashReporter(context: Context) { + + private val appContext = context.applicationContext + private val filesDir get() = appContext.filesDir + + /** + * Installs [this], chaining to whatever handler was previously registered + * (the platform default, normally) so Android's own crash dialog and + * process teardown still happen — without chaining, the process would + * hang instead of dying. Call once, from + * [org.modg.bookshelf.BookshelfApplication.onCreate]. Never throws: an + * exception while installing a crash handler would itself take the app + * down before it ever got a chance to protect anything. + */ + fun install() { + runCatching { + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + try { + // The whole point of this handler is to not be the thing + // that goes wrong next — a failure while writing the + // report must never stop the previous handler running. + runCatching { recordCrash(thread, throwable) } + } finally { + previous?.uncaughtException(thread, throwable) + } + } + } + } + + /** + * Writes one crash report file, then trims to [MAX_REPORTS]. Internal + * (not private) so tests can drive it directly without actually crashing + * a thread. + */ + internal fun recordCrash(thread: Thread, throwable: Throwable) { + val stackTrace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString() + val text = buildString { + appendLine("Bookshelf crash report") + appendLine("timestamp: ${isoNow()}") + appendLine("version: ${versionName()} (${versionCode()})") + appendLine("device: ${Build.MODEL}, sdk ${Build.VERSION.SDK_INT}") + appendLine("thread: ${thread.name}") + appendLine(SEPARATOR) + append(stackTrace) + } + File(filesDir, fileName()).writeText(text) + trimToNewest(MAX_REPORTS) + } + + /** Every saved crash report, most recent first. Empty (never throws) if there are none or [filesDir] can't be read. */ + fun list(): List = runCatching { + reportFiles().sortedByDescending { it.name }.mapNotNull(::parse) + }.getOrDefault(emptyList()) + + /** The most recently written crash report, or null if none. This is the only I/O this class does outside a crash. */ + fun latest(): CrashReport? = list().firstOrNull() + + private fun reportFiles(): List = + filesDir.listFiles { f -> f.isFile && f.name.startsWith(FILE_PREFIX) && f.name.endsWith(FILE_SUFFIX) } + ?.toList() + ?: emptyList() + + /** Deletes everything past the newest [keep] — a phone must never accumulate these without bound. */ + private fun trimToNewest(keep: Int) { + reportFiles().sortedByDescending { it.name }.drop(keep).forEach { it.delete() } + } + + private fun parse(file: File): CrashReport? = runCatching { + val text = file.readText() + val headerEnd = text.indexOf("\n$SEPARATOR\n") + val header = if (headerEnd >= 0) text.substring(0, headerEnd) else "" + val trace = if (headerEnd >= 0) text.substring(headerEnd + SEPARATOR.length + 2) else text + val timestamp = header.lineSequence().firstOrNull { it.startsWith("timestamp: ") } + ?.removePrefix("timestamp: ") + ?: "unknown time" + val headline = trace.lineSequence().firstOrNull { it.isNotBlank() } ?: "unknown error" + CrashReport(file = file, timestamp = timestamp, headline = headline, fullText = text) + }.getOrNull() + + private fun versionName(): String = runCatching { + appContext.packageManager.getPackageInfo(appContext.packageName, 0).versionName + }.getOrNull() ?: "unknown" + + private fun versionCode(): Long = runCatching { + val info = appContext.packageManager.getPackageInfo(appContext.packageName, 0) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + info.longVersionCode + } else { + @Suppress("DEPRECATION") + info.versionCode.toLong() + } + }.getOrDefault(-1L) + + private fun isoNow(): String = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } + .format(java.util.Date()) + + /** + * Millis for rough chronological sorting plus a monotonic sequence number + * so two crashes in the same millisecond (readily possible in tests, and + * not provably impossible on-device) never collide and silently overwrite + * each other. + */ + private fun fileName(): String = + String.format(Locale.US, "$FILE_PREFIX%013d-%06d$FILE_SUFFIX", System.currentTimeMillis(), sequence.incrementAndGet()) + + private companion object { + const val MAX_REPORTS = 5 + const val FILE_PREFIX = "crash-" + const val FILE_SUFFIX = ".txt" + const val SEPARATOR = "---" + val sequence = AtomicLong(0) + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanViewModel.kt b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanViewModel.kt index 59a91a5..fcce674 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanViewModel.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanViewModel.kt @@ -2,6 +2,7 @@ package org.modg.bookshelf.ui.scan import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -71,11 +72,28 @@ class ScanViewModel( runLookup(isbn13) } - private suspend fun runLookup(isbn13: String) { + /** + * Internal (not private) so tests can await it directly, same reasoning as + * [performSave]. Wrapped in try/catch because nothing below this point — + * [bookRepository] (Room/disk) or [metadataRepository] (§1's clients) — is + * proven not to throw on a real device; see [GoogleBooksClient]'s KDoc on + * the 2026-09-12 crash. Belt-and-braces with that fix: this is the + * backstop for anything that escapes anywhere else in this function, not + * just the network. + */ + internal suspend fun runLookup(isbn13: String) { _sheetState.value = ScanSheetState.Loading(isbn13) - val duplicate = DuplicateCheck.check(bookRepository.findByIsbn13(isbn13)) - val result = metadataRepository.lookup(isbn13) - _sheetState.value = ScanMetadataOutcome.from(isbn13, result, duplicate) + try { + val duplicate = DuplicateCheck.check(bookRepository.findByIsbn13(isbn13)) + val result = metadataRepository.lookup(isbn13) + _sheetState.value = ScanMetadataOutcome.from(isbn13, result, duplicate) + } catch (e: CancellationException) { + // Normal control flow (sheet dismissed / screen left mid-lookup) — + // must propagate, never be reported as a failed lookup. + throw e + } catch (e: Throwable) { + _sheetState.value = ScanSheetState.LookupFailed(isbn13, "unexpected: ${e.javaClass.simpleName}") + } } /** [ScanSheetState.LookupFailed]'s Retry — re-enters loading and re-runs the same lookup. */ diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsScreen.kt index 17607d3..4330be9 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsScreen.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsScreen.kt @@ -1,10 +1,16 @@ package org.modg.bookshelf.ui.settings +import android.content.ClipData +import android.content.Context +import android.content.Intent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.AlertDialog @@ -18,13 +24,19 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.launch import org.modg.bookshelf.AppContainer +import org.modg.bookshelf.diagnostics.CrashReport import org.modg.bookshelf.ui.components.BookshelfScaffold import org.modg.bookshelf.ui.components.GoldDivider import org.modg.bookshelf.ui.components.PaperSurface @@ -50,12 +62,17 @@ fun SettingsScreen( settingsStore = container.settingsStore, bookRepository = container.bookRepository, syncEngine = container.syncEngine, + crashReporter = container.crashReporter, ) } }, ) val state by viewModel.uiState.collectAsState() var confirmSignOut by remember { mutableStateOf(false) } + var showFullTrace by remember { mutableStateOf(false) } + val context = LocalContext.current + val clipboard = LocalClipboard.current + val coroutineScope = rememberCoroutineScope() BookshelfScaffold( title = "Settings", @@ -99,6 +116,15 @@ fun SettingsScreen( SectionHeading("Library") InfoRow(label = "Books", value = state.bookCount.toString()) InfoRow(label = "Covers", value = state.coverCount.toString()) + + GoldDivider(modifier = Modifier.padding(vertical = 16.dp)) + + SectionHeading("Diagnostics") + DiagnosticsSection( + crash = state.latestCrash, + onViewTrace = { showFullTrace = true }, + onShare = { state.latestCrash?.let { shareCrashReport(context, it.fullText) } }, + ) } } } @@ -117,6 +143,78 @@ fun SettingsScreen( dismissButton = { TextButton(onClick = { confirmSignOut = false }) { Text("Cancel") } }, ) } + + val crash = state.latestCrash + if (showFullTrace && crash != null) { + AlertDialog( + onDismissRequest = { showFullTrace = false }, + title = { Text(text = "Crash report", style = MaterialTheme.typography.titleLarge) }, + text = { + Text( + text = crash.fullText, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState()), + ) + }, + confirmButton = { + TextButton(onClick = { + coroutineScope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("crash report", crash.fullText))) + } + showFullTrace = false + }) { Text("Copy") } + }, + dismissButton = { TextButton(onClick = { showFullTrace = false }) { Text("Close") } }, + ) + } +} + +/** + * SPEC has no dedicated diagnostics entry; added 2026-09-12 alongside + * [org.modg.bookshelf.diagnostics.CrashReporter] so a crash on a phone this + * box cannot reach leaves evidence the user can actually get off the device + * — a screenshot of a stack trace, from another country, is not that. Kept + * as its own composable (rather than inlined into [SettingsScreen]) so + * [org.modg.bookshelf.ui.screens.SettingsScreenPaparazziTest] can snapshot + * both the empty and populated states directly, the same pattern as + * [SectionHeading]/[InfoRow]. + */ +@Composable +internal fun DiagnosticsSection( + crash: CrashReport?, + onViewTrace: () -> Unit, + onShare: () -> Unit, +) { + if (crash == null) { + // An empty section with nothing in it reads as broken, not as good news — say so. + Text( + text = "No crashes recorded.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + return + } + InfoRow(label = "Last crash", value = crash.timestamp) + Text( + text = crash.headline, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + Row(modifier = Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SecondaryButton(text = "View trace", onClick = onViewTrace) + SecondaryButton(text = "Share", onClick = onShare) + } +} + +/** Hands the full report text to any app that accepts plain text — the user is in another country from this machine. */ +private fun shareCrashReport(context: Context, text: String) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + } + context.startActivity(Intent.createChooser(intent, "Share crash report")) } @Composable diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsViewModel.kt b/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsViewModel.kt index 6d6d2e1..6c4edc7 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsViewModel.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsViewModel.kt @@ -2,6 +2,7 @@ package org.modg.bookshelf.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -13,6 +14,8 @@ import org.modg.bookshelf.data.repo.AuthRepository import org.modg.bookshelf.data.repo.BookRepository import org.modg.bookshelf.data.repo.SyncEngine import org.modg.bookshelf.data.repo.SyncResult +import org.modg.bookshelf.diagnostics.CrashReport +import org.modg.bookshelf.diagnostics.CrashReporter import org.modg.bookshelf.ui.components.SyncStatus data class SettingsUiState( @@ -23,6 +26,7 @@ data class SettingsUiState( val lastSyncTime: Long? = null, val isSyncing: Boolean = false, val syncError: String? = null, + val latestCrash: CrashReport? = null, ) { val syncStatus: SyncStatus get() = when { @@ -47,11 +51,22 @@ class SettingsViewModel( private val settingsStore: SettingsStore, private val bookRepository: BookRepository, private val syncEngine: SyncEngine, + private val crashReporter: CrashReporter, ) : ViewModel() { private val isSyncing = MutableStateFlow(false) private val syncError = MutableStateFlow(null) + // Read only when this screen is opened (never at app startup) and off the + // main thread — CrashReporter.list() reads files. See its KDoc. + private val latestCrash = MutableStateFlow(null) + + init { + viewModelScope.launch(Dispatchers.IO) { + latestCrash.value = crashReporter.latest() + } + } + private val baseInfo = combine( authRepository.serverUrl, settingsStore.userEmail, @@ -67,17 +82,19 @@ class SettingsViewModel( ) } - val uiState: StateFlow = combine(baseInfo, isSyncing, syncError) { base, syncing, error -> - SettingsUiState( - serverUrl = base.serverUrl, - userEmail = base.userEmail, - bookCount = base.bookCount, - coverCount = base.coverCount, - lastSyncTime = base.lastSyncTime, - isSyncing = syncing, - syncError = error, - ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) + val uiState: StateFlow = + combine(baseInfo, isSyncing, syncError, latestCrash) { base, syncing, error, crash -> + SettingsUiState( + serverUrl = base.serverUrl, + userEmail = base.userEmail, + bookCount = base.bookCount, + coverCount = base.coverCount, + lastSyncTime = base.lastSyncTime, + isSyncing = syncing, + syncError = error, + latestCrash = crash, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) fun syncNow() { if (isSyncing.value) return diff --git a/app/app/src/test/java/org/modg/bookshelf/data/metadata/GoogleBooksClientTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/metadata/GoogleBooksClientTest.kt index d8c1061..0acb756 100644 --- a/app/app/src/test/java/org/modg/bookshelf/data/metadata/GoogleBooksClientTest.kt +++ b/app/app/src/test/java/org/modg/bookshelf/data/metadata/GoogleBooksClientTest.kt @@ -1,11 +1,15 @@ package org.modg.bookshelf.data.metadata +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json +import okhttp3.Interceptor import okhttp3.OkHttpClient import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Test /** @@ -157,6 +161,56 @@ class GoogleBooksClientTest { assertEquals(clean, client.redact(clean)) } + // --- fetch(): §1 of the 2026-09-12 crash fix -- a non-IOException thrown by the real + // call must become Failed(UNEXPECTED), never escape as a crash, and CancellationException + // is the one exception to that: it must propagate. An Interceptor that throws is the + // straightforward way to provoke this with the real client (no fake seam needed). + + @Test + fun `lookupOnce reports Failed UNEXPECTED, not a crash, when the http call throws a non-IOException`() = runTest { + val client = clientThrowing(IllegalStateException("boom, and here's the key: test-key-123")) + + val result = client.lookupOnce("9780134685991") + + val failed = result as? SourceResult.Failed + checkNotNull(failed) { "expected Failed, got $result" } + assertEquals(FailureKind.UNEXPECTED, failed.kind) + assertEquals("unexpected: IllegalStateException", failed.reason) + } + + @Test + fun `the UNEXPECTED reason names the exception class but never carries its message`() = runTest { + // The message is the one place a leaked key could hide (the same reasoning as + // redact() below) -- it must never reach the reason string at all. + val secretMessage = "connect to https://www.googleapis.com/books/v1/volumes?key=test-key-123 failed" + val client = clientThrowing(IllegalStateException(secretMessage)) + + val failed = client.lookupOnce("9780134685991") as? SourceResult.Failed + checkNotNull(failed) + assertFalse(failed.reason.contains(secretMessage)) + assertFalse(failed.reason.contains("test-key-123")) + assertTrue(failed.reason.contains("IllegalStateException")) + } + + @Test + fun `CancellationException propagates out of the client rather than becoming a Failed`() = runTest { + val client = clientThrowing(CancellationException("scope cancelled")) + + try { + client.lookupOnce("9780134685991") + fail("expected CancellationException to propagate") + } catch (e: CancellationException) { + // expected -- cancellation is normal control flow, not a lookup failure. + } + } + + private fun clientThrowing(t: Throwable): GoogleBooksClient { + val httpClient = OkHttpClient.Builder() + .addInterceptor(Interceptor { throw t }) + .build() + return GoogleBooksClient(httpClient, Json) + } + private fun fixture(name: String): String = checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" } .bufferedReader() diff --git a/app/app/src/test/java/org/modg/bookshelf/data/metadata/OpenLibraryClientTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/metadata/OpenLibraryClientTest.kt index 6e653f3..69e5eca 100644 --- a/app/app/src/test/java/org/modg/bookshelf/data/metadata/OpenLibraryClientTest.kt +++ b/app/app/src/test/java/org/modg/bookshelf/data/metadata/OpenLibraryClientTest.kt @@ -1,9 +1,15 @@ package org.modg.bookshelf.data.metadata +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json +import okhttp3.Interceptor import okhttp3.OkHttpClient import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Test /** @@ -146,6 +152,50 @@ class OpenLibraryClientTest { assertEquals(SourceResult.Failed("malformed json", FailureKind.MALFORMED), result) } + // --- fetch(): §1 of the 2026-09-12 crash fix -- same coverage as GoogleBooksClientTest. + + @Test + fun `lookupOnce reports Failed UNEXPECTED, not a crash, when the http call throws a non-IOException`() = runTest { + val client = clientThrowing(IllegalStateException("boom")) + + val result = client.lookupOnce("9780201558029") + + val failed = result as? SourceResult.Failed + checkNotNull(failed) { "expected Failed, got $result" } + assertEquals(FailureKind.UNEXPECTED, failed.kind) + assertEquals("unexpected: IllegalStateException", failed.reason) + } + + @Test + fun `the UNEXPECTED reason names the exception class but never carries its message`() = runTest { + val secretMessage = "connection reset while reading a great deal of private detail" + val client = clientThrowing(IllegalStateException(secretMessage)) + + val failed = client.lookupOnce("9780201558029") as? SourceResult.Failed + checkNotNull(failed) + assertFalse(failed.reason.contains(secretMessage)) + assertTrue(failed.reason.contains("IllegalStateException")) + } + + @Test + fun `CancellationException propagates out of the client rather than becoming a Failed`() = runTest { + val client = clientThrowing(CancellationException("scope cancelled")) + + try { + client.lookupOnce("9780201558029") + fail("expected CancellationException to propagate") + } catch (e: CancellationException) { + // expected -- cancellation is normal control flow, not a lookup failure. + } + } + + private fun clientThrowing(t: Throwable): OpenLibraryClient { + val httpClient = OkHttpClient.Builder() + .addInterceptor(Interceptor { throw t }) + .build() + return OpenLibraryClient(httpClient, Json) + } + private fun fixture(name: String): String = checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" } .bufferedReader() diff --git a/app/app/src/test/java/org/modg/bookshelf/data/metadata/RetryPolicyTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/metadata/RetryPolicyTest.kt index 1124418..bb69149 100644 --- a/app/app/src/test/java/org/modg/bookshelf/data/metadata/RetryPolicyTest.kt +++ b/app/app/src/test/java/org/modg/bookshelf/data/metadata/RetryPolicyTest.kt @@ -53,6 +53,20 @@ class RetryPolicyTest { assertFalse(RetryPolicy.isRetryable(FailureKind.MALFORMED)) } + @Test + fun `an unexpected failure is not retried -- repeating an unknown failure could repeat its damage`() { + assertFalse(RetryPolicy.isRetryable(FailureKind.UNEXPECTED)) + } + + @Test + fun `withRetry makes exactly one attempt for an unexpected failure`() = runTest { + var calls = 0 + val unexpected = SourceResult.Failed("unexpected: IllegalStateException", FailureKind.UNEXPECTED) + val result = withRetry(sleep = {}) { calls++; unexpected } + assertEquals(1, calls) + assertEquals(unexpected, result) + } + // --- the loop --- @Test diff --git a/app/app/src/test/java/org/modg/bookshelf/diagnostics/CrashReporterTest.kt b/app/app/src/test/java/org/modg/bookshelf/diagnostics/CrashReporterTest.kt new file mode 100644 index 0000000..b1a4ea0 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/diagnostics/CrashReporterTest.kt @@ -0,0 +1,99 @@ +package org.modg.bookshelf.diagnostics + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import java.io.File +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * [CrashReporter.recordCrash] is exercised directly (internal visibility, same + * split as [org.modg.bookshelf.ui.scan.ScanViewModel.performSave]) rather than + * by actually crashing a thread. [CrashReporter.install]'s tests do go through + * the real [Thread.UncaughtExceptionHandler] plumbing, since that chaining + * behavior is the whole point of §3 — see [CrashReporter]'s KDoc. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class CrashReporterTest { + + private lateinit var context: Context + private lateinit var filesDir: File + private lateinit var reporter: CrashReporter + private var originalHandler: Thread.UncaughtExceptionHandler? = null + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + filesDir = context.filesDir + clearReports() + reporter = CrashReporter(context) + originalHandler = Thread.getDefaultUncaughtExceptionHandler() + } + + @After + fun tearDown() { + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + clearReports() + } + + private fun clearReports() { + filesDir.listFiles { f -> f.name.startsWith("crash-") }?.forEach { it.delete() } + } + + private fun reportFiles(): List = filesDir.listFiles { f -> f.name.startsWith("crash-") }?.toList().orEmpty() + + @Test + fun `recordCrash writes one file containing the exception class and its cause's message`() { + val cause = IllegalStateException("disk full") + val throwable = RuntimeException("wrapped", cause) + + reporter.recordCrash(Thread.currentThread(), throwable) + + val files = reportFiles() + assertEquals(1, files.size) + val content = files.single().readText() + assertTrue(content.contains("RuntimeException")) + assertTrue(content.contains("disk full")) + } + + @Test + fun `keeps only the newest 5 crash reports`() { + repeat(7) { i -> reporter.recordCrash(Thread.currentThread(), RuntimeException("crash $i")) } + + assertEquals(5, reportFiles().size) + } + + @Test + fun `list reports the newest crash first`() { + reporter.recordCrash(Thread.currentThread(), RuntimeException("first")) + reporter.recordCrash(Thread.currentThread(), RuntimeException("second")) + + val latest = reporter.latest() + checkNotNull(latest) + assertTrue(latest.headline.contains("second")) + } + + @Test + fun `install records the crash AND delegates to the previously-installed handler`() { + var delegatedTo: Throwable? = null + val previous = Thread.UncaughtExceptionHandler { _, e -> delegatedTo = e } + Thread.setDefaultUncaughtExceptionHandler(previous) + + reporter.install() + val installed = checkNotNull(Thread.getDefaultUncaughtExceptionHandler()) + val throwable = RuntimeException("boom") + installed.uncaughtException(Thread.currentThread(), throwable) + + // Delegation is the load-bearing part -- without it Android never sees the + // crash, and the process hangs instead of dying (see the class KDoc). + assertEquals(throwable, delegatedTo) + assertEquals(1, reportFiles().size) + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/scan/ScanViewModelTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/scan/ScanViewModelTest.kt index 423e12d..786f8ad 100644 --- a/app/app/src/test/java/org/modg/bookshelf/ui/scan/ScanViewModelTest.kt +++ b/app/app/src/test/java/org/modg/bookshelf/ui/scan/ScanViewModelTest.kt @@ -2,15 +2,20 @@ package org.modg.bookshelf.ui.scan import androidx.room.Room import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Before import org.junit.Test 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.modg.bookshelf.data.metadata.BookMetadata import org.modg.bookshelf.data.metadata.MetadataRepository @@ -35,10 +40,11 @@ class ScanViewModelTest { private lateinit var viewModel: ScanViewModel private lateinit var locationRepository: LocationRepository private lateinit var shelfId: String + private lateinit var context: android.content.Context @Before fun setUp() = runTest { - val context = ApplicationProvider.getApplicationContext() + context = ApplicationProvider.getApplicationContext() db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java) .allowMainThreadQueries() .build() @@ -97,4 +103,46 @@ class ScanViewModelTest { assertEquals(shelfId, settingsStore.lastShelfId.first()) } + + // --- runLookup: §2 of the 2026-09-12 crash fix -- nothing below this point, including + // Room itself, is proven not to throw on a real device; runLookup must survive it. --- + + @Test + fun `runLookup surfaces an unexpected repository failure as LookupFailed instead of crashing`() = runTest { + val vm = viewModelWithThrowingBookDao(IllegalStateException("disk full")) + + vm.runLookup("9780765326355") // must not throw + + val failed = vm.sheetState.value as? ScanSheetState.LookupFailed + checkNotNull(failed) { "expected LookupFailed, got ${vm.sheetState.value}" } + assertTrue(failed.reason.contains("unexpected")) + assertTrue(failed.reason.contains("IllegalStateException")) + } + + @Test + fun `runLookup rethrows CancellationException rather than swallowing it into LookupFailed`() = runTest { + val vm = viewModelWithThrowingBookDao(CancellationException("scope cancelled")) + + try { + vm.runLookup("9780765326355") + fail("expected CancellationException to propagate") + } catch (e: CancellationException) { + // expected -- a cancelled lookup is not a failed one. + } + } + + private fun viewModelWithThrowingBookDao(toThrow: Throwable): ScanViewModel = ScanViewModel( + bookRepository = BookRepository(ThrowingFindByIsbnBookDao(db.bookDao(), toThrow), context), + locationRepository = locationRepository, + metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }), + settingsStore = settingsStore, + ) + + /** Delegates every query to the real DAO except [findByIsbn13], which is [ScanViewModel.runLookup]'s first call. */ + private class ThrowingFindByIsbnBookDao( + private val delegate: BookDao, + private val toThrow: Throwable, + ) : BookDao by delegate { + override suspend fun findByIsbn13(isbn13: String): BookEntity? = throw toThrow + } } diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/screens/SettingsScreenPaparazziTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/screens/SettingsScreenPaparazziTest.kt index 354d178..633c3f1 100644 --- a/app/app/src/test/java/org/modg/bookshelf/ui/screens/SettingsScreenPaparazziTest.kt +++ b/app/app/src/test/java/org/modg/bookshelf/ui/screens/SettingsScreenPaparazziTest.kt @@ -12,8 +12,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import app.cash.paparazzi.DeviceConfig import app.cash.paparazzi.Paparazzi +import java.io.File import org.junit.Rule import org.junit.Test +import org.modg.bookshelf.diagnostics.CrashReport import org.modg.bookshelf.ui.components.BookshelfScaffold import org.modg.bookshelf.ui.components.GoldDivider import org.modg.bookshelf.ui.components.PaperSurface @@ -21,6 +23,7 @@ import org.modg.bookshelf.ui.components.PrimaryButton import org.modg.bookshelf.ui.components.SecondaryButton import org.modg.bookshelf.ui.components.SyncStatus import org.modg.bookshelf.ui.components.SyncStatusBar +import org.modg.bookshelf.ui.settings.DiagnosticsSection import org.modg.bookshelf.ui.settings.InfoRow import org.modg.bookshelf.ui.settings.SectionHeading import org.modg.bookshelf.ui.settings.formatLastSync @@ -28,9 +31,11 @@ import org.modg.bookshelf.ui.theme.BookshelfTheme /** * SPEC.md "settings" screen — server, account (now showing the signed-in - * email per the wave-4 fix), sync status, book/cover counts. Rebuilds - * [org.modg.bookshelf.ui.settings.SettingsScreen]'s shell around its own real - * [SectionHeading]/[InfoRow]/[formatLastSync], per the pattern in [ScreenFixtures]. + * email per the wave-4 fix), sync status, book/cover counts, and — added + * 2026-09-12 alongside [org.modg.bookshelf.diagnostics.CrashReporter] — a + * Diagnostics section. Rebuilds [org.modg.bookshelf.ui.settings.SettingsScreen]'s + * shell around its own real [SectionHeading]/[InfoRow]/[formatLastSync]/ + * [DiagnosticsSection], per the pattern in [ScreenFixtures]. */ class SettingsScreenPaparazziTest { @@ -40,11 +45,18 @@ class SettingsScreenPaparazziTest { @Test fun settingsPopulatedLight() = snapshotBoth("settings-populated") { Populated() } - @Composable - private fun Populated() = Shell() + /** SPEC "an empty 'Diagnostics' section that looks broken is worse than none" — the no-crashes state. */ + @Test + fun settingsDiagnosticsEmptyLight() = snapshotBoth("settings-diagnostics-empty") { DiagnosticsEmpty() } @Composable - private fun Shell() { + private fun Populated() = Shell(crash = sampleCrash) + + @Composable + private fun DiagnosticsEmpty() = Shell(crash = null) + + @Composable + private fun Shell(crash: CrashReport?) { val lastSync = System.currentTimeMillis() - 2 * 60 * 1000L BookshelfScaffold( title = "Settings", @@ -77,11 +89,24 @@ class SettingsScreenPaparazziTest { SectionHeading("Library") InfoRow(label = "Books", value = ScreenFixtures.books.size.toString()) InfoRow(label = "Covers", value = "0") + + GoldDivider(modifier = Modifier.padding(vertical = 16.dp)) + + SectionHeading("Diagnostics") + DiagnosticsSection(crash = crash, onViewTrace = {}, onShare = {}) } } } } + private val sampleCrash = CrashReport( + file = File("crash-sample.txt"), + timestamp = "2026-09-12T03:14:07Z", + headline = "java.lang.IllegalStateException: unexpected: IllegalStateException", + fullText = "Bookshelf crash report\ntimestamp: 2026-09-12T03:14:07Z\n---\n" + + "java.lang.IllegalStateException: unexpected: IllegalStateException\n\tat ...", + ) + private fun snapshotBoth(name: String, content: @Composable () -> Unit) { paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } } paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } } diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsDiagnosticsEmptyLight_settings-diagnostics-empty-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsDiagnosticsEmptyLight_settings-diagnostics-empty-dark.png new file mode 100644 index 0000000..0656662 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsDiagnosticsEmptyLight_settings-diagnostics-empty-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsDiagnosticsEmptyLight_settings-diagnostics-empty-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsDiagnosticsEmptyLight_settings-diagnostics-empty-light.png new file mode 100644 index 0000000..63794a7 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsDiagnosticsEmptyLight_settings-diagnostics-empty-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-dark.png index 5396600..f95dee1 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-dark.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-light.png index 94aa248..2b53bf5 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-light.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-light.png differ diff --git a/logs/J-crashsafe.sid b/logs/J-crashsafe.sid new file mode 100644 index 0000000..9ab98ad --- /dev/null +++ b/logs/J-crashsafe.sid @@ -0,0 +1 @@ +c0db9a19-d387-460b-899c-ea5995ce1ead diff --git a/logs/J-crashsafe.summary b/logs/J-crashsafe.summary new file mode 100644 index 0000000..732edc0 --- /dev/null +++ b/logs/J-crashsafe.summary @@ -0,0 +1,15 @@ +=== J-crashsafe attempts=1 quota_waits=0 hard_fails=0 === +cost=$4.0241814 turns=111 err=? +--- result (tail) --- +tingsStore.setLastShelfId`. A Room/disk failure during *save* (as opposed to *lookup*) can still crash the process. The task scoped this fix to the lookup path specifically ("Note that `bookRepository.findByIsbn13(isbn13)` is inside this function too... This is deliberately belt-and-braces with §1"), so I left `performSave` alone rather than expanding scope unasked — but it's the same failure class and worth a future wave if it matters. + +**What I could not do / did differently** + +- Switched `LocalClipboardManager`/`AnnotatedString` to the non-deprecated `LocalClipboard`/`ClipEntry` API for the Copy action, since the deprecated path threw a fresh compiler warning on a file I was actively touching (instructions call out exactly this class of warning as not-cosmetic). Small deviation from the most obvious implementation, done to avoid leaving a new warning behind. +- No new dependencies needed; nothing in `build.gradle.kts` or `libs.versions.toml` touched. +- Did not touch `data/local`, `data/remote`, `data/repo`, `data/prefs`, or `server/`. `AppContainer.kt`/`BookshelfApplication.kt` edits are exactly the `crashReporter` wiring §3 needs. + +**Anything I noticed that looks wrong but is out of scope** + +- As noted above, `performSave`/`performSaveManualEntry` (the *save* path, not lookup) has the same unguarded-Room-call shape that `runLookup` had before this fix. Same failure class, different function. +- `ScanViewModel.manualIsbnEntered` and `retryLookup` both do `viewModelScope.launch { onScanned(...) / runLookup(...) }` — fire-and-forget launches. Since `runLookup` no longer lets anything but `CancellationException` escape, and `viewModelScope` cancellation is exactly what should happen there, this is fine as-is; flagging only because it's adjacent code I read closely. diff --git a/logs/WAVE8-DONE b/logs/WAVE8-DONE new file mode 100644 index 0000000..dee0e46 --- /dev/null +++ b/logs/WAVE8-DONE @@ -0,0 +1,10 @@ +=== WAVE8-DONE written 2026-09-13T11:16:40+00:00 === +Workers finished. The orchestrator was NOT necessarily alive for this. + +--- J-crashsafe --- +[2026-09-12T18:20:37+00:00] J-crashsafe: SUCCESS after 1 attempt(s), 0 quota wait(s) +cost=$4.0241814 turns=111 + +NEXT: orchestrator must independently verify before accepting: + cd ~/bookshelf && ./tasks/gw assembleDebug && ./tasks/gw testDebugUnitTest + git status --porcelain # boundary check: who touched what