The user's first scan on the wave-7 build crashed and never reproduced. Their theory fits structurally — the Google Books Found path had never executed in production before the key landed — but nine live lookups through the real repository, including both of their ISBNs, threw nothing, so the parse/merge code is exonerated by evidence rather than by argument. Written down so no future worker "fixes" code that was measured working. What the wave fixes is the defect found while looking: the lookup path catches only IOException and the ViewModel catches nothing, so any other throwable kills the process instead of reaching the user as "couldn't be reached" — and the app has no crash capture at all, which is why one crash left no evidence. Committed with an explicit pathspec: a wave is in flight (HAZARD #7). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPpdG8VnRfS3KkisR3HUAE
218 lines
12 KiB
Plaintext
218 lines
12 KiB
Plaintext
You are implementing ONE change in the Bookshelf Android app at ~/bookshelf.
|
|
|
|
READ FIRST, in this order:
|
|
1. ~/bookshelf/docs/SPEC.md — the authoritative contract. Sections "Book metadata
|
|
lookup" and "Barcode scanning". Do not contradict it and do not edit it.
|
|
2. ~/bookshelf/docs/METADATA-SOURCES.md — section "The key landed" and the
|
|
"Measured again 2026-09-09" section above it. This is the evidence base.
|
|
3. The files you will change (listed in "What to build").
|
|
|
|
## The bug, and what is already known — do NOT redo this investigation
|
|
|
|
On 2026-09-11 the Google Books API key landed (commit 486f6eb). On the user's phone,
|
|
the FIRST barcode scanned after installing that build **crashed the app**. The ISBN
|
|
was decoded and displayed, the spinner ran for a few seconds, then the process died.
|
|
It has never reproduced, on that book or any other.
|
|
|
|
The orchestrator has already investigated. Do not repeat any of this:
|
|
|
|
- **The structural suspicion is sound.** Keyless Google Books returned HTTP 429 to
|
|
every caller on the internet (a shared, exhausted daily quota — METADATA-SOURCES.md),
|
|
so before that commit `GoogleBooksClient.classify`'s 2xx branch, `toBookMetadata()`,
|
|
`normalizeCoverUrl()` and the two-source merge in `MetadataMerger` had NEVER ONCE
|
|
EXECUTED in production. A first crash belongs in a path's first real exercise.
|
|
- **It does not reproduce off-device.** There is now an opt-in live test,
|
|
`app/app/src/test/java/org/modg/bookshelf/livemetadata/LiveMetadataLookupTest.kt`,
|
|
which drives the real `MetadataRepository.lookup` against both real APIs with the
|
|
real key. Nine lookups — both of the user's previously-failing ISBNs plus a control,
|
|
three times each — all returned Found with cover art and nothing threw. Run it
|
|
yourself if you like: `LIVE_METADATA=1 GOOGLE_BOOKS_API_KEY=<key> ./tasks/gw
|
|
testDebugUnitTest --tests '*LiveMetadataLookupTest*'`. (Get the key from the
|
|
environment if it is set; do NOT read app/local.properties.)
|
|
- Static review found no unsafe operation in that path: no `!!` outside a guarded
|
|
branch, no date parsing, no unsafe indexing, and `ui/components/BookCover.kt`
|
|
handles every `AsyncImagePainter.State`.
|
|
|
|
**So do not go hunting for the throwing line. Nobody knows what it is, and you will
|
|
not find it either.** Your job is to make the crash impossible whatever it was, and to
|
|
make sure the next one leaves evidence. That is the whole wave.
|
|
|
|
## The actual defect you ARE fixing
|
|
|
|
The lookup path has no exception safety anywhere along it:
|
|
|
|
- `GoogleBooksClient.fetch` catches **only `IOException`**. `OpenLibraryClient.fetch`
|
|
does the same. `classify` catches only `SerializationException` /
|
|
`IllegalArgumentException`.
|
|
- `ScanViewModel.runLookup` is called from inside
|
|
`viewModelScope.launch { scannerController.scanResults.collect { ... } }` with **no
|
|
try/catch at any level**.
|
|
|
|
Both clients' KDoc claims "Never throws: every outcome, including transport failure,
|
|
comes back as a SourceResult rather than a swallowed null." **That claim is currently
|
|
false** — it is true only for `IOException`. Anything else that escapes on a real
|
|
device (the platform TLS stack, an OkHttp internal, memory pressure, an Android API
|
|
level difference — none of which the JVM here reproduces) takes the whole process
|
|
down instead of surfacing as "couldn't be reached".
|
|
|
|
That is a real, provable defect regardless of whether it is what bit the user, and it
|
|
is exactly the failure class this project already decided to eliminate in wave 5: a
|
|
failure the user cannot distinguish from anything else.
|
|
|
|
## What to build
|
|
|
|
### 1. The clients genuinely never throw
|
|
|
|
In BOTH `data/metadata/GoogleBooksClient.kt` and `data/metadata/OpenLibraryClient.kt`,
|
|
widen the catch in `fetch` from `IOException` to any `Throwable`, mapping the
|
|
unexpected case to a `SourceResult.Failed`.
|
|
|
|
- Add a new `FailureKind` for it — `UNEXPECTED` — rather than reusing `TRANSPORT`.
|
|
It is NOT retryable (`RetryPolicy.isRetryable` must return false for it): we have no
|
|
idea what went wrong, so repeating it is as likely to repeat the damage as to help.
|
|
- The `reason` must name the exception class, e.g. `"unexpected: IllegalStateException"`.
|
|
That string is rendered on the scan sheet and is our ONLY diagnostic channel from a
|
|
real phone. Include the class name; do NOT include the message (it can contain a URL,
|
|
and therefore the API key). `GoogleBooksClient.redact` still applies on top.
|
|
- **`CancellationException` MUST be rethrown, not converted.** This is the one way to
|
|
get this wrong. Coroutine cancellation is normal control flow — the user dismissing
|
|
the sheet or leaving the screen cancels the lookup — and swallowing it turns a
|
|
cancelled coroutine into a "failed lookup" and breaks structured concurrency.
|
|
`kotlin.coroutines.cancellation.CancellationException`. Rethrow it first, before any
|
|
other handling.
|
|
- Keep the existing `IOException` arm and its specific classification
|
|
(`SourceResult.fromException`) exactly as it is. `UNEXPECTED` is the fallthrough for
|
|
what that function never sees, not a replacement for it.
|
|
|
|
### 2. The ViewModel survives anything the layer below does
|
|
|
|
In `ui/scan/ScanViewModel.kt`, `runLookup` must not be able to kill the process. Wrap
|
|
its body so that any `Throwable` becomes the existing "lookup failed" sheet state
|
|
(`ScanSheetState.LookupFailed` — read `ScanModels.kt`; it already carries a reason
|
|
string and already offers Retry and "Enter by hand", which is exactly the right
|
|
affordance here).
|
|
|
|
- Again: rethrow `CancellationException` first.
|
|
- Note that `bookRepository.findByIsbn13(isbn13)` is inside this function too, so this
|
|
guard covers a Room/disk failure as well, not just the network.
|
|
- The reason string should mark it as unexpected and name the exception class, so it is
|
|
distinguishable on the sheet from an ordinary source failure.
|
|
- This is deliberately belt-and-braces with §1. Keep both: §1 keeps one source's
|
|
problem from destroying the other source's answer, §2 catches anything that escapes
|
|
anywhere else in the function.
|
|
|
|
### 3. A crash leaves evidence
|
|
|
|
New package `org.modg.bookshelf.diagnostics`. A `CrashReporter` that:
|
|
|
|
- Installs a `Thread.setDefaultUncaughtExceptionHandler` from
|
|
`BookshelfApplication.onCreate`.
|
|
- **Chains to the previously-installed handler at the end, always.** If you do not,
|
|
the process hangs instead of dying and Android never shows its dialog or records the
|
|
crash. Capture the previous handler before installing, and call it in a `finally`.
|
|
- Writes ONE file per crash into the app's `filesDir`, containing: an ISO-8601
|
|
timestamp, the app `versionName`/`versionCode`, `Build.MODEL`, `Build.VERSION.SDK_INT`,
|
|
the crashing thread's name, and the full stack trace INCLUDING the cause chain
|
|
(`Throwable.printStackTrace` on a `PrintWriter` over a `StringWriter` gives you the
|
|
causes for free — do not hand-roll the walk).
|
|
- Keeps only the most recent 5 and deletes older ones, so this cannot grow without
|
|
bound on a phone.
|
|
- Cannot itself throw: the whole body goes in `runCatching`, because an exception
|
|
inside an uncaught-exception handler is the worst possible place to have one. Must
|
|
not do I/O on the main thread at startup — writing happens only when a crash
|
|
happens, and reading happens only when the user opens the screen in §4.
|
|
|
|
### 4. The user can actually read it
|
|
|
|
Add a "Diagnostics" section to the settings screen (`ui/settings/`). It shows the most
|
|
recent crash — timestamp and the exception's first line — with a way to see the full
|
|
trace and get it off the phone. Getting it off the phone is the entire point: the user
|
|
is in another country from this machine and screenshots of a stack trace are painful.
|
|
Offer a share action (`Intent.ACTION_SEND`, `text/plain`) and a copy-to-clipboard.
|
|
When there are no crash files, say so plainly — an empty "Diagnostics" section that
|
|
looks broken is worse than none.
|
|
|
|
Match the existing settings screen's structure and visual language exactly; read it
|
|
before writing. Follow SPEC's "Design language" section. This screen has Paparazzi
|
|
coverage — add snapshots for the new section in both light and dark, in the same style
|
|
as the existing settings snapshots, and record them.
|
|
|
|
### 5. Update the comments you invalidate
|
|
|
|
Both clients' class KDoc claims they never throw. After §1 that becomes true — say so
|
|
accurately, and say WHY the `UNEXPECTED` kind exists (the crash above, undiagnosed,
|
|
2026-09-12). This codebase comments with the evidence for a decision, not a restatement
|
|
of the code. Match that.
|
|
|
|
## Constraints — these are hard
|
|
|
|
- Kotlin. Match the surrounding code's style, naming and comment density. Read
|
|
neighbouring files before writing and follow their conventions.
|
|
- DO NOT touch: app/app/build.gradle.kts, gradle/libs.versions.toml, or any other
|
|
build file. If you believe you need a new dependency, STOP and say so in your report
|
|
instead — everything above is doable with what is already declared.
|
|
- DO NOT touch anything under data/local, data/remote, data/repo, data/prefs, or
|
|
server/. You may edit AppContainer.kt and BookshelfApplication.kt only as far as §3
|
|
requires.
|
|
- DO NOT read, print, cat, grep or echo `app/local.properties`, and never paste a real
|
|
API key into a source file, a test, or your report. Tests use a fake like
|
|
"test-key-123".
|
|
- DO NOT git commit, git add, or git push. The orchestrator commits. Leave your work
|
|
in the working tree.
|
|
- Build with `./tasks/gw <task>` — NEVER `./gradlew` directly (tasks/gw is a
|
|
flock-serialized wrapper).
|
|
- RUN BUILDS IN THE FOREGROUND. Do not background a Gradle build and end your turn
|
|
saying you will report when it finishes. Three previous workers on this project did
|
|
exactly that and could never report. A full build takes 1-3 minutes; wait for it.
|
|
- There is no emulator on this box (no KVM). You cannot run the app. Do not claim you
|
|
did.
|
|
|
|
## Definition of done
|
|
|
|
Run these yourself and paste the real output:
|
|
|
|
./tasks/gw assembleDebug -> exit 0
|
|
./tasks/gw testDebugUnitTest -> exit 0. **190 tests pass today, 2 skipped**
|
|
(LiveSyncTest and LiveMetadataLookupTest, both
|
|
opt-in live tests). The count must go UP and
|
|
nothing may regress. Get the count by summing
|
|
the TEST-*.xml files, not by reading the console.
|
|
./tasks/gw recordPaparazziDebug -> exit 0, for the new settings snapshots (§4)
|
|
./tasks/gw verifyPaparazziDebug -> exit 0 against the re-recorded snapshots
|
|
|
|
Required new tests, with REAL assertions (assertion-free tests are a spec violation
|
|
on this project):
|
|
- each client returns `Failed(kind = UNEXPECTED)` — not a crash — when the HTTP call
|
|
throws a non-IOException `RuntimeException`. An `Interceptor` that throws is the
|
|
straightforward way to provoke this with the real client.
|
|
- the reason names the exception class and does NOT contain the exception's message.
|
|
- `CancellationException` propagates out of a client rather than becoming a `Failed`.
|
|
Test this explicitly. It is the easiest thing here to get wrong.
|
|
- `RetryPolicy.isRetryable(UNEXPECTED)` is false, and `withRetry` therefore makes
|
|
exactly one attempt.
|
|
- `ScanViewModel.runLookup` puts the sheet into LookupFailed (and does not throw)
|
|
when the repository throws; and a `CancellationException` is not swallowed.
|
|
- `CrashReporter` writes a file containing the exception class and its cause's
|
|
message; retains only the newest 5; and DELEGATES to the previously-installed
|
|
handler (assert the previous handler was actually invoked).
|
|
|
|
Also: grep the build log for Kotlin warnings on the files you touched. A warning
|
|
reading "Check for instance is always 'false'" is NOT cosmetic — that exact warning
|
|
hid a bug that blanked every book cover in this app for months.
|
|
|
|
## Report
|
|
|
|
End with a plain report covering:
|
|
- what you changed, file by file
|
|
- the verbatim tail of each gradle command above
|
|
- the test count before and after, summed from the XML
|
|
- **your own judgement call on this**: does §1+§2 actually make the lookup path
|
|
non-crashing, or is there still a way for a throwable to reach the top? Say so
|
|
plainly if there is. Naming a remaining hole is worth more than a clean report.
|
|
- anything you could NOT do, or did differently from these instructions, and why
|
|
- anything you noticed that looks wrong but was out of scope
|
|
|
|
Be honest. Workers on this project have over-claimed success before, and the
|
|
orchestrator independently re-verifies everything, so an inflated report only wastes a
|
|
round trip.
|