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:
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 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) }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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(
|
||||
authRepository.serverUrl,
|
||||
settingsStore.userEmail,
|
||||
@@ -67,17 +82,19 @@ class SettingsViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
val uiState: StateFlow<SettingsUiState> = 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<SettingsUiState> =
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.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<android.content.Context>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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() } }
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 51 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 51 KiB |
Reference in new issue
Block a user