Lookup path can't crash the app; persist crash reports for off-device reading

The wave-7 build crashed on the first scan on a real phone and never reproduced.
A live off-device run of the real lookup (93546ed) found nothing wrong with the
parse/merge code. What it did find: nothing on that path was exception-safe. Both
clients caught only IOException, classify caught only serialization errors, and
ScanViewModel.runLookup had no guard at all, so any other throwable — platform
TLS, an OkHttp internal, anything this JVM can't reproduce — killed the process
instead of surfacing on the scan sheet.

- OpenLibraryClient and GoogleBooksClient catch Throwable -> new
  FailureKind.UNEXPECTED, never retried. The reason names the exception CLASS
  only; its message can carry the request URL and therefore the API key.
- ScanViewModel.runLookup guards the same way, which also covers its Room call.
- CancellationException is rethrown ahead of every catch-all: dismissing the
  sheet cancels the lookup, and that must not render as a failure.
- diagnostics.CrashReporter persists uncaught stack traces and chains to the
  previously installed handler in a finally, so the process still dies normally
  even if writing the report fails.
- Settings gains a Diagnostics section: last crash, View trace, Share.

Not fixed, flagged by the worker: performSave/performSaveManualEntry make the
same unguarded Room calls on the save path.

Verified by the orchestrator: assembleDebug (--rerun-tasks) exit 0;
testDebugUnitTest exit 0, 205 tests (was 190), 2 skipped, 0 failures, counted
from TEST-*.xml; verifyPaparazziDebug exit 0; 0 "always 'false'" warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQb3d68LqWD4EdND3C8jkD
This commit is contained in:
Spriteandclaude committed 2026-09-13 11:31:00 +00:00
1 parent 1295d88d6e
commit 34097269c9
23 files changed
+681 -26

No files matched your search

@@ -12,6 +12,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.MetadataRepository 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.prefs.SettingsStore
import org.modg.bookshelf.data.remote.ApiProvider import org.modg.bookshelf.data.remote.ApiProvider
import org.modg.bookshelf.data.remote.PbAuthInterceptor import org.modg.bookshelf.data.remote.PbAuthInterceptor
@@ -41,6 +42,9 @@ class AppContainer(private val context: Context) {
val settingsStore = SettingsStore(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) } private val database by lazy { BookshelfDatabase.build(context) }
val bookRepository by lazy { BookRepository(database.bookDao(), context) } val bookRepository by lazy { BookRepository(database.bookDao(), context) }
@@ -13,5 +13,8 @@ class BookshelfApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
appContainer = AppContainer(this) 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()
} }
} }
@@ -1,6 +1,7 @@
package org.modg.bookshelf.data.metadata package org.modg.bookshelf.data.metadata
import java.io.IOException import java.io.IOException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
@@ -17,8 +18,17 @@ import okhttp3.Request
* key (the default) sends the request keyless, unchanged from before — this * 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 * matters for a fresh clone with no key configured, which must still build a
* working app rather than a broken one. * 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( class GoogleBooksClient(
private val httpClient: OkHttpClient, private val httpClient: OkHttpClient,
@@ -48,8 +58,20 @@ class GoogleBooksClient(
httpClient.newCall(request).execute().use { response -> httpClient.newCall(request).execute().use { response ->
classify(response.code, response.body.string(), response.header("Retry-After")) 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) { } catch (e: IOException) {
SourceResult.fromException(e) 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)
}, },
) )
@@ -1,6 +1,7 @@
package org.modg.bookshelf.data.metadata package org.modg.bookshelf.data.metadata
import java.io.IOException import java.io.IOException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
@@ -12,8 +13,12 @@ import okhttp3.Request
/** /**
* Open Library lookup — SPEC.md "Book metadata lookup" primary source. * 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( class OpenLibraryClient(
private val httpClient: OkHttpClient, private val httpClient: OkHttpClient,
@@ -41,8 +46,17 @@ class OpenLibraryClient(
httpClient.newCall(request).execute().use { response -> httpClient.newCall(request).execute().use { response ->
classify(response.code, response.body.string(), isbn13) 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) { } catch (e: IOException) {
SourceResult.fromException(e) 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)
} }
/** /**
@@ -63,6 +63,8 @@ object RetryPolicy {
* changing this blanket answer. * changing this blanket answer.
* - CLIENT_ERROR — an identical request gets an identical answer. * - CLIENT_ERROR — an identical request gets an identical answer.
* - MALFORMED — same bytes, same parse failure. * - 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 = fun isRetryable(kind: FailureKind): Boolean =
kind == FailureKind.TRANSPORT || kind == FailureKind.SERVER_ERROR kind == FailureKind.TRANSPORT || kind == FailureKind.SERVER_ERROR
@@ -32,6 +32,17 @@ enum class FailureKind {
/** 2xx whose body we could not parse. Deterministic: the same bytes will fail again. */ /** 2xx whose body we could not parse. Deterministic: the same bytes will fail again. */
MALFORMED, 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,
} }
/** /**
@@ -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<CrashReport> = 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<File> =
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)
}
}
@@ -2,6 +2,7 @@ package org.modg.bookshelf.ui.scan
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -71,11 +72,28 @@ class ScanViewModel(
runLookup(isbn13) 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) _sheetState.value = ScanSheetState.Loading(isbn13)
val duplicate = DuplicateCheck.check(bookRepository.findByIsbn13(isbn13)) try {
val result = metadataRepository.lookup(isbn13) val duplicate = DuplicateCheck.check(bookRepository.findByIsbn13(isbn13))
_sheetState.value = ScanMetadataOutcome.from(isbn13, result, duplicate) 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. */ /** [ScanSheetState.LookupFailed]'s Retry — re-enters loading and re-runs the same lookup. */
@@ -1,10 +1,16 @@
package org.modg.bookshelf.ui.settings 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -18,13 +24,19 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.launch
import org.modg.bookshelf.AppContainer import org.modg.bookshelf.AppContainer
import org.modg.bookshelf.diagnostics.CrashReport
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
@@ -50,12 +62,17 @@ fun SettingsScreen(
settingsStore = container.settingsStore, settingsStore = container.settingsStore,
bookRepository = container.bookRepository, bookRepository = container.bookRepository,
syncEngine = container.syncEngine, syncEngine = container.syncEngine,
crashReporter = container.crashReporter,
) )
} }
}, },
) )
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
var confirmSignOut by remember { mutableStateOf(false) } var confirmSignOut by remember { mutableStateOf(false) }
var showFullTrace by remember { mutableStateOf(false) }
val context = LocalContext.current
val clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
BookshelfScaffold( BookshelfScaffold(
title = "Settings", title = "Settings",
@@ -99,6 +116,15 @@ fun SettingsScreen(
SectionHeading("Library") SectionHeading("Library")
InfoRow(label = "Books", value = state.bookCount.toString()) InfoRow(label = "Books", value = state.bookCount.toString())
InfoRow(label = "Covers", value = state.coverCount.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") } }, 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 @Composable
@@ -2,6 +2,7 @@ package org.modg.bookshelf.ui.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow 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.BookRepository
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
import org.modg.bookshelf.diagnostics.CrashReport
import org.modg.bookshelf.diagnostics.CrashReporter
import org.modg.bookshelf.ui.components.SyncStatus import org.modg.bookshelf.ui.components.SyncStatus
data class SettingsUiState( data class SettingsUiState(
@@ -23,6 +26,7 @@ data class SettingsUiState(
val lastSyncTime: Long? = null, val lastSyncTime: Long? = null,
val isSyncing: Boolean = false, val isSyncing: Boolean = false,
val syncError: String? = null, val syncError: String? = null,
val latestCrash: CrashReport? = null,
) { ) {
val syncStatus: SyncStatus val syncStatus: SyncStatus
get() = when { get() = when {
@@ -47,11 +51,22 @@ class SettingsViewModel(
private val settingsStore: SettingsStore, private val settingsStore: SettingsStore,
private val bookRepository: BookRepository, private val bookRepository: BookRepository,
private val syncEngine: SyncEngine, private val syncEngine: SyncEngine,
private val crashReporter: CrashReporter,
) : ViewModel() { ) : ViewModel() {
private val isSyncing = MutableStateFlow(false) private val isSyncing = MutableStateFlow(false)
private val syncError = MutableStateFlow<String?>(null) private val syncError = MutableStateFlow<String?>(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<CrashReport?>(null)
init {
viewModelScope.launch(Dispatchers.IO) {
latestCrash.value = crashReporter.latest()
}
}
private val baseInfo = combine( private val baseInfo = combine(
authRepository.serverUrl, authRepository.serverUrl,
settingsStore.userEmail, settingsStore.userEmail,
@@ -67,17 +82,19 @@ class SettingsViewModel(
) )
} }
val uiState: StateFlow<SettingsUiState> = combine(baseInfo, isSyncing, syncError) { base, syncing, error -> val uiState: StateFlow<SettingsUiState> =
SettingsUiState( combine(baseInfo, isSyncing, syncError, latestCrash) { base, syncing, error, crash ->
serverUrl = base.serverUrl, SettingsUiState(
userEmail = base.userEmail, serverUrl = base.serverUrl,
bookCount = base.bookCount, userEmail = base.userEmail,
coverCount = base.coverCount, bookCount = base.bookCount,
lastSyncTime = base.lastSyncTime, coverCount = base.coverCount,
isSyncing = syncing, lastSyncTime = base.lastSyncTime,
syncError = error, isSyncing = syncing,
) syncError = error,
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) latestCrash = crash,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
fun syncNow() { fun syncNow() {
if (isSyncing.value) return if (isSyncing.value) return
@@ -1,11 +1,15 @@
package org.modg.bookshelf.data.metadata package org.modg.bookshelf.data.metadata
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test import org.junit.Test
/** /**
@@ -157,6 +161,56 @@ class GoogleBooksClientTest {
assertEquals(clean, client.redact(clean)) 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 = private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" } checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
.bufferedReader() .bufferedReader()
@@ -1,9 +1,15 @@
package org.modg.bookshelf.data.metadata package org.modg.bookshelf.data.metadata
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test import org.junit.Test
/** /**
@@ -146,6 +152,50 @@ class OpenLibraryClientTest {
assertEquals(SourceResult.Failed("malformed json", FailureKind.MALFORMED), result) 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 = private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" } checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
.bufferedReader() .bufferedReader()
@@ -53,6 +53,20 @@ class RetryPolicyTest {
assertFalse(RetryPolicy.isRetryable(FailureKind.MALFORMED)) 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 --- // --- the loop ---
@Test @Test
@@ -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<File> = 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)
}
}
@@ -2,15 +2,20 @@ 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.flow.first import kotlinx.coroutines.flow.first
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
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith 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.local.BookshelfDatabase
import org.modg.bookshelf.data.metadata.BookMetadata import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.data.metadata.MetadataRepository import org.modg.bookshelf.data.metadata.MetadataRepository
@@ -35,10 +40,11 @@ class ScanViewModelTest {
private lateinit var viewModel: ScanViewModel private lateinit var viewModel: ScanViewModel
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
@Before @Before
fun setUp() = runTest { fun setUp() = runTest {
val context = ApplicationProvider.getApplicationContext<android.content.Context>() context = ApplicationProvider.getApplicationContext()
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java) db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
.allowMainThreadQueries() .allowMainThreadQueries()
.build() .build()
@@ -97,4 +103,46 @@ class ScanViewModelTest {
assertEquals(shelfId, settingsStore.lastShelfId.first()) 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
}
} }
@@ -12,8 +12,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi import app.cash.paparazzi.Paparazzi
import java.io.File
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.modg.bookshelf.diagnostics.CrashReport
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
@@ -21,6 +23,7 @@ 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.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.settings.DiagnosticsSection
import org.modg.bookshelf.ui.settings.InfoRow import org.modg.bookshelf.ui.settings.InfoRow
import org.modg.bookshelf.ui.settings.SectionHeading import org.modg.bookshelf.ui.settings.SectionHeading
import org.modg.bookshelf.ui.settings.formatLastSync 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 * SPEC.md "settings" screen — server, account (now showing the signed-in
* email per the wave-4 fix), sync status, book/cover counts. Rebuilds * email per the wave-4 fix), sync status, book/cover counts, and — added
* [org.modg.bookshelf.ui.settings.SettingsScreen]'s shell around its own real * 2026-09-12 alongside [org.modg.bookshelf.diagnostics.CrashReporter] — a
* [SectionHeading]/[InfoRow]/[formatLastSync], per the pattern in [ScreenFixtures]. * 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 { class SettingsScreenPaparazziTest {
@@ -40,11 +45,18 @@ class SettingsScreenPaparazziTest {
@Test @Test
fun settingsPopulatedLight() = snapshotBoth("settings-populated") { Populated() } fun settingsPopulatedLight() = snapshotBoth("settings-populated") { Populated() }
@Composable /** SPEC "an empty 'Diagnostics' section that looks broken is worse than none" — the no-crashes state. */
private fun Populated() = Shell() @Test
fun settingsDiagnosticsEmptyLight() = snapshotBoth("settings-diagnostics-empty") { DiagnosticsEmpty() }
@Composable @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 val lastSync = System.currentTimeMillis() - 2 * 60 * 1000L
BookshelfScaffold( BookshelfScaffold(
title = "Settings", title = "Settings",
@@ -77,11 +89,24 @@ class SettingsScreenPaparazziTest {
SectionHeading("Library") SectionHeading("Library")
InfoRow(label = "Books", value = ScreenFixtures.books.size.toString()) InfoRow(label = "Books", value = ScreenFixtures.books.size.toString())
InfoRow(label = "Covers", value = "0") 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) { private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } } paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } } paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 51 KiB

+1
View File
@@ -0,0 +1 @@
c0db9a19-d387-460b-899c-ea5995ce1ead
+15
View File
@@ -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.
+10
View File
@@ -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