Compare commits

...
3 Commits
Author SHA1 Message Date
Spriteandclaude d6d02f788c Second on-device feedback round: eight fixes, and a measured retry policy
The ghost bookcase was an inset bug, not a data bug. LocationsScreen's list
branch dropped the Scaffold's innerPadding while its empty-state branch applied
it, so the first bookcase row rendered under the top app bar and was invisible.
Both of the user's bookcases were always real; they just could not see the first
one, so they made a second. Every other screen was checked for the same class of
bug — Locations was the only one.

Metadata lookup is now designed against a measurement rather than a guess
(docs/METADATA-SOURCES.md § "Measured again 2026-09-09"):

  - Google Books keyless is dead for everyone, permanently. The user's
    residential-IP test returned a quota error naming a shared anonymous PROJECT,
    not an IP, so the earlier "your phone may well get answers" guess is wrong and
    is now marked CORRECTED in place. Because combine() turns any Failed-with-no-
    Found into Unavailable, that standing failure meant every Open Library hiccup
    surfaced as "one or more sources couldn't be reached". The API key is
    deliberately deferred by the user; this commit leaves the source broken.

  - Our own timeouts were manufacturing failures. Over 30 live requests, 13%
    failed — all fast TLS resets under 2.5s — while successes ran to a median of
    4.3s and a max of 22.0s. Two of 26 successes exceeded the old 12s callTimeout,
    so ~8% of lookups that were about to work were cancelled and reported as
    unreachable. Timeouts are now 25s/20s/20s.

That asymmetry (cheap failures, expensive successes) is what RetryPolicy encodes.
It retries TRANSPORT and SERVER_ERROR with a 250ms/750ms jittered backoff, and
deliberately does not retry TIMEOUT (the budget is already spent) or RATE_LIMITED
(hammering a quota is how an intermittent block becomes a permanent one — this
project's IP has already been refused outright once during research).

SourceResult.Failed now carries a FailureKind alongside its human reason, and the
reason names the specific failure ("tls connection reset, 3 attempts") instead of
a generic "network error". That string was already threaded to the UI and dropped
on the floor; LookupFailedSheet now renders it. It is the only diagnostic channel
we have from a real phone, so nothing may parse it.

Also from the same feedback round:
  - Grouped ModalBottomSheet shelf picker, replacing two near-duplicate flat
    dropdowns that listed every bookcase x shelf pair. Sections per bookcase,
    empty bookcases say so, and the most recently used shelf is pinned on top.
  - The recent shelf persists across sessions (SettingsStore.LAST_SHELF_ID) and is
    cleared on sign-out. It is offered, never pre-selected: the user weighed that
    and chose one tap over the risk of silently mis-shelving a book.
  - Locations dialogs and the manual-ISBN dialog auto-focus their first field.
  - The library filter menu offers "Add a bookcase to enable filtering" instead of
    a lone "All books" that is already the active state and cannot be changed.
  - The Locations button is Material Symbols' "shelves" (a bookcase) instead of
    Warehouse (a barn). material-icons-extended 1.7.8 has no bookcase glyph.
  - The scan sheet drops "you can lower the book" — the ISBN echo already says it.

assembleDebug exit 0; testDebugUnitTest 172 tests, 1 skipped, 0 failures (was
138); verifyPaparazziDebug exit 0; assembleRelease exit 0, signed with the real
release key; zero "always 'false'" warnings on a --rerun-tasks rebuild.

Three soft spots are recorded in docs/HANDOFF.md and are NOT verified: the
ghost-bookcase Paparazzi snapshot renders a lookalike of the screen rather than
the screen, the auto-focus calls swallow their own failure and no emulator exists
here, and the picker opens as a sheet stacked on the save sheet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSnVqWdiQNEcPFRq1hGZAi
2026-09-09 17:45:09 +00:00
Spriteandclaude dd3a61fc33 docs: accept wave 5; record HAZARD #8 (guard can die without its sentinel)
The wave-guard was killed after its 11:13 renewal and never wrote
logs/WAVE5-DONE, despite the worker succeeding at 11:21. That makes this
file's own first-command heuristic actively misleading — "no sentinel +
no processes -> workers were KILLED" would have thrown away a completed,
verified wave. Recorded the reliable signals instead: the size of
logs/<name>.json and the tail of logs/<name>.state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 12:46:49 +00:00
Spriteandclaude 93f972b7d1 Distinguish can't-scan, can't-look-up, and genuinely-not-found
Implemented by a Sonnet worker (tasks/G-diagnostics.txt) against the
three-way contract added to SPEC in 1969b74; independently re-verified by
the orchestrator rather than accepted on the worker's own report.

The app previously conflated three outcomes. A barcode that failed the
ISBN-13 checksum produced NOTHING — no sheet, no message — which is
indistinguishable from a dead camera. A lookup that failed at the
transport layer (Google Books answers HTTP 429 to keyless callers) was
reported as "No match found", telling the user a book does not exist when
the app never managed to ask. Only the third case was ever honest.

Now: each client returns SourceResult{Found,NotFound,Failed} from a pure
classify() so the status-code matrix is testable offline without a
MockWebServer; MetadataRepository.combine folds those into
LookupResult{Found,NotFound,Unavailable}, where NotFound requires EVERY
source to have answered authoritatively. A rejected barcode raises a
throttled banner on the camera screen, sharing ScanCodeFilter's existing
debounce so a non-book barcode sitting in frame shows the message once
instead of flickering per analyzed frame. A failed lookup gets its own
sheet with Retry / Enter by hand / Skip that never claims the book is
unknown. The metadata OkHttpClient finally has a call timeout.

Orchestrator's own addition: the manual-ISBN dialog silently discarded an
unparseable entry — the same silent failure on the same screen, missed
because it sat just outside the worker's brief. It now marks the field in
error and disables Look up until the checksum passes.

assembleDebug + verifyPaparazziDebug exit 0; 138 tests, 1 skipped, 0
failures (was 107). Boundary check clean: no build files, no data/local,
data/remote, data/repo, ui/settings, ui/locations or ui/detail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 12:44:29 +00:00
56 changed files with 2440 additions and 190 deletions
@@ -1,6 +1,7 @@
package org.modg.bookshelf
import android.content.Context
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
@@ -86,11 +87,32 @@ class AppContainer(private val context: Context) {
val authRepository by lazy { AuthRepository(apiProvider, settingsStore) }
// A bare client — deliberately NOT [okHttpClient] above, which carries our
// PocketBase bearer token via PbAuthInterceptor. Open Library/Google Books
// are third-party services; that token must never leave this device's
// requests to our own server.
private val metadataHttpClient: OkHttpClient by lazy { OkHttpClient() }
// Deliberately NOT [okHttpClient] above, which carries our PocketBase bearer
// token via PbAuthInterceptor. Open Library/Google Books are third-party
// services; that token must never leave this device's requests to our own
// server. A call timeout is load-bearing here: with none, a stalled
// connection hangs on OkHttp's default (unbounded) socket timeouts, and the
// user is standing at a bookshelf waiting on it.
//
// These were 12s/10s/10s and were MANUFACTURING failures. Measuring the exact
// Open Library call 30 times on 2026-09-09 (docs/METADATA-SOURCES.md) found
// successful requests with a median of 4.3s but a long tail — p90 9.2s, max
// 22.0s, and one connect phase alone of 19.6s. Two of 26 successes exceeded the
// old 12s call timeout, so ~8% of lookups that were about to work were being
// cancelled and reported to the user as "couldn't be reached".
//
// Raising these does NOT slow the failure path: every observed failure was a
// TLS-stage reset returning in under 2.5s, and a socket that is going to break
// breaks long before any of these limits. The timeouts only ever bound the
// slow-success tail, which is precisely what we want to stop truncating.
// 25s > the 22.0s worst observed success, with room to spare.
private val metadataHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.callTimeout(25, TimeUnit.SECONDS)
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
}
val metadataRepository by lazy { MetadataRepository(metadataHttpClient, json) }
@@ -10,7 +10,8 @@ import okhttp3.Request
/**
* Google Books lookup — SPEC.md "Book metadata lookup" fallback source. No API key.
* Never throws: network/parse failures fail soft and return null.
* Never throws: every outcome, including transport failure, comes back as a
* [SourceResult] rather than a swallowed null.
*/
class GoogleBooksClient(
private val httpClient: OkHttpClient,
@@ -18,29 +19,51 @@ class GoogleBooksClient(
) {
private val json = Json(from = json) { ignoreUnknownKeys = true }
suspend fun lookup(isbn13: String): BookMetadata? = withContext(Dispatchers.IO) {
val body = fetchBody(isbn13) ?: return@withContext null
parseResponse(body)
}
/**
* Retries transient failures per [RetryPolicy]. Note this source's standing
* failure — keyless requests share one exhausted global quota and answer 429,
* which [RetryPolicy] deliberately does NOT retry, so today this costs nothing
* and changes nothing here. See docs/METADATA-SOURCES.md.
*/
suspend fun lookup(isbn13: String): SourceResult =
withContext(Dispatchers.IO) { withRetry { fetch(isbn13) } }
private fun fetchBody(isbn13: String): String? = try {
/** Single un-retried attempt, for tests that need to count calls. */
internal suspend fun lookupOnce(isbn13: String): SourceResult =
withContext(Dispatchers.IO) { fetch(isbn13) }
private fun fetch(isbn13: String): SourceResult = try {
val request = Request.Builder()
.url("https://www.googleapis.com/books/v1/volumes?q=isbn:$isbn13")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) null else response.body?.string()
classify(response.code, response.body.string())
}
} catch (e: IOException) {
null
SourceResult.fromException(e)
}
/**
* Package-visible pure function — no socket involved — so it's exhaustively
* unit-testable offline (2xx-with-record, 2xx-without-record, 404, 429, 500,
* malformed body). [parseResponse] is defined in terms of this so the two
* can never disagree about what a body means.
*/
internal fun classify(httpCode: Int, body: String?): SourceResult {
if (httpCode !in 200..299) return SourceResult.fromHttpCode(httpCode)
if (body.isNullOrBlank()) return SourceResult.Failed("empty body", FailureKind.MALFORMED)
return try {
val dto = json.decodeFromString(GoogleBooksResponseDto.serializer(), body)
val metadata = dto.items.firstOrNull()?.volumeInfo?.toBookMetadata()
if (metadata != null) SourceResult.Found(metadata) else SourceResult.NotFound
} catch (e: SerializationException) {
SourceResult.Failed("malformed json", FailureKind.MALFORMED)
} catch (e: IllegalArgumentException) {
SourceResult.Failed("malformed json", FailureKind.MALFORMED)
}
}
/** Package-visible for offline fixture tests — parses a raw response body with no network involved. */
internal fun parseResponse(body: String): BookMetadata? = try {
val dto = json.decodeFromString(GoogleBooksResponseDto.serializer(), body)
dto.items.firstOrNull()?.volumeInfo?.toBookMetadata()
} catch (e: SerializationException) {
null
} catch (e: IllegalArgumentException) {
null
}
internal fun parseResponse(body: String): BookMetadata? =
(classify(200, body) as? SourceResult.Found)?.metadata
}
@@ -0,0 +1,18 @@
package org.modg.bookshelf.data.metadata
/**
* The combined outcome of a metadata lookup across both sources (SPEC.md "Book
* metadata lookup"). Produced by [MetadataRepository.lookup] from the two
* [SourceResult]s per [MetadataRepository.combine]'s rules:
* - any source [SourceResult.Found] -> [Found]
* - every source [SourceResult.NotFound] -> [NotFound]
* - otherwise (at least one [SourceResult.Failed], none Found) -> [Unavailable]
*
* The last rule is the whole point: one reachable source answering "no" is not
* authoritative while the other source couldn't be asked at all.
*/
sealed interface LookupResult {
data class Found(val metadata: BookMetadata) : LookupResult
data object NotFound : LookupResult
data class Unavailable(val reason: String) : LookupResult
}
@@ -7,9 +7,10 @@ import okhttp3.OkHttpClient
/**
* Single entry point for book metadata lookup (SPEC.md "Book metadata lookup").
* Queries both sources concurrently and merges per [MetadataMerger]. Returns null
* if [isbn] doesn't checksum-validate or if both sources miss — callers (the scan
* screen) must then fall back to manual entry pre-filled with the scanned ISBN.
* Queries both sources concurrently and combines their [SourceResult]s into one
* [LookupResult] per [combine]. [isbn] must already be a checksum-valid ISBN-10/13
* by the time it reaches here — the scan layer is responsible for that, and an
* invalid one is a programming error at this layer, not a lookup outcome.
*/
class MetadataRepository(
private val openLibraryClient: OpenLibraryClient,
@@ -20,21 +21,46 @@ class MetadataRepository(
GoogleBooksClient(httpClient, json),
)
suspend fun lookup(isbn: String): BookMetadata? {
val isbn13 = IsbnUtils.toIsbn13(isbn) ?: return null
val merged = coroutineScope {
suspend fun lookup(isbn: String): LookupResult {
val isbn13 = checkNotNull(IsbnUtils.toIsbn13(isbn)) {
"MetadataRepository.lookup requires an already-validated ISBN-10/13; got: $isbn"
}
return coroutineScope {
val openLibrary = async { openLibraryClient.lookup(isbn13) }
val googleBooks = async { googleBooksClient.lookup(isbn13) }
MetadataMerger.merge(openLibrary.await(), googleBooks.await())
} ?: return null
return if (merged.coverUrl.isNullOrBlank()) {
merged.copy(coverUrl = byIsbnCoverUrl(isbn13))
} else {
merged
combine(openLibrary.await(), googleBooks.await(), isbn13)
}
}
companion object {
/**
* Combines the two sources' outcomes per SPEC.md's three-way rule: any Found
* wins (merged via [MetadataMerger] and given the last-resort cover), all
* NotFound is an honest miss, and anything else — at least one Failed and
* nothing Found — is Unavailable, because a source that couldn't be reached
* must never be reported as a book that doesn't exist. Package-visible and
* pure (no I/O) so the full 3x3 matrix is unit-testable offline.
*/
internal fun combine(openLibrary: SourceResult, googleBooks: SourceResult, isbn13: String): LookupResult {
val merged = MetadataMerger.merge(
(openLibrary as? SourceResult.Found)?.metadata,
(googleBooks as? SourceResult.Found)?.metadata,
)
if (merged != null) {
val withCover = if (merged.coverUrl.isNullOrBlank()) {
merged.copy(coverUrl = byIsbnCoverUrl(isbn13))
} else {
merged
}
return LookupResult.Found(withCover)
}
val failures = listOfNotNull(
(openLibrary as? SourceResult.Failed)?.reason?.let { "open library: $it" },
(googleBooks as? SourceResult.Failed)?.reason?.let { "google books: $it" },
)
return if (failures.isNotEmpty()) LookupResult.Unavailable(failures.joinToString("; ")) else LookupResult.NotFound
}
/**
* Last resort when neither source reported cover art, per SPEC's cover chain.
* `default=false` is load-bearing: without it this endpoint answers 200 with a
@@ -12,7 +12,8 @@ import okhttp3.Request
/**
* Open Library lookup — SPEC.md "Book metadata lookup" primary source.
* Never throws: network/parse failures fail soft and return null.
* Never throws: every outcome, including transport failure, comes back as a
* [SourceResult] rather than a swallowed null.
*/
class OpenLibraryClient(
private val httpClient: OkHttpClient,
@@ -21,30 +22,50 @@ class OpenLibraryClient(
// Real responses carry fields this DTO doesn't model; never let an unknown key throw.
private val json = Json(from = json) { ignoreUnknownKeys = true }
suspend fun lookup(isbn13: String): BookMetadata? = withContext(Dispatchers.IO) {
val body = fetchBody(isbn13) ?: return@withContext null
parseResponse(body, isbn13)
}
/**
* Retries transient failures per [RetryPolicy]. Measured against the live API,
* 13% of requests fail at the TLS stage in well under a second while successful
* ones take seconds — so a retry is nearly free and removes most of that 13%.
*/
suspend fun lookup(isbn13: String): SourceResult =
withContext(Dispatchers.IO) { withRetry { fetch(isbn13) } }
private fun fetchBody(isbn13: String): String? = try {
/** Single un-retried attempt, for tests that need to count calls. */
internal suspend fun lookupOnce(isbn13: String): SourceResult =
withContext(Dispatchers.IO) { fetch(isbn13) }
private fun fetch(isbn13: String): SourceResult = try {
val request = Request.Builder()
.url("https://openlibrary.org/api/books?bibkeys=ISBN:$isbn13&format=json&jscmd=data")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) null else response.body?.string()
classify(response.code, response.body.string(), isbn13)
}
} catch (e: IOException) {
null
SourceResult.fromException(e)
}
/**
* Package-visible pure function — no socket involved — so it's exhaustively
* unit-testable offline (2xx-with-record, 2xx-without-record, 404, 429, 500,
* malformed body). [parseResponse] is defined in terms of this so the two
* can never disagree about what a body means.
*/
internal fun classify(httpCode: Int, body: String?, isbn13: String): SourceResult {
if (httpCode !in 200..299) return SourceResult.fromHttpCode(httpCode)
if (body.isNullOrBlank()) return SourceResult.Failed("empty body", FailureKind.MALFORMED)
return try {
val root = json.parseToJsonElement(body).jsonObject
val entry = root["ISBN:$isbn13"]?.jsonObject ?: return SourceResult.NotFound
SourceResult.Found(json.decodeFromJsonElement<OpenLibraryBookDto>(entry).toBookMetadata(isbn13))
} catch (e: SerializationException) {
SourceResult.Failed("malformed json", FailureKind.MALFORMED)
} catch (e: IllegalArgumentException) {
SourceResult.Failed("malformed json", FailureKind.MALFORMED)
}
}
/** Package-visible for offline fixture tests — parses a raw response body with no network involved. */
internal fun parseResponse(body: String, isbn13: String): BookMetadata? = try {
val root = json.parseToJsonElement(body).jsonObject
val entry = root["ISBN:$isbn13"]?.jsonObject ?: return null
json.decodeFromJsonElement<OpenLibraryBookDto>(entry).toBookMetadata(isbn13)
} catch (e: SerializationException) {
null
} catch (e: IllegalArgumentException) {
null
}
internal fun parseResponse(body: String, isbn13: String): BookMetadata? =
(classify(200, body, isbn13) as? SourceResult.Found)?.metadata
}
@@ -0,0 +1,110 @@
package org.modg.bookshelf.data.metadata
import kotlin.random.Random
import kotlinx.coroutines.delay
/**
* When a failed lookup attempt is worth repeating, and how long to wait first.
*
* Grounded in a measurement of the live Open Library API from 2026-09-09 — 30
* requests, the exact call [OpenLibraryClient] makes (see docs/METADATA-SOURCES.md):
*
* - 13% of requests failed, every one of them a TLS-stage connection reset.
* - Failures were FAST: 0.23s, 0.31s, 0.59s, 2.46s.
* - Successes were SLOW and long-tailed: median 4.3s, p90 9.2s, max 22.0s.
*
* That asymmetry drives every constant here. A retry costs roughly a fifth of a
* second of the user's time and turns a 13% failure rate into ~1.7% at two
* attempts and ~0.2% at three, which is why the backoff is short rather than the
* conventional exponential-with-seconds. It is also why [isRetryable] refuses to
* repeat a [FailureKind.TIMEOUT]: a timeout means we already spent the full budget
* on that attempt, so repeating it risks tripling the wait for a user standing at
* a bookshelf, and the measurement says slow requests usually eventually succeed
* rather than fail — the fix for those is a generous timeout, not another attempt.
*/
object RetryPolicy {
/** One original attempt plus two retries. Beyond this the marginal gain is noise. */
const val MAX_ATTEMPTS = 3
/**
* Stop starting NEW attempts once this much time has gone into a single source.
* A backstop against pathological cases (every attempt hitting the slow tail),
* not a normal-path limit. It is deliberately checked only BETWEEN attempts —
* an in-flight request is never cancelled, because the 22-second request in the
* sample was a successful one and killing it would manufacture exactly the
* failure this whole change exists to remove.
*/
const val TOTAL_BUDGET_MILLIS = 30_000L
/**
* [FailureKind.TRANSPORT] and [FailureKind.SERVER_ERROR] are transient and
* cheap to re-ask. The rest are not:
* - TIMEOUT — the budget is already spent; see the class KDoc.
* - RATE_LIMITED — the source is explicitly asking us to stop. Hammering a
* quota is how an intermittent block becomes a permanent one,
* and METADATA-SOURCES.md records that happening to this
* project's IP during research. When the Google Books API key
* lands, revisit this: a keyed 429 is a per-second rate limit
* and IS worth one Retry-After-respecting retry, unlike
* today's keyless daily-quota 429, which never clears.
* - CLIENT_ERROR — an identical request gets an identical answer.
* - MALFORMED — same bytes, same parse failure.
*/
fun isRetryable(kind: FailureKind): Boolean =
kind == FailureKind.TRANSPORT || kind == FailureKind.SERVER_ERROR
/**
* Backoff before attempt number [nextAttempt] (2-based: the wait before the
* first retry is `backoffMillis(2)`). 250ms then 750ms, plus up to 40% jitter
* so that two sources — or two phones in the same house — cannot fall into
* lockstep and hammer a recovering server in unison.
*/
fun backoffMillis(nextAttempt: Int, random: Random = Random.Default): Long {
val base = when (nextAttempt) {
2 -> 250L
else -> 750L
}
return base + random.nextLong(0, (base * 0.4).toLong().coerceAtLeast(1))
}
}
/**
* Runs [attempt] until it succeeds, fails un-retryably, or runs out of attempts or
* budget. Returns the LAST result, so the caller always sees a real outcome rather
* than a synthesised one.
*
* A [SourceResult.Failed] that survives retrying has its attempt count appended to
* [SourceResult.Failed.reason] ("tls connection reset, 3 attempts"). That string is
* shown to the user on the scan sheet and is the only diagnostic we get back from a
* real phone — "failed once" and "failed three times in a row" are very different
* stories about the network, and without this they are indistinguishable.
*
* [sleep] and [nowMillis] are injectable purely so tests can run the real policy
* with no wall-clock delay; production callers use the defaults.
*/
suspend fun withRetry(
maxAttempts: Int = RetryPolicy.MAX_ATTEMPTS,
budgetMillis: Long = RetryPolicy.TOTAL_BUDGET_MILLIS,
random: Random = Random.Default,
nowMillis: () -> Long = { System.currentTimeMillis() },
sleep: suspend (Long) -> Unit = { delay(it) },
attempt: suspend () -> SourceResult,
): SourceResult {
val started = nowMillis()
var last: SourceResult = attempt()
var attemptsMade = 1
while (attemptsMade < maxAttempts) {
val failure = last as? SourceResult.Failed ?: return last
if (!RetryPolicy.isRetryable(failure.kind)) break
if (nowMillis() - started >= budgetMillis) break
sleep(RetryPolicy.backoffMillis(attemptsMade + 1, random))
last = attempt()
attemptsMade++
}
val failure = last as? SourceResult.Failed ?: return last
return if (attemptsMade > 1) failure.copy(reason = "${failure.reason}, $attemptsMade attempts") else failure
}
@@ -0,0 +1,101 @@
package org.modg.bookshelf.data.metadata
import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
import javax.net.ssl.SSLHandshakeException
/**
* Why a source failed, in the only terms that matter to a caller: is it worth
* asking again? [RetryPolicy] is the single place that decides, so the answer
* cannot drift between the two clients.
*/
enum class FailureKind {
/** Never got an answer: DNS, refused connection, reset socket, failed TLS handshake. */
TRANSPORT,
/** We gave up waiting. Distinct from [TRANSPORT] because the budget is already spent. */
TIMEOUT,
/** HTTP 429. The source is telling us to stop asking; asking harder is the wrong move. */
RATE_LIMITED,
/** HTTP 5xx — the source's problem, and usually a passing one. */
SERVER_ERROR,
/** HTTP 4xx other than 429. Repeating an identical request cannot change the answer. */
CLIENT_ERROR,
/** 2xx whose body we could not parse. Deterministic: the same bytes will fail again. */
MALFORMED,
}
/**
* Per-source lookup outcome (SPEC.md "Book metadata lookup": "Lookup outcome is
* THREE-WAY, never a bare null"). [OpenLibraryClient] and [GoogleBooksClient] each
* produce one of these instead of collapsing "couldn't be reached" and "answered,
* doesn't have it" into the same `null`. [MetadataRepository] combines the two
* source results into a [LookupResult].
*/
sealed interface SourceResult {
data class Found(val metadata: BookMetadata) : SourceResult
/** The source answered (2xx) and, in good faith, has no record for this ISBN. */
data object NotFound : SourceResult
/**
* The source could not be asked, or its answer couldn't be trusted. [reason] is
* a short diagnostic ("http 429", "tls reset", "malformed json") that is shown
* to the user as supplementary detail on the scan sheet and is our ONLY
* diagnostic channel from a real phone — so it names the specific failure, not
* a generic one. [kind] is the same fact in a form [RetryPolicy] can act on;
* nothing should ever parse [reason] to recover it.
*/
data class Failed(val reason: String, val kind: FailureKind = FailureKind.TRANSPORT) : SourceResult
companion object {
/**
* Maps a thrown [IOException] to a specific failure. Both clients call this
* so they can never disagree, and so a new exception type gets classified
* once rather than twice.
*
* Measured against the live Open Library API on 2026-09-09 (30 requests):
* every observed failure was a TLS-stage `Connection reset by peer`, and all
* four came back in under 2.5s while successful requests took a median of
* 4.3s. Failures are cheap and fast; that asymmetry is the whole reason
* retrying is worth doing, and why the timeouts are set as generously as
* they are in `AppContainer.metadataHttpClient`.
*
* Order matters: [SocketTimeoutException] is an [InterruptedIOException] and
* [SSLHandshakeException] is an [SSLException], so the specific arms come
* first. OkHttp reports a blown `callTimeout` as a bare
* [InterruptedIOException], which is why that arm exists at all.
*/
fun fromException(e: IOException): Failed = when (e) {
is SocketTimeoutException -> Failed("timeout", FailureKind.TIMEOUT)
is InterruptedIOException -> Failed("timeout", FailureKind.TIMEOUT)
is UnknownHostException -> Failed("dns lookup failed", FailureKind.TRANSPORT)
is SSLHandshakeException -> Failed("tls handshake failed", FailureKind.TRANSPORT)
is SSLException -> Failed("tls connection reset", FailureKind.TRANSPORT)
is ConnectException -> Failed("connection refused", FailureKind.TRANSPORT)
is SocketException -> Failed("connection reset", FailureKind.TRANSPORT)
else -> Failed("network error (${e.javaClass.simpleName})", FailureKind.TRANSPORT)
}
/**
* Maps a non-2xx HTTP status to a specific failure. 429 is called out
* separately from the rest of 4xx because it is the one client error that is
* about us rather than about the request, and because it is currently
* Google Books' permanent state — see docs/METADATA-SOURCES.md.
*/
fun fromHttpCode(code: Int): Failed = when {
code == 429 -> Failed("http 429 (rate limited)", FailureKind.RATE_LIMITED)
code in 500..599 -> Failed("http $code (server error)", FailureKind.SERVER_ERROR)
else -> Failed("http $code", FailureKind.CLIENT_ERROR)
}
}
}
@@ -26,6 +26,7 @@ class SettingsStore(private val context: Context) {
val USER_ID = stringPreferencesKey("user_id")
val USER_EMAIL = stringPreferencesKey("user_email")
val LAST_SYNC_TIME = longPreferencesKey("last_sync_time")
val LAST_SHELF_ID = stringPreferencesKey("last_shelf_id")
fun cursor(collection: String) = stringPreferencesKey("cursor_$collection")
}
@@ -35,6 +36,13 @@ class SettingsStore(private val context: Context) {
val userEmail: Flow<String?> = context.dataStore.data.map { it[Keys.USER_EMAIL] }
val lastSyncTime: Flow<Long?> = context.dataStore.data.map { it[Keys.LAST_SYNC_TIME] }
/**
* The shelf most recently assigned to a book — SPEC "remember the most recently
* used shelf" (shelving a box of books usually means one shelf, over and over).
* Never written for "Not shelved"; see [SettingsStore] callers.
*/
val lastShelfId: Flow<String?> = context.dataStore.data.map { it[Keys.LAST_SHELF_ID] }
fun cursorFor(collection: String): Flow<String?> =
context.dataStore.data.map { it[Keys.cursor(collection)] }
@@ -62,12 +70,25 @@ class SettingsStore(private val context: Context) {
context.dataStore.edit { it[Keys.LAST_SYNC_TIME] = epochMillis }
}
/** Sign out: drop the token/user identity but keep the server URL — no need to re-enter it. */
suspend fun setLastShelfId(shelfId: String) {
context.dataStore.edit { it[Keys.LAST_SHELF_ID] = shelfId }
}
suspend fun clearLastShelfId() {
context.dataStore.edit { it.remove(Keys.LAST_SHELF_ID) }
}
/**
* Sign out: drop the token/user identity but keep the server URL — no need to
* re-enter it. Also drops the remembered shelf: a shared library's other
* account shouldn't have its shelving habit leak into this one's session.
*/
suspend fun clearAuth() {
context.dataStore.edit {
it.remove(Keys.AUTH_TOKEN)
it.remove(Keys.USER_ID)
it.remove(Keys.USER_EMAIL)
it.remove(Keys.LAST_SHELF_ID)
}
}
@@ -0,0 +1,180 @@
package org.modg.bookshelf.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
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.outlined.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
/**
* The grouped shelf picker shared by the scan screen's save sheet and the
* detail screen's location picker. Replaces the old flat "Bookcase • Shelf"
* [androidx.compose.material3.DropdownMenu] — which stopped being usable once
* a library had more than a couple of bookcases — with a scrolling
* [ModalBottomSheet] grouped one section per bookcase. A bookcase is not
* itself a place a book can sit, so its header is a non-selectable label;
* only the shelves listed under it are choices.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShelfPickerSheet(
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
selectedShelfId: String?,
recentShelfId: String?,
onShelfSelected: (String?) -> Unit,
onDismissRequest: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismissRequest, sheetState = rememberModalBottomSheetState()) {
ShelfPickerContent(
bookcases = bookcases,
shelves = shelves,
selectedShelfId = selectedShelfId,
recentShelfId = recentShelfId,
onShelfSelected = { shelfId ->
onShelfSelected(shelfId)
onDismissRequest()
},
)
}
}
/**
* The picker's contents, split out from [ShelfPickerSheet] so it can be rendered
* directly (Paparazzi has no real Window/scrim behind a headless [ModalBottomSheet],
* same problem [org.modg.bookshelf.ui.screens.ScanScreenPaparazziTest]'s class doc
* describes for the camera preview). Internal rather than private so that test can
* reach it.
*/
@Composable
internal fun ShelfPickerContent(
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
selectedShelfId: String?,
recentShelfId: String?,
onShelfSelected: (String?) -> Unit,
) {
val recent = resolveRecentShelf(recentShelfId, shelves, bookcases)
val recentShelf = recent?.first
val recentBookcase = recent?.second
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
// Omitted entirely when there is no remembered shelf, or it no longer exists
// (deleted since it was last used) — a dangling "Recent" entry would be worse
// than no shortcut at all.
if (recentShelf != null && recentBookcase != null) {
SectionHeader(text = "Recent")
ShelfRow(
label = "${recentBookcase.name}${recentShelf.label}",
selected = selectedShelfId == recentShelf.id,
onClick = { onShelfSelected(recentShelf.id) },
)
GoldDivider(modifier = Modifier.padding(vertical = 12.dp))
}
ShelfRow(
label = "Not shelved",
selected = selectedShelfId == null,
onClick = { onShelfSelected(null) },
)
bookcases.sortedBy { it.position }.forEach { bookcase ->
GoldDivider(modifier = Modifier.padding(vertical = 12.dp))
SectionHeader(text = bookcase.name)
val bookcaseShelves = shelves.filter { it.bookcaseId == bookcase.id }.sortedBy { it.position }
if (bookcaseShelves.isEmpty()) {
// So an empty bookcase reads as "no shelves yet", not a rendering bug.
Text(
text = "No shelves yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontStyle = FontStyle.Italic,
modifier = Modifier.padding(vertical = 8.dp),
)
} else {
bookcaseShelves.forEach { shelf ->
ShelfRow(
label = shelf.label,
selected = selectedShelfId == shelf.id,
onClick = { onShelfSelected(shelf.id) },
)
}
}
}
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
}
@Composable
private fun ShelfRow(label: String, selected: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
if (selected) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
/**
* Resolves [recentShelfId] to the shelf + bookcase it names, or null when there is
* nothing remembered, or the remembered shelf was deleted since — the two cases the
* "Recent" section must be omitted for. Pure so it's directly unit-testable without
* standing up Compose.
*/
internal fun resolveRecentShelf(
recentShelfId: String?,
shelves: List<ShelfEntity>,
bookcases: List<BookcaseEntity>,
): Pair<ShelfEntity, BookcaseEntity>? {
val shelf = recentShelfId?.let { id -> shelves.find { it.id == id } } ?: return null
val bookcase = bookcases.find { it.id == shelf.bookcaseId } ?: return null
return shelf to bookcase
}
@@ -19,8 +19,6 @@ import androidx.compose.material.icons.outlined.ExpandLess
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -56,6 +54,7 @@ import org.modg.bookshelf.ui.components.BookCover
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.components.ShelfPickerSheet
/**
* SPEC.md "detail" screen. [book] filters `deleted = 0`, so once the user
@@ -76,6 +75,7 @@ fun DetailScreen(
DetailViewModel(
bookRepository = container.bookRepository,
locationRepository = container.locationRepository,
settingsStore = container.settingsStore,
bookId = bookId,
)
}
@@ -85,6 +85,7 @@ fun DetailScreen(
val book by viewModel.book.collectAsState()
val bookcases by viewModel.bookcases.collectAsState()
val shelves by viewModel.shelves.collectAsState()
val recentShelfId by viewModel.recentShelfId.collectAsState()
var retainedBook by remember { mutableStateOf<BookEntity?>(null) }
LaunchedEffect(book) { book?.let { retainedBook = it } }
@@ -163,6 +164,7 @@ fun DetailScreen(
book = display,
bookcases = bookcases,
shelves = shelves,
recentShelfId = recentShelfId,
onShelfSelected = viewModel::saveLocation,
)
}
@@ -271,9 +273,10 @@ internal fun LocationSection(
book: BookEntity,
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
recentShelfId: String?,
onShelfSelected: (String?) -> Unit,
) {
var menuExpanded by remember { mutableStateOf(false) }
var pickerOpen by remember { mutableStateOf(false) }
val currentShelf = shelves.find { it.id == book.shelfId }
val currentBookcase = currentShelf?.let { shelf -> bookcases.find { it.id == shelf.bookcaseId } }
val label = if (currentShelf != null && currentBookcase != null) {
@@ -291,24 +294,19 @@ internal fun LocationSection(
) {
Icon(Icons.Outlined.LocationOn, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
Text(text = label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Box {
TextButton(onClick = { menuExpanded = true }) { Text("Change") }
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(
text = { Text("Not shelved") },
onClick = { onShelfSelected(null); menuExpanded = false },
TextButton(onClick = { pickerOpen = true }) { Text("Change") }
}
}
if (pickerOpen) {
ShelfPickerSheet(
bookcases = bookcases,
shelves = shelves,
selectedShelfId = book.shelfId,
recentShelfId = recentShelfId,
onShelfSelected = onShelfSelected,
onDismissRequest = { pickerOpen = false },
)
bookcases.forEach { bookcase ->
shelves.filter { it.bookcaseId == bookcase.id }.forEach { shelf ->
DropdownMenuItem(
text = { Text("${bookcase.name}${shelf.label}") },
onClick = { onShelfSelected(shelf.id); menuExpanded = false },
)
}
}
}
}
}
}
}
@@ -9,6 +9,7 @@ import kotlinx.coroutines.launch
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.LocationRepository
import org.modg.bookshelf.data.repo.decodeAuthors
@@ -44,6 +45,7 @@ data class BookEditForm(
class DetailViewModel(
private val bookRepository: BookRepository,
private val locationRepository: LocationRepository,
private val settingsStore: SettingsStore,
private val bookId: String,
) : ViewModel() {
@@ -61,6 +63,10 @@ class DetailViewModel(
val shelves: StateFlow<List<ShelfEntity>> = locationRepository.observeShelves()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/** The shelf most recently assigned to any book, across sessions — surfaced as the picker's "Recent" shortcut. */
val recentShelfId: StateFlow<String?> = settingsStore.lastShelfId
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun saveNotes(notes: String) {
val current = book.value ?: return
viewModelScope.launch { bookRepository.save(current.copy(notes = notes.ifBlank { null })) }
@@ -68,7 +74,17 @@ class DetailViewModel(
fun saveLocation(shelfId: String?) {
val current = book.value ?: return
viewModelScope.launch { bookRepository.save(current.copy(shelfId = shelfId)) }
viewModelScope.launch { performSaveLocation(current, shelfId) }
}
/**
* The actual save-location logic, split out from [saveLocation] so tests can await it
* directly (as a plain suspend call) instead of racing [viewModelScope]'s launch.
*/
internal suspend fun performSaveLocation(current: BookEntity, shelfId: String?) {
bookRepository.save(current.copy(shelfId = shelfId))
// "Not shelved" (null) must never overwrite the memory — it isn't a shelf.
if (shelfId != null) settingsStore.setLastShelfId(shelfId)
}
fun saveEdit(form: BookEditForm) {
@@ -18,7 +18,6 @@ import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Sort
import androidx.compose.material.icons.outlined.Warehouse
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -37,6 +36,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -44,6 +44,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import org.modg.bookshelf.AppContainer
import org.modg.bookshelf.R
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
@@ -97,7 +98,7 @@ fun LibraryScreen(
title = "Bookshelf",
actions = {
IconButton(onClick = onLocationsClick) {
Icon(Icons.Outlined.Warehouse, contentDescription = "Bookcases & shelves")
Icon(painterResource(R.drawable.ic_shelves), contentDescription = "Bookcases & shelves")
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Outlined.Settings, contentDescription = "Settings")
@@ -202,6 +203,13 @@ internal fun LibraryToolbar(
Icon(Icons.Outlined.FilterList, contentDescription = "Filter by bookcase or shelf")
}
DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) {
if (bookcases.isEmpty() && shelves.isEmpty()) {
DropdownMenuItem(
text = { Text("Add a bookcase to enable filtering") },
enabled = false,
onClick = {},
)
} else {
DropdownMenuItem(
text = { Text("All books") },
onClick = { onFilterChange(LibraryFilter.All); filterMenuExpanded = false },
@@ -220,6 +228,7 @@ internal fun LibraryToolbar(
}
}
}
}
Column {
IconButton(onClick = { sortMenuExpanded = true }) {
@@ -32,12 +32,15 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
@@ -94,7 +97,12 @@ fun LocationsScreen(
action = { PrimaryButton(text = "Add a bookcase", onClick = viewModel::openAddBookcase) },
)
} else {
LazyColumn(contentPadding = PaddingValues(bottom = 96.dp)) {
LazyColumn(
contentPadding = PaddingValues(
top = innerPadding.calculateTopPadding(),
bottom = innerPadding.calculateBottomPadding() + 96.dp,
),
) {
items(state.bookcases, key = { it.bookcase.id }) { bookcaseUi ->
BookcaseRow(
bookcaseUi = bookcaseUi,
@@ -268,12 +276,22 @@ private fun BookcaseEditDialog(
) {
var name by remember { mutableStateOf(editing?.name.orEmpty()) }
var note by remember { mutableStateOf(editing?.note.orEmpty()) }
val nameFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
runCatching { nameFocusRequester.requestFocus() }
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = title, style = MaterialTheme.typography.titleLarge) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Name") }, singleLine = true)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
modifier = Modifier.focusRequester(nameFocusRequester),
)
OutlinedTextField(value = note, onValueChange = { note = it }, label = { Text("Note (optional)") }, singleLine = true)
}
},
@@ -292,11 +310,21 @@ private fun ShelfEditDialog(
onSubmit: (label: String) -> Unit,
) {
var label by remember { mutableStateOf(editing?.label.orEmpty()) }
val labelFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
runCatching { labelFocusRequester.requestFocus() }
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = title, style = MaterialTheme.typography.titleLarge) },
text = {
OutlinedTextField(value = label, onValueChange = { label = it }, label = { Text("Label") }, singleLine = true)
OutlinedTextField(
value = label,
onValueChange = { label = it },
label = { Text("Label") },
singleLine = true,
modifier = Modifier.focusRequester(labelFocusRequester),
)
},
confirmButton = {
TextButton(onClick = { onSubmit(label) }, enabled = label.isNotBlank()) { Text("Save") }
@@ -2,11 +2,31 @@ package org.modg.bookshelf.ui.scan
import org.modg.bookshelf.data.metadata.IsbnUtils
/** What a single decoded barcode read means, once debouncing has been applied. */
sealed interface ScanOutcome {
/** A checksum-valid ISBN-13, ready for metadata lookup. */
data class Isbn(val isbn13: String) : ScanOutcome
/**
* Pure decode-to-ISBN pipeline shared by the analyzer and its tests: normalizes a raw
* barcode value, validates the ISBN-13 checksum (this is what makes EAN_8/UPC_A reads
* fall out as "non-book barcodes" per SPEC.md "Barcode scanning" — they can never be
* 13 digits), and debounces repeat reads of the same code.
* Decoded fine, but its checksum rules it out as a book ISBN (SPEC.md
* "Barcode scanning": "ignore non-book barcodes" — but not silently, see
* [rawValue]). Carries the raw value so the camera screen can echo back what
* it read.
*/
data class NotAnIsbn(val rawValue: String) : ScanOutcome
/** A debounced repeat of the last code (valid or not) — emit no UI at all. */
data object Ignored : ScanOutcome
}
/**
* Pure decode-to-outcome pipeline shared by the analyzer and its tests: normalizes
* a raw barcode value, classifies it as a valid ISBN-13 or not (this is what makes
* EAN_8/UPC_A reads fall out as "non-book barcodes" per SPEC.md "Barcode scanning"
* — they can never be 13 digits), and debounces repeats of the same code, whether
* or not it validated. The debounce is what keeps a non-book barcode sitting in
* frame — which decodes on nearly every analyzed frame — from flickering a
* rejection message instead of showing it once.
*/
class ScanCodeFilter(
private val debounceMillis: Long = 2000L,
@@ -15,15 +35,19 @@ class ScanCodeFilter(
private var lastCode: String? = null
private var lastEmitMillis: Long = Long.MIN_VALUE
/** Returns the normalized ISBN-13 if [rawValue] is a valid, non-debounced hit; null otherwise. */
fun accept(rawValue: String?): String? {
val normalized = IsbnUtils.normalize(rawValue ?: return null)
if (!IsbnUtils.isValidIsbn13(normalized)) return null
/** Classifies [rawValue] per [ScanOutcome], applying the debounce window. */
fun accept(rawValue: String?): ScanOutcome {
val raw = rawValue ?: return ScanOutcome.Ignored
val normalized = IsbnUtils.normalize(raw)
val now = nowMillis()
if (normalized == lastCode && now - lastEmitMillis < debounceMillis) return null
if (normalized == lastCode && now - lastEmitMillis < debounceMillis) return ScanOutcome.Ignored
lastCode = normalized
lastEmitMillis = now
return normalized
return if (IsbnUtils.isValidIsbn13(normalized)) {
ScanOutcome.Isbn(normalized)
} else {
ScanOutcome.NotAnIsbn(raw)
}
}
/** Allows the next scan of any code (including a repeat) to emit immediately, e.g. after Skip. */
@@ -2,6 +2,7 @@ package org.modg.bookshelf.ui.scan
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.data.metadata.LookupResult
/** SPEC.md scan screen: "Duplicate-ISBN warning if already owned." */
sealed interface DuplicateStatus {
@@ -27,14 +28,30 @@ sealed interface ScanSheetState {
*/
data class Loading(val isbn13: String) : ScanSheetState
data class Found(val isbn13: String, val metadata: BookMetadata, val duplicate: DuplicateStatus) : ScanSheetState
data class NotFound(val isbn13: String) : ScanSheetState
/**
* Every source answered and none had the book (SPEC: an honest "no", not a
* cover story for a failed request). [viaLookupFailure] is true only when the
* user reached this form through [LookupFailed]'s "Enter by hand" escape hatch
* rather than a genuine miss, so the sheet's copy can stop short of claiming
* the book is unknown.
*/
data class NotFound(val isbn13: String, val viaLookupFailure: Boolean = false) : ScanSheetState
/**
* At least one source couldn't be reached (non-2xx, timeout, transport error)
* and neither had the book — SPEC: "must NOT claim the book is unknown". Offers
* retry, manual entry, and skip instead of a manual-entry form captioned as a miss.
*/
data class LookupFailed(val isbn13: String, val reason: String) : ScanSheetState
}
/** Combines a metadata lookup result with duplicate status into the sheet state to show. */
object ScanMetadataOutcome {
fun from(isbn13: String, metadata: BookMetadata?, duplicate: DuplicateStatus): ScanSheetState = when (metadata) {
null -> ScanSheetState.NotFound(isbn13)
else -> ScanSheetState.Found(isbn13, metadata, duplicate)
fun from(isbn13: String, result: LookupResult, duplicate: DuplicateStatus): ScanSheetState = when (result) {
is LookupResult.Found -> ScanSheetState.Found(isbn13, result.metadata, duplicate)
is LookupResult.NotFound -> ScanSheetState.NotFound(isbn13)
is LookupResult.Unavailable -> ScanSheetState.LookupFailed(isbn13, result.reason)
}
}
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.FlashOff
@@ -43,7 +44,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
@@ -58,11 +63,13 @@ import org.modg.bookshelf.AppContainer
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.data.metadata.IsbnUtils
import org.modg.bookshelf.ui.components.BookCover
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.EmptyState
import org.modg.bookshelf.ui.components.PrimaryButton
import org.modg.bookshelf.ui.components.SecondaryButton
import org.modg.bookshelf.ui.components.ShelfPickerSheet
/**
* SPEC.md "scan" screen: camera + reticle, on-hit bottom sheet, continuous
@@ -82,6 +89,7 @@ fun ScanScreen(
bookRepository = container.bookRepository,
locationRepository = container.locationRepository,
metadataRepository = container.metadataRepository,
settingsStore = container.settingsStore,
)
}
},
@@ -89,10 +97,12 @@ fun ScanScreen(
val sheetState by viewModel.sheetState.collectAsState()
val sessionState by viewModel.sessionState.collectAsState()
val rejectedMessage by viewModel.rejectedMessage.collectAsState()
val torchEnabled by viewModel.scannerController.torchEnabled.collectAsState()
val bookcases by viewModel.bookcases.collectAsState()
val shelves by viewModel.shelves.collectAsState()
val selectedShelfId by viewModel.selectedShelfId.collectAsState()
val recentShelfId by viewModel.recentShelfId.collectAsState()
val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
@@ -128,6 +138,12 @@ fun ScanScreen(
CameraPreview(controller = viewModel.scannerController, torchEnabled = torchEnabled)
ScanReticle(modifier = Modifier.align(Alignment.Center))
SessionBadge(count = sessionState.savedCount, modifier = Modifier.align(Alignment.TopCenter).padding(16.dp))
rejectedMessage?.let { message ->
RejectedBarcodeBanner(
message = message,
modifier = Modifier.align(Alignment.BottomCenter).padding(24.dp),
)
}
} else {
PermissionDeniedContent(onRequestAgain = { permissionState.launchPermissionRequest() })
}
@@ -149,6 +165,7 @@ fun ScanScreen(
bookcases = bookcases,
shelves = shelves,
selectedShelfId = selectedShelfId,
recentShelfId = recentShelfId,
onShelfSelected = viewModel::selectShelf,
onSave = { viewModel.save(state.isbn13, state.metadata) },
onSkip = { viewModel.skip() },
@@ -160,14 +177,28 @@ fun ScanScreen(
) {
ManualEntrySheet(
isbn13 = state.isbn13,
authoritative = !state.viaLookupFailure,
bookcases = bookcases,
shelves = shelves,
selectedShelfId = selectedShelfId,
recentShelfId = recentShelfId,
onShelfSelected = viewModel::selectShelf,
onSave = { title, authors -> viewModel.saveManualEntry(state.isbn13, title, authors) },
onSkip = { viewModel.skip() },
)
}
is ScanSheetState.LookupFailed -> ModalBottomSheet(
onDismissRequest = { viewModel.dismissSheet() },
sheetState = rememberModalBottomSheetState(),
) {
LookupFailedSheet(
isbn13 = state.isbn13,
reason = state.reason,
onRetry = { viewModel.retryLookup(state.isbn13) },
onEnterByHand = { viewModel.enterByHand(state.isbn13) },
onSkip = { viewModel.skip() },
)
}
}
if (showManualEntry) {
@@ -238,9 +269,8 @@ internal fun ScanReticle(modifier: Modifier = Modifier) {
/**
* Shown the moment a barcode is decoded, while the metadata lookup runs. It names
* the ISBN it read and says so in words, because a lone spinner reads as "still
* working on it" — the user goes on holding the book up to the camera when the
* camera is already done with it.
* the ISBN it read, because a lone spinner reads as "still working on it" — echoing
* the number back is what tells the user the barcode was actually recognised.
*/
@Composable
internal fun SearchingSheet(isbn13: String) {
@@ -261,12 +291,6 @@ internal fun SearchingSheet(isbn13: String) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
Text(
text = "Barcode read — you can lower the book.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
}
}
@@ -299,6 +323,7 @@ internal fun FoundBookSheet(
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
selectedShelfId: String?,
recentShelfId: String?,
onShelfSelected: (String?) -> Unit,
onSave: () -> Unit,
onSkip: () -> Unit,
@@ -331,6 +356,7 @@ internal fun FoundBookSheet(
bookcases = bookcases,
shelves = shelves,
selectedShelfId = selectedShelfId,
recentShelfId = recentShelfId,
onShelfSelected = onShelfSelected,
)
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -340,12 +366,20 @@ internal fun FoundBookSheet(
}
}
/**
* Shown when every source answered and none had the book — SPEC: word this as the
* authoritative negative it is, not as a generic failure. [authoritative] is false
* only when this form was reached via [LookupFailedSheet]'s "Enter by hand", where
* the lookup never completed and the copy must not imply the book is unknown.
*/
@Composable
internal fun ManualEntrySheet(
isbn13: String,
authoritative: Boolean,
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
selectedShelfId: String?,
recentShelfId: String?,
onShelfSelected: (String?) -> Unit,
onSave: (title: String, authors: List<String>) -> Unit,
onSkip: () -> Unit,
@@ -354,9 +388,12 @@ internal fun ManualEntrySheet(
var authors by remember { mutableStateOf("") }
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(text = "No match found", style = MaterialTheme.typography.titleLarge)
Text(
text = "ISBN $isbn13 — enter the details by hand.",
text = if (authoritative) "Not in Open Library or Google Books" else "Enter the details by hand",
style = MaterialTheme.typography.titleLarge,
)
Text(
text = if (authoritative) "ISBN $isbn13 — enter the details by hand." else "ISBN $isbn13",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp),
@@ -377,6 +414,7 @@ internal fun ManualEntrySheet(
bookcases = bookcases,
shelves = shelves,
selectedShelfId = selectedShelfId,
recentShelfId = recentShelfId,
onShelfSelected = onShelfSelected,
)
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -393,14 +431,73 @@ internal fun ManualEntrySheet(
}
}
/**
* Shown when a lookup couldn't be completed — SPEC: "a retry affordance, with
* manual entry as the escape hatch; it must NOT claim the book is unknown." No
* shelf picker here: saving isn't offered from this sheet, only a path onward to
* one that does (Retry, or the manual-entry form via "Enter by hand"). [reason]
* is [MetadataRepository]'s diagnostic string (e.g. "open library: network error;
* google books: http 429") — the only channel we have back from a lookup failure
* on a real phone, so it must actually reach the screen instead of being dropped.
*/
@Composable
internal fun LookupFailedSheet(
isbn13: String,
reason: String,
onRetry: () -> Unit,
onEnterByHand: () -> Unit,
onSkip: () -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(text = "Couldn't complete the lookup", style = MaterialTheme.typography.titleLarge)
Text(
text = "ISBN $isbn13 — one or more sources couldn't be reached. " +
"This doesn't mean the book is unknown.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
Text(
text = reason,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
)
PrimaryButton(text = "Retry", onClick = onRetry, modifier = Modifier.fillMaxWidth())
Row(modifier = Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
SecondaryButton(text = "Enter by hand", onClick = onEnterByHand, modifier = Modifier.weight(1f))
SecondaryButton(text = "Skip", onClick = onSkip, modifier = Modifier.weight(1f))
}
}
}
/**
* Transient banner for a decoded-but-rejected barcode (SPEC.md "Barcode scanning":
* "A rejected barcode is NOT silent"). [ScanViewModel] owns the debounce (via
* [ScanCodeFilter]) and the auto-clear timer behind [message] — this composable
* just renders whatever it's handed.
*/
@Composable
internal fun RejectedBarcodeBanner(message: String, modifier: Modifier = Modifier) {
Text(
text = message,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = modifier
.background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(20.dp))
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
@Composable
internal fun ShelfPicker(
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
selectedShelfId: String?,
recentShelfId: String?,
onShelfSelected: (String?) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
var pickerOpen by remember { mutableStateOf(false) }
val currentShelf = shelves.find { it.id == selectedShelfId }
val currentBookcase = currentShelf?.let { shelf -> bookcases.find { it.id == shelf.bookcaseId } }
val label = if (currentShelf != null && currentBookcase != null) {
@@ -410,40 +507,64 @@ internal fun ShelfPicker(
}
Box(modifier = Modifier.padding(top = 12.dp)) {
SecondaryButton(text = label, onClick = { expanded = true })
androidx.compose.material3.DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
androidx.compose.material3.DropdownMenuItem(
text = { Text("Not shelved") },
onClick = { onShelfSelected(null); expanded = false },
SecondaryButton(text = label, onClick = { pickerOpen = true })
}
if (pickerOpen) {
ShelfPickerSheet(
bookcases = bookcases,
shelves = shelves,
selectedShelfId = selectedShelfId,
recentShelfId = recentShelfId,
onShelfSelected = onShelfSelected,
onDismissRequest = { pickerOpen = false },
)
bookcases.forEach { bookcase ->
shelves.filter { it.bookcaseId == bookcase.id }.forEach { shelf ->
androidx.compose.material3.DropdownMenuItem(
text = { Text("${bookcase.name}${shelf.label}") },
onClick = { onShelfSelected(shelf.id); expanded = false },
)
}
}
}
}
}
@Composable
private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit) {
var text by remember { mutableStateOf("") }
// Validate here rather than letting ScanViewModel.manualIsbnEntered drop an
// unparseable ISBN on the floor. A typed-in check digit is easy to get wrong,
// and a dialog whose button does nothing is the same silent failure this whole
// screen was just rebuilt to eliminate.
val isbn13 = remember(text) { IsbnUtils.toIsbn13(text) }
val malformed = text.isNotBlank() && isbn13 == null
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
// The dialog opens with the field unfocused otherwise — this is the only entry
// point into the field, so make it ready to type into immediately.
LaunchedEffect(Unit) {
runCatching { focusRequester.requestFocus() }
keyboardController?.show()
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Enter ISBN") },
text = {
Column {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("ISBN-10 or ISBN-13") },
singleLine = true,
isError = malformed,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.focusRequester(focusRequester),
)
if (malformed) {
Text(
text = "That isn't a valid ISBN — check the digits.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp),
)
}
}
},
confirmButton = {
PrimaryButton(text = "Look up", onClick = { onSubmit(text) }, enabled = text.isNotBlank())
PrimaryButton(text = "Look up", onClick = { onSubmit(text) }, enabled = isbn13 != null)
},
dismissButton = {
SecondaryButton(text = "Cancel", onClick = onDismiss)
@@ -2,6 +2,8 @@ package org.modg.bookshelf.ui.scan
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -13,6 +15,7 @@ import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.data.metadata.IsbnUtils
import org.modg.bookshelf.data.metadata.MetadataRepository
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.LocationRepository
@@ -26,6 +29,7 @@ class ScanViewModel(
private val bookRepository: BookRepository,
locationRepository: LocationRepository,
private val metadataRepository: MetadataRepository,
private val settingsStore: SettingsStore,
val scannerController: ScannerController = ScannerController(),
) : ViewModel() {
@@ -35,6 +39,11 @@ class ScanViewModel(
private val _sessionState = MutableStateFlow(ScanSessionState())
val sessionState: StateFlow<ScanSessionState> = _sessionState.asStateFlow()
private val _rejectedMessage = MutableStateFlow<String?>(null)
/** Transient "read but not a book barcode" message for the camera overlay; auto-clears. */
val rejectedMessage: StateFlow<String?> = _rejectedMessage.asStateFlow()
private var rejectedMessageClearJob: Job? = null
val bookcases: StateFlow<List<BookcaseEntity>> = locationRepository.observeBookcases()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val shelves: StateFlow<List<ShelfEntity>> = locationRepository.observeShelves()
@@ -44,18 +53,49 @@ class ScanViewModel(
private val _selectedShelfId = MutableStateFlow<String?>(null)
val selectedShelfId: StateFlow<String?> = _selectedShelfId.asStateFlow()
/** The shelf most recently assigned to any book, across sessions — surfaced as the picker's "Recent" shortcut. */
val recentShelfId: StateFlow<String?> = settingsStore.lastShelfId
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
init {
viewModelScope.launch {
scannerController.scanResults.collect { isbn13 -> onScanned(isbn13) }
}
viewModelScope.launch {
scannerController.rejectedCodes.collect { rawValue -> showRejectedMessage(rawValue) }
}
}
private suspend fun onScanned(isbn13: String) {
if (_sheetState.value !is ScanSheetState.Hidden) return // a sheet is already up for a previous hit
runLookup(isbn13)
}
private suspend fun runLookup(isbn13: String) {
_sheetState.value = ScanSheetState.Loading(isbn13)
val duplicate = DuplicateCheck.check(bookRepository.findByIsbn13(isbn13))
val metadata = metadataRepository.lookup(isbn13)
_sheetState.value = ScanMetadataOutcome.from(isbn13, metadata, duplicate)
val result = metadataRepository.lookup(isbn13)
_sheetState.value = ScanMetadataOutcome.from(isbn13, result, duplicate)
}
/** [ScanSheetState.LookupFailed]'s Retry — re-enters loading and re-runs the same lookup. */
fun retryLookup(isbn13: String) {
viewModelScope.launch { runLookup(isbn13) }
}
/** [ScanSheetState.LookupFailed]'s "Enter by hand" — falls through to the manual-entry form. */
fun enterByHand(isbn13: String) {
_sheetState.value = ScanSheetState.NotFound(isbn13, viaLookupFailure = true)
}
/** Throttled per [ScanCodeFilter]'s debounce; this just owns the auto-clear timer on top. */
private fun showRejectedMessage(rawValue: String) {
rejectedMessageClearJob?.cancel()
_rejectedMessage.value = "Read $rawValue — not a book barcode"
rejectedMessageClearJob = viewModelScope.launch {
delay(REJECTED_MESSAGE_MILLIS)
_rejectedMessage.value = null
}
}
/** The manual-ISBN-entry escape hatch (SPEC: for when a barcode won't scan). */
@@ -70,7 +110,15 @@ class ScanViewModel(
/** Save from a successful metadata lookup. */
fun save(isbn13: String, metadata: BookMetadata) {
viewModelScope.launch {
viewModelScope.launch { performSave(isbn13, metadata) }
}
/**
* The actual save-from-metadata logic, split out from [save] so tests can await it
* directly (as a plain suspend call) instead of racing [viewModelScope]'s launch.
*/
internal suspend fun performSave(isbn13: String, metadata: BookMetadata) {
val shelfId = _selectedShelfId.value
bookRepository.createBook(
title = metadata.title ?: "Untitled",
subtitle = metadata.subtitle,
@@ -82,23 +130,33 @@ class ScanViewModel(
pageCount = metadata.pageCount,
description = metadata.description,
coverSourceUrl = metadata.coverUrl,
shelfId = _selectedShelfId.value,
shelfId = shelfId,
)
rememberShelf(shelfId)
recordSave()
}
}
/** Save from the manual-entry form shown when metadata lookup misses (SPEC: pre-filled with the scanned ISBN). */
fun saveManualEntry(isbn13: String, title: String, authors: List<String>) {
viewModelScope.launch {
viewModelScope.launch { performSaveManualEntry(isbn13, title, authors) }
}
/** Same split as [performSave], for [saveManualEntry]. */
internal suspend fun performSaveManualEntry(isbn13: String, title: String, authors: List<String>) {
val shelfId = _selectedShelfId.value
bookRepository.createBook(
title = title.ifBlank { "Untitled" },
authors = authors,
isbn13 = isbn13,
shelfId = _selectedShelfId.value,
shelfId = shelfId,
)
rememberShelf(shelfId)
recordSave()
}
/** "Not shelved" (null) must never overwrite the memory — it isn't a shelf. */
private suspend fun rememberShelf(shelfId: String?) {
if (shelfId != null) settingsStore.setLastShelfId(shelfId)
}
private fun recordSave() {
@@ -114,4 +172,9 @@ class ScanViewModel(
_sheetState.value = ScanSheetState.Hidden
scannerController.resetDebounce()
}
private companion object {
/** How long a rejected-barcode message stays on screen before it auto-clears. */
const val REJECTED_MESSAGE_MILLIS = 3000L
}
}
@@ -19,6 +19,14 @@ class ScannerController(
/** Emits a debounced, checksum-valid ISBN-13 each time [IsbnBarcodeAnalyzer] sees a new book barcode. */
val scanResults: SharedFlow<String> = _scanResults.asSharedFlow()
private val _rejectedCodes = MutableSharedFlow<String>(extraBufferCapacity = 1)
/**
* Emits the raw value of a debounced, non-book barcode read (SPEC.md "Barcode
* scanning": "A rejected barcode is NOT silent"). [ScanOutcome.Ignored] reads
* (debounced repeats) never reach here.
*/
val rejectedCodes: SharedFlow<String> = _rejectedCodes.asSharedFlow()
private val _torchEnabled = MutableStateFlow(false)
val torchEnabled: StateFlow<Boolean> = _torchEnabled.asStateFlow()
@@ -27,7 +35,11 @@ class ScannerController(
/** Called by [IsbnBarcodeAnalyzer] for every decoded barcode's raw value. */
fun onBarcodeScanned(rawValue: String?) {
codeFilter.accept(rawValue)?.let { _scanResults.tryEmit(it) }
when (val outcome = codeFilter.accept(rawValue)) {
is ScanOutcome.Isbn -> _scanResults.tryEmit(outcome.isbn13)
is ScanOutcome.NotAnIsbn -> _rejectedCodes.tryEmit(outcome.rawValue)
ScanOutcome.Ignored -> Unit
}
}
fun toggleTorch() {
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols "shelves" (Apache 2.0). Source viewBox is
"0 -960 960 960"; Android has no viewport origin, so the group
translate is load-bearing. Do not flatten it. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<group android:translateY="960">
<path
android:fillColor="#FF000000"
android:pathData="M120-40v-880h80v80h560v-80h80v880h-80v-80H200v80h-80Zm80-480h80v-160h240v160h240v-240H200v240Zm0 320h240v-160h240v160h80v-240H200v240Zm160-320h80v-80h-80v80Zm160 320h80v-80h-80v80Z" />
</group>
</vector>
@@ -46,6 +46,47 @@ class GoogleBooksClientTest {
assertNull(client.parseResponse(body))
}
// --- classify(): the three-way per-source outcome (SPEC.md "Book metadata lookup"). ---
@Test
fun `classify reports Found for a 2xx response with a record`() {
val result = client.classify(200, fixture("googlebooks_success.json"))
val found = result as? SourceResult.Found
checkNotNull(found) { "expected Found, got $result" }
assertEquals("Effective Java", found.metadata.title)
}
@Test
fun `classify reports NotFound for a 2xx response with no items`() {
val result = client.classify(200, fixture("googlebooks_no_items.json"))
assertEquals(SourceResult.NotFound, result)
}
@Test
fun `classify reports Failed for a 404`() {
val result = client.classify(404, null)
assertEquals(SourceResult.Failed("http 404", FailureKind.CLIENT_ERROR), result)
}
@Test
fun `classify reports Failed for a 429 keyless-quota response, distinctly from NotFound`() {
val result = client.classify(429, null)
assertEquals(SourceResult.Failed("http 429 (rate limited)", FailureKind.RATE_LIMITED), result)
}
@Test
fun `classify reports Failed for a 500`() {
val result = client.classify(500, "Internal Server Error")
assertEquals(SourceResult.Failed("http 500 (server error)", FailureKind.SERVER_ERROR), result)
}
@Test
fun `classify reports Failed for a malformed body even on a 2xx status`() {
val result = client.classify(200, fixture("malformed.json"))
assertEquals(SourceResult.Failed("malformed json", FailureKind.MALFORMED), result)
}
private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
.bufferedReader()
@@ -6,6 +6,80 @@ import org.junit.Test
class MetadataRepositoryTest {
private val isbn13 = "9780201558029"
private val openLibraryHit = SourceResult.Found(BookMetadata(title = "Open Library Title", isbn13 = isbn13))
private val googleBooksHit = SourceResult.Found(BookMetadata(title = "Google Books Title", isbn13 = isbn13))
// --- The full 3x3 (Open Library outcome x Google Books outcome) combination matrix
// from SPEC.md "Book metadata lookup": any Found wins, all-NotFound is an honest
// miss, anything else with a Failed in it is Unavailable — never a false negative. ---
@Test
fun `Found + Found merges and returns Found`() {
val result = MetadataRepository.combine(openLibraryHit, googleBooksHit, isbn13)
assertTrue(result is LookupResult.Found)
}
@Test
fun `Found + NotFound returns Found from the one source that had it`() {
val result = MetadataRepository.combine(openLibraryHit, SourceResult.NotFound, isbn13)
assertEquals("Open Library Title", (result as LookupResult.Found).metadata.title)
}
@Test
fun `Found + Failed returns Found -- a reachable hit is not overruled by the other failing`() {
val result = MetadataRepository.combine(openLibraryHit, SourceResult.Failed("http 429"), isbn13)
assertEquals("Open Library Title", (result as LookupResult.Found).metadata.title)
}
@Test
fun `NotFound + Found returns Found from the one source that had it`() {
val result = MetadataRepository.combine(SourceResult.NotFound, googleBooksHit, isbn13)
assertEquals("Google Books Title", (result as LookupResult.Found).metadata.title)
}
@Test
fun `NotFound + NotFound returns NotFound -- both sources answered and neither had it`() {
val result = MetadataRepository.combine(SourceResult.NotFound, SourceResult.NotFound, isbn13)
assertEquals(LookupResult.NotFound, result)
}
@Test
fun `NotFound + Failed returns Unavailable -- one honest miss is not authoritative alone`() {
val result = MetadataRepository.combine(SourceResult.NotFound, SourceResult.Failed("timeout"), isbn13)
assertTrue(result is LookupResult.Unavailable)
}
@Test
fun `Failed + Found returns Found from the one source that had it`() {
val result = MetadataRepository.combine(SourceResult.Failed("http 500"), googleBooksHit, isbn13)
assertEquals("Google Books Title", (result as LookupResult.Found).metadata.title)
}
@Test
fun `Failed + NotFound returns Unavailable -- one honest miss is not authoritative alone`() {
val result = MetadataRepository.combine(SourceResult.Failed("timeout"), SourceResult.NotFound, isbn13)
assertTrue(result is LookupResult.Unavailable)
}
@Test
fun `Failed + Failed returns Unavailable and preserves both reasons`() {
val result = MetadataRepository.combine(SourceResult.Failed("http 429"), SourceResult.Failed("timeout"), isbn13)
val unavailable = result as? LookupResult.Unavailable
checkNotNull(unavailable) { "expected Unavailable, got $result" }
assertTrue(unavailable.reason.contains("http 429"))
assertTrue(unavailable.reason.contains("timeout"))
}
@Test
fun `a Found result without cover art falls back to the by-isbn cover url`() {
val bare = SourceResult.Found(BookMetadata(title = "No Cover", isbn13 = isbn13))
val result = MetadataRepository.combine(bare, SourceResult.NotFound, isbn13)
assertEquals(MetadataRepository.byIsbnCoverUrl(isbn13), (result as LookupResult.Found).metadata.coverUrl)
}
/**
* `default=false` is the whole point of this URL. Without it covers.openlibrary.org
* answers 200 with a 43-byte 1x1 transparent GIF for an edition it holds no art for
@@ -105,6 +105,47 @@ class OpenLibraryClientTest {
assertNull(client.parseResponse(body, "9780201558029"))
}
// --- classify(): the three-way per-source outcome (SPEC.md "Book metadata lookup"). ---
@Test
fun `classify reports Found for a 2xx response with a record`() {
val result = client.classify(200, fixture("openlibrary_success.json"), "9780201558029")
val found = result as? SourceResult.Found
checkNotNull(found) { "expected Found, got $result" }
assertEquals("Concrete Mathematics", found.metadata.title)
}
@Test
fun `classify reports NotFound for a 2xx response with no record for that isbn`() {
val result = client.classify(200, fixture("openlibrary_not_found.json"), "9780201558029")
assertEquals(SourceResult.NotFound, result)
}
@Test
fun `classify reports Failed for a 404`() {
val result = client.classify(404, null, "9780201558029")
assertEquals(SourceResult.Failed("http 404", FailureKind.CLIENT_ERROR), result)
}
@Test
fun `classify reports Failed for a 429, distinctly from NotFound`() {
val result = client.classify(429, null, "9780201558029")
assertEquals(SourceResult.Failed("http 429 (rate limited)", FailureKind.RATE_LIMITED), result)
}
@Test
fun `classify reports Failed for a 500`() {
val result = client.classify(500, "Internal Server Error", "9780201558029")
assertEquals(SourceResult.Failed("http 500 (server error)", FailureKind.SERVER_ERROR), result)
}
@Test
fun `classify reports Failed for a malformed body even on a 2xx status`() {
val result = client.classify(200, fixture("malformed.json"), "9780201558029")
assertEquals(SourceResult.Failed("malformed json", FailureKind.MALFORMED), result)
}
private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
.bufferedReader()
@@ -0,0 +1,191 @@
package org.modg.bookshelf.data.metadata
import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
import javax.net.ssl.SSLHandshakeException
import kotlin.random.Random
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Covers the retry decision and the loop that acts on it. Every case is driven by
* the 2026-09-09 measurement of the live Open Library API recorded in
* docs/METADATA-SOURCES.md: fast transient TLS resets (worth repeating) against a
* slow but usually-successful long tail (not worth repeating).
*
* The loop is exercised with an injected clock and sleep, so these assert the real
* policy constants with no wall-clock time and no flakiness.
*/
class RetryPolicyTest {
private val transport = SourceResult.Failed("tls connection reset", FailureKind.TRANSPORT)
private val found = SourceResult.Found(BookMetadata(title = "Dune"))
// --- which failures are worth repeating ---
@Test
fun `transport and server errors are retryable`() {
assertTrue(RetryPolicy.isRetryable(FailureKind.TRANSPORT))
assertTrue(RetryPolicy.isRetryable(FailureKind.SERVER_ERROR))
}
@Test
fun `a timeout is not retried -- its budget is already spent`() {
assertFalse(RetryPolicy.isRetryable(FailureKind.TIMEOUT))
}
@Test
fun `a rate limit is not retried -- hammering a quota is how a block becomes permanent`() {
assertFalse(RetryPolicy.isRetryable(FailureKind.RATE_LIMITED))
}
@Test
fun `deterministic failures are not retried`() {
assertFalse(RetryPolicy.isRetryable(FailureKind.CLIENT_ERROR))
assertFalse(RetryPolicy.isRetryable(FailureKind.MALFORMED))
}
// --- the loop ---
@Test
fun `a first-attempt success is returned without retrying`() = runTest {
var calls = 0
val result = withRetry(sleep = {}) { calls++; found }
assertEquals(found, result)
assertEquals(1, calls)
}
@Test
fun `a transient failure followed by success returns the success`() = runTest {
var calls = 0
val result = withRetry(sleep = {}) {
calls++
if (calls == 1) transport else found
}
assertEquals(found, result)
assertEquals(2, calls)
}
@Test
fun `retrying stops at MAX_ATTEMPTS and reports how many were made`() = runTest {
var calls = 0
val result = withRetry(sleep = {}) { calls++; transport }
assertEquals(RetryPolicy.MAX_ATTEMPTS, calls)
// The attempt count is the whole diagnostic value of the string on a real
// phone: one reset and three in a row are different network stories.
assertEquals(
SourceResult.Failed("tls connection reset, 3 attempts", FailureKind.TRANSPORT),
result,
)
}
@Test
fun `a single failure is reported without an attempt count`() = runTest {
val timeout = SourceResult.Failed("timeout", FailureKind.TIMEOUT)
var calls = 0
val result = withRetry(sleep = {}) { calls++; timeout }
assertEquals(1, calls)
assertEquals(timeout, result)
}
@Test
fun `NotFound is authoritative and is never retried`() = runTest {
var calls = 0
val result = withRetry(sleep = {}) { calls++; SourceResult.NotFound }
assertEquals(SourceResult.NotFound, result)
assertEquals(1, calls)
}
@Test
fun `an un-retryable failure short-circuits after one attempt`() = runTest {
var calls = 0
val result = withRetry(sleep = {}) { calls++; SourceResult.fromHttpCode(429) }
assertEquals(1, calls)
assertEquals(FailureKind.RATE_LIMITED, (result as SourceResult.Failed).kind)
}
@Test
fun `the budget stops a new attempt but never cancels one in flight`() = runTest {
// Clock jumps past the budget during the first attempt. The result of that
// attempt must still be honoured, and no second attempt may start.
var now = 0L
var calls = 0
val result = withRetry(
budgetMillis = 1_000L,
nowMillis = { now },
sleep = {},
) {
calls++
now += 5_000L
transport
}
assertEquals(1, calls)
assertEquals(transport, result)
}
@Test
fun `backoff is short and jittered, never zero and never seconds long`() {
val r = Random(1234)
repeat(200) {
val first = RetryPolicy.backoffMillis(2, r)
val second = RetryPolicy.backoffMillis(3, r)
assertTrue("first retry backoff was $first", first in 250L..349L)
assertTrue("second retry backoff was $second", second in 750L..1049L)
}
}
// --- exception and status classification ---
@Test
fun `socket timeout and a blown call timeout both classify as TIMEOUT`() {
assertEquals(FailureKind.TIMEOUT, SourceResult.fromException(SocketTimeoutException()).kind)
// OkHttp reports an exceeded callTimeout as a bare InterruptedIOException.
assertEquals(FailureKind.TIMEOUT, SourceResult.fromException(InterruptedIOException()).kind)
}
@Test
fun `the observed live failure -- a TLS-stage reset -- classifies as retryable transport`() {
val failed = SourceResult.fromException(SSLException("Connection reset by peer"))
assertEquals(FailureKind.TRANSPORT, failed.kind)
assertTrue(RetryPolicy.isRetryable(failed.kind))
}
@Test
fun `each transport exception gets its own reason, not a generic one`() {
// The reason string is the only diagnostic we get back from a real phone,
// so these must stay distinguishable from each other.
val reasons = listOf(
SourceResult.fromException(UnknownHostException()).reason,
SourceResult.fromException(SSLHandshakeException("h")).reason,
SourceResult.fromException(SSLException("r")).reason,
SourceResult.fromException(ConnectException()).reason,
SourceResult.fromException(SocketException()).reason,
)
assertEquals(reasons.size, reasons.toSet().size)
assertTrue(reasons.none { it.isBlank() })
}
@Test
fun `an unrecognised IOException names its own type rather than saying network error`() {
val failed = SourceResult.fromException(IOException("boom"))
assertEquals(FailureKind.TRANSPORT, failed.kind)
assertTrue(failed.reason, failed.reason.contains("IOException"))
}
@Test
fun `http statuses map to the kinds that drive retrying`() {
assertEquals(FailureKind.RATE_LIMITED, SourceResult.fromHttpCode(429).kind)
assertEquals(FailureKind.SERVER_ERROR, SourceResult.fromHttpCode(500).kind)
assertEquals(FailureKind.SERVER_ERROR, SourceResult.fromHttpCode(503).kind)
assertEquals(FailureKind.CLIENT_ERROR, SourceResult.fromHttpCode(404).kind)
assertEquals(FailureKind.CLIENT_ERROR, SourceResult.fromHttpCode(400).kind)
}
}
@@ -0,0 +1,73 @@
package org.modg.bookshelf.data.prefs
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Covers the "remember the most recently used shelf" feature's persistence layer —
* see [org.modg.bookshelf.ui.scan.ScanViewModel] and
* [org.modg.bookshelf.ui.detail.DetailViewModel] for the callers that decide *when*
* to write, and [org.modg.bookshelf.ui.components.resolveRecentShelf] for how the
* picker decides whether the remembered shelf is still offerable.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class SettingsStoreTest {
private lateinit var settingsStore: SettingsStore
@Before
fun setUp() = runTest {
settingsStore = SettingsStore(ApplicationProvider.getApplicationContext())
// DataStore's backing file lives in the app's real files dir, which Robolectric
// does not reset between test methods in this class — start every test from a
// known-clean slate instead of depending on method execution order.
settingsStore.clearLastShelfId()
}
@Test
fun `last shelf id is null before anything is remembered`() = runTest {
assertNull(settingsStore.lastShelfId.first())
}
@Test
fun `last shelf id round-trips through the store`() = runTest {
settingsStore.setLastShelfId("sh-top")
assertEquals("sh-top", settingsStore.lastShelfId.first())
}
@Test
fun `setting a new last shelf id overwrites the previous one`() = runTest {
settingsStore.setLastShelfId("sh-top")
settingsStore.setLastShelfId("sh-desk")
assertEquals("sh-desk", settingsStore.lastShelfId.first())
}
@Test
fun `clearLastShelfId drops the remembered shelf`() = runTest {
settingsStore.setLastShelfId("sh-top")
settingsStore.clearLastShelfId()
assertNull(settingsStore.lastShelfId.first())
}
@Test
fun `sign out clears the remembered shelf so it can't leak to the other account`() = runTest {
settingsStore.setLastShelfId("sh-top")
settingsStore.clearAuth()
assertNull(settingsStore.lastShelfId.first())
}
}
@@ -0,0 +1,78 @@
package org.modg.bookshelf.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.local.SyncState
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* [ShelfPickerSheet]'s grouped picker, replacing the old flat dropdown (SPEC
* task: "the state the whole change exists for"). Renders [ShelfPickerContent]
* directly rather than the real [androidx.compose.material3.ModalBottomSheet] —
* Paparazzi has no real Window/scrim behind a headless sheet, same problem
* [org.modg.bookshelf.ui.screens.ScanScreenPaparazziTest]'s class doc describes.
* Two bookcases, one of them empty, plus a remembered "Recent" shelf — the
* state this whole component exists for.
*/
class ShelfPickerSheetPaparazziTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
private val livingRoom = BookcaseEntity(
id = "bc-living-room", name = "Living Room", note = null, position = 0,
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
)
private val study = BookcaseEntity(
id = "bc-study", name = "Study", note = null, position = 1,
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
)
private val topShelf = ShelfEntity(
id = "sh-top", bookcaseId = livingRoom.id, label = "Top shelf — fiction", position = 0,
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
)
private val bottomShelf = ShelfEntity(
id = "sh-bottom", bookcaseId = livingRoom.id, label = "Bottom shelf — reference", position = 1,
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
)
@Test
fun shelfPickerGroupedLight() = snapshotBoth("shelf-picker-grouped") {
Content()
}
@Composable
private fun Content() = Shell {
ShelfPickerContent(
bookcases = listOf(livingRoom, study), // "study" has no shelves yet — empty-bookcase state
shelves = listOf(topShelf, bottomShelf),
selectedShelfId = bottomShelf.id,
recentShelfId = topShelf.id, // recent shelf, distinct from the current selection
onShelfSelected = {},
)
}
@Composable
private fun Shell(content: @Composable () -> Unit) {
Box(modifier = Modifier.fillMaxWidth()) {
Surface(color = MaterialTheme.colorScheme.surface) {
content()
}
}
}
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() } }
}
}
@@ -0,0 +1,53 @@
package org.modg.bookshelf.ui.components
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.local.SyncState
/**
* [resolveRecentShelf] backs [ShelfPickerSheet]'s "Recent" section: it must be
* omitted, not shown dangling, when there is nothing remembered or the
* remembered shelf no longer exists (deleted since it was last used).
*/
class ShelfPickerSheetTest {
private val livingRoom = BookcaseEntity(
id = "bc-living-room", name = "Living Room", note = null, position = 0,
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
)
private val topShelf = ShelfEntity(
id = "sh-top", bookcaseId = livingRoom.id, label = "Top shelf", position = 0,
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
)
@Test
fun `no remembered shelf resolves to null`() {
val result = resolveRecentShelf(recentShelfId = null, shelves = listOf(topShelf), bookcases = listOf(livingRoom))
assertNull(result)
}
@Test
fun `a remembered shelf that no longer exists resolves to null`() {
val result = resolveRecentShelf(recentShelfId = "sh-deleted", shelves = listOf(topShelf), bookcases = listOf(livingRoom))
assertNull(result)
}
@Test
fun `a remembered shelf whose bookcase was deleted resolves to null`() {
val result = resolveRecentShelf(recentShelfId = topShelf.id, shelves = listOf(topShelf), bookcases = emptyList())
assertNull(result)
}
@Test
fun `a remembered shelf that still exists resolves to its shelf and bookcase`() {
val result = resolveRecentShelf(recentShelfId = topShelf.id, shelves = listOf(topShelf), bookcases = listOf(livingRoom))
assertEquals(topShelf to livingRoom, result)
}
}
@@ -0,0 +1,77 @@
package org.modg.bookshelf.ui.detail
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.BookshelfDatabase
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.LocationRepository
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* SPEC's "remember the most recently used shelf" from the detail screen's side:
* [DetailViewModel.saveLocation] must write a non-null shelf to [SettingsStore],
* but "Not shelved" (null) must never overwrite what's already remembered.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class DetailViewModelTest {
private lateinit var db: BookshelfDatabase
private lateinit var settingsStore: SettingsStore
private lateinit var viewModel: DetailViewModel
private lateinit var book: BookEntity
private lateinit var shelfId: String
@Before
fun setUp() = runTest {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
.allowMainThreadQueries()
.build()
settingsStore = SettingsStore(context)
val bookRepository = BookRepository(db.bookDao(), context)
val locationRepository = LocationRepository(db.bookcaseDao(), db.shelfDao(), db.bookDao())
val bookcaseId = locationRepository.createBookcase(name = "Living Room")
shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf")
val bookId = bookRepository.createBook(title = "Piranesi")
book = checkNotNull(bookRepository.getById(bookId))
viewModel = DetailViewModel(
bookRepository = bookRepository,
locationRepository = locationRepository,
settingsStore = settingsStore,
bookId = bookId,
)
}
@After
fun tearDown() {
db.close()
}
@Test
fun `moving a book to a shelf remembers that shelf`() = runTest {
viewModel.performSaveLocation(book, shelfId)
assertEquals(shelfId, settingsStore.lastShelfId.first())
}
@Test
fun `marking a book Not shelved does not overwrite the remembered shelf`() = runTest {
settingsStore.setLastShelfId(shelfId)
viewModel.performSaveLocation(book, null)
assertEquals(shelfId, settingsStore.lastShelfId.first())
}
}
@@ -1,7 +1,6 @@
package org.modg.bookshelf.ui.scan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ScanCodeFilterTest {
@@ -9,45 +8,45 @@ class ScanCodeFilterTest {
@Test
fun `accepts a checksum-valid isbn13`() {
val filter = ScanCodeFilter()
assertEquals("9780201558029", filter.accept("9780201558029"))
assertEquals(ScanOutcome.Isbn("9780201558029"), filter.accept("9780201558029"))
}
@Test
fun `rejects a checksum-invalid isbn13-shaped code`() {
fun `rejects a checksum-invalid isbn13-shaped code as not-an-isbn`() {
val filter = ScanCodeFilter()
assertNull(filter.accept("9780201558020"))
assertEquals(ScanOutcome.NotAnIsbn("9780201558020"), filter.accept("9780201558020"))
}
@Test
fun `rejects non-book barcode lengths such as EAN-8 or UPC-A`() {
fun `rejects non-book barcode lengths such as EAN-8 or UPC-A as not-an-isbn`() {
val filter = ScanCodeFilter()
assertNull(filter.accept("12345670")) // EAN-8 shaped
assertNull(filter.accept("012345678905")) // UPC-A shaped, 12 digits
assertEquals(ScanOutcome.NotAnIsbn("12345670"), filter.accept("12345670")) // EAN-8 shaped
assertEquals(ScanOutcome.NotAnIsbn("012345678905"), filter.accept("012345678905")) // UPC-A shaped, 12 digits
}
@Test
fun `rejects null raw value`() {
assertNull(ScanCodeFilter().accept(null))
fun `ignores null raw value`() {
assertEquals(ScanOutcome.Ignored, ScanCodeFilter().accept(null))
}
@Test
fun `debounces a repeat of the same code within the window`() {
fun `debounces a repeat of the same valid code within the window`() {
var now = 0L
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
assertEquals("9780201558029", filter.accept("9780201558029"))
assertEquals(ScanOutcome.Isbn("9780201558029"), filter.accept("9780201558029"))
now += 500
assertNull(filter.accept("9780201558029"))
assertEquals(ScanOutcome.Ignored, filter.accept("9780201558029"))
}
@Test
fun `re-emits the same code once the debounce window elapses`() {
fun `re-emits the same valid code once the debounce window elapses`() {
var now = 0L
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
assertEquals("9780201558029", filter.accept("9780201558029"))
assertEquals(ScanOutcome.Isbn("9780201558029"), filter.accept("9780201558029"))
now += 2001
assertEquals("9780201558029", filter.accept("9780201558029"))
assertEquals(ScanOutcome.Isbn("9780201558029"), filter.accept("9780201558029"))
}
@Test
@@ -55,9 +54,9 @@ class ScanCodeFilterTest {
var now = 0L
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
assertEquals("9780201558029", filter.accept("9780201558029"))
assertEquals(ScanOutcome.Isbn("9780201558029"), filter.accept("9780201558029"))
now += 10
assertEquals("9780134685991", filter.accept("9780134685991"))
assertEquals(ScanOutcome.Isbn("9780134685991"), filter.accept("9780134685991"))
}
@Test
@@ -65,8 +64,56 @@ class ScanCodeFilterTest {
var now = 0L
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
assertEquals("9780201558029", filter.accept("9780201558029"))
assertEquals(ScanOutcome.Isbn("9780201558029"), filter.accept("9780201558029"))
filter.reset()
assertEquals("9780201558029", filter.accept("9780201558029"))
assertEquals(ScanOutcome.Isbn("9780201558029"), filter.accept("9780201558029"))
}
// --- Rejected-code throttle (SPEC.md "Barcode scanning": a non-book barcode sitting
// in frame decodes on almost every analyzed frame, so this must not flicker). ---
@Test
fun `a rejected code emits once, then is throttled on every subsequent frame within the window`() {
var now = 0L
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
assertEquals(ScanOutcome.NotAnIsbn("12345670"), filter.accept("12345670"))
// Simulate several more analyzed frames, all still within the debounce window.
now += 50
assertEquals(ScanOutcome.Ignored, filter.accept("12345670"))
now += 50
assertEquals(ScanOutcome.Ignored, filter.accept("12345670"))
now += 1000
assertEquals(ScanOutcome.Ignored, filter.accept("12345670"))
}
@Test
fun `a rejected code re-emits once the debounce window elapses`() {
var now = 0L
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
assertEquals(ScanOutcome.NotAnIsbn("12345670"), filter.accept("12345670"))
now += 2001
assertEquals(ScanOutcome.NotAnIsbn("12345670"), filter.accept("12345670"))
}
@Test
fun `a rejected code does not suppress a genuinely different valid isbn read right after it`() {
var now = 0L
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
assertEquals(ScanOutcome.NotAnIsbn("12345670"), filter.accept("12345670"))
now += 10
assertEquals(ScanOutcome.Isbn("9780201558029"), filter.accept("9780201558029"))
}
@Test
fun `switching between two different rejected codes does not throttle either`() {
var now = 0L
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
assertEquals(ScanOutcome.NotAnIsbn("12345670"), filter.accept("12345670"))
now += 10
assertEquals(ScanOutcome.NotAnIsbn("012345678905"), filter.accept("012345678905"))
}
}
@@ -5,6 +5,7 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.data.metadata.LookupResult
class ScanModelsTest {
@@ -25,21 +26,27 @@ class ScanModelsTest {
}
@Test
fun `metadata miss maps to NotFound regardless of duplicate status`() {
val outcome = ScanMetadataOutcome.from("9780201558029", null, DuplicateStatus.New)
fun `an authoritative NotFound result maps to NotFound regardless of duplicate status`() {
val outcome = ScanMetadataOutcome.from("9780201558029", LookupResult.NotFound, DuplicateStatus.New)
assertEquals(ScanSheetState.NotFound("9780201558029"), outcome)
}
@Test
fun `metadata hit maps to Found carrying the duplicate status through`() {
fun `a Found result maps to Found carrying the duplicate status through`() {
val metadata = BookMetadata(title = "Dune", isbn13 = "9780201558029")
val duplicate = DuplicateStatus.AlreadyOwned("abc123", "Dune")
val outcome = ScanMetadataOutcome.from("9780201558029", metadata, duplicate)
val outcome = ScanMetadataOutcome.from("9780201558029", LookupResult.Found(metadata), duplicate)
assertEquals(ScanSheetState.Found("9780201558029", metadata, duplicate), outcome)
}
@Test
fun `an Unavailable result maps to LookupFailed and never claims the book is unknown`() {
val outcome = ScanMetadataOutcome.from("9780201558029", LookupResult.Unavailable("http 429"), DuplicateStatus.New)
assertEquals(ScanSheetState.LookupFailed("9780201558029", "http 429"), outcome)
}
@Test
fun `session count starts at zero and increments per save, not per skip`() {
var session = ScanSessionState()
@@ -0,0 +1,100 @@
package org.modg.bookshelf.ui.scan
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
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.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.modg.bookshelf.data.local.BookshelfDatabase
import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.data.metadata.MetadataRepository
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.LocationRepository
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* SPEC's "remember the most recently used shelf": [ScanViewModel.save] and
* [ScanViewModel.saveManualEntry] must write the chosen shelf to [SettingsStore]
* so it survives to the next scanning session — except "Not shelved" (null),
* which must never overwrite what's already remembered.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ScanViewModelTest {
private lateinit var db: BookshelfDatabase
private lateinit var settingsStore: SettingsStore
private lateinit var viewModel: ScanViewModel
private lateinit var locationRepository: LocationRepository
private lateinit var shelfId: String
@Before
fun setUp() = runTest {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
.allowMainThreadQueries()
.build()
settingsStore = SettingsStore(context)
locationRepository = LocationRepository(db.bookcaseDao(), db.shelfDao(), db.bookDao())
val bookcaseId = locationRepository.createBookcase(name = "Living Room")
shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf")
viewModel = ScanViewModel(
bookRepository = BookRepository(db.bookDao(), context),
locationRepository = locationRepository,
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
settingsStore = settingsStore,
)
}
@After
fun tearDown() {
db.close()
}
@Test
fun `saving a metadata hit to a shelf remembers that shelf`() = runTest {
viewModel.selectShelf(shelfId)
viewModel.performSave("9780765326355", BookMetadata(title = "The Way of Kings"))
assertEquals(shelfId, settingsStore.lastShelfId.first())
}
@Test
fun `saving a manual entry to a shelf remembers that shelf`() = runTest {
viewModel.selectShelf(shelfId)
viewModel.performSaveManualEntry("9780765326355", "The Way of Kings", listOf("Brandon Sanderson"))
assertEquals(shelfId, settingsStore.lastShelfId.first())
}
@Test
fun `saving as Not shelved does not overwrite the remembered shelf`() = runTest {
settingsStore.setLastShelfId(shelfId)
viewModel.selectShelf(null)
viewModel.performSave("9780765326355", BookMetadata(title = "The Way of Kings"))
assertEquals(shelfId, settingsStore.lastShelfId.first())
}
@Test
fun `saving a manual entry as Not shelved does not overwrite the remembered shelf`() = runTest {
settingsStore.setLastShelfId(shelfId)
viewModel.selectShelf(null)
viewModel.performSaveManualEntry("9780765326355", "The Way of Kings", emptyList())
assertEquals(shelfId, settingsStore.lastShelfId.first())
}
}
@@ -46,6 +46,39 @@ class ScannerControllerTest {
assertEquals(listOf("9780201558029"), received)
}
@Test
fun `non-book barcode emits on rejectedCodes, not scanResults`() = runTest {
val controller = ScannerController()
val scanned = mutableListOf<String>()
val rejected = mutableListOf<String>()
backgroundScope.launch { controller.scanResults.toList(scanned) }
backgroundScope.launch { controller.rejectedCodes.toList(rejected) }
runCurrent()
controller.onBarcodeScanned("012345678905") // UPC-A shaped, fails the ISBN-13 checksum
runCurrent()
assertTrue(scanned.isEmpty())
assertEquals(listOf("012345678905"), rejected)
}
@Test
fun `a debounced repeat reaches neither scanResults nor rejectedCodes`() = runTest {
val controller = ScannerController()
val scanned = mutableListOf<String>()
val rejected = mutableListOf<String>()
backgroundScope.launch { controller.scanResults.toList(scanned) }
backgroundScope.launch { controller.rejectedCodes.toList(rejected) }
runCurrent()
controller.onBarcodeScanned("012345678905")
controller.onBarcodeScanned("012345678905") // same frame's worth of repeat reads
runCurrent()
assertEquals(listOf("012345678905"), rejected)
assertTrue(scanned.isEmpty())
}
@Test
fun `torch toggles from its default off state`() {
val controller = ScannerController()
@@ -78,6 +78,7 @@ class DetailScreenPaparazziTest {
book = book,
bookcases = ScreenFixtures.bookcases,
shelves = ScreenFixtures.shelves,
recentShelfId = null,
onShelfSelected = {},
)
}
@@ -18,6 +18,7 @@ import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface
@@ -40,12 +41,25 @@ class LocationsScreenPaparazziTest {
@Test
fun locationsPopulatedLight() = snapshotBoth("locations-populated") { Populated() }
@Composable
private fun Populated() = Shell()
/**
* Regression coverage for the ghost-bookcase bug: with the list branch not
* folding in the Scaffold's top app bar inset, a single bookcase's row
* rendered underneath the app bar and was invisible. This renders exactly
* that one-bookcase state; check the recorded PNG shows the bookcase row
* fully below the app bar, not clipped/hidden behind it.
*/
@Test
fun locationsSingleBookcaseLight() = snapshotBoth("locations-single-bookcase") { SingleBookcase() }
@Composable
private fun Shell() {
val bookcaseUis = ScreenFixtures.bookcases.map { bookcase ->
private fun Populated() = Shell(ScreenFixtures.bookcases)
@Composable
private fun SingleBookcase() = Shell(listOf(ScreenFixtures.livingRoom))
@Composable
private fun Shell(bookcases: List<BookcaseEntity>) {
val bookcaseUis = bookcases.map { bookcase ->
BookcaseUi(
bookcase = bookcase,
shelves = ScreenFixtures.shelves
@@ -27,6 +27,8 @@ import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.scan.DuplicateStatus
import org.modg.bookshelf.ui.scan.FoundBookSheet
import org.modg.bookshelf.ui.scan.LookupFailedSheet
import org.modg.bookshelf.ui.scan.RejectedBarcodeBanner
import org.modg.bookshelf.ui.scan.ScanReticle
import org.modg.bookshelf.ui.scan.SearchingSheet
import org.modg.bookshelf.ui.scan.SessionBadge
@@ -57,6 +59,14 @@ class ScanScreenPaparazziTest {
@Test
fun scanSearchingSheetLight() = snapshotBoth("scan-searching-sheet") { SearchingSheetOverlay() }
/** Neither source could be reached — see [LookupFailedSheet]. Must not read as "not found". */
@Test
fun scanLookupFailedSheetLight() = snapshotBoth("scan-lookup-failed-sheet") { LookupFailedSheetOverlay() }
/** A decoded barcode that failed the ISBN-13 checksum — see [RejectedBarcodeBanner]. */
@Test
fun scanRejectedBarcodeLight() = snapshotBoth("scan-rejected-barcode") { RejectedBarcodeOverlay() }
@Composable
private fun ReticleOverlay() = Shell {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -86,6 +96,7 @@ class ScanScreenPaparazziTest {
bookcases = ScreenFixtures.bookcases,
shelves = ScreenFixtures.shelves,
selectedShelfId = ScreenFixtures.deskShelf.id,
recentShelfId = null,
onShelfSelected = {},
onSave = {},
onSkip = {},
@@ -107,6 +118,36 @@ class ScanScreenPaparazziTest {
}
}
@Composable
private fun LookupFailedSheetOverlay() = Shell {
Box(modifier = Modifier.fillMaxSize()) {
Surface(
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
color = MaterialTheme.colorScheme.surface,
) {
LookupFailedSheet(
isbn13 = "9780765326355",
reason = "open library: network error; google books: http 429",
onRetry = {},
onEnterByHand = {},
onSkip = {},
)
}
}
}
@Composable
private fun RejectedBarcodeOverlay() = Shell {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
ScanReticle(modifier = Modifier.align(Alignment.Center))
RejectedBarcodeBanner(
message = "Read 012345678905 — not a book barcode",
modifier = Modifier.align(Alignment.BottomCenter).padding(24.dp),
)
}
}
@Composable
private fun Shell(overlay: @Composable () -> Unit) {
BookshelfScaffold(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+125
View File
@@ -373,3 +373,128 @@ that.
worth doing on its own merits but no longer the leading theory), R8 still off so
the release APK is 41.8MB and too large to send over the file channel (30MB cap),
where the server will live, and the two account emails for `create-user.sh`.
## Wave 5 — G-diagnostics: COMPLETE, verified by the orchestrator 2026-09-09
Commit `93f972b`. The app now distinguishes barcode-didn't-decode from
lookup-request-failed from genuinely-not-found; see the commit message and
`docs/METADATA-SOURCES.md`.
| Check | Result |
|---|---|
| `assembleDebug` | exit 0 |
| `testDebugUnitTest` | exit 0 — **138 tests**, 1 skipped, 0 failures (was 107) |
| `verifyPaparazziDebug` | exit 0 |
| `assembleRelease` | exit 0 — 41,793,760 bytes, V2 signer `CN=Bookshelf` |
| boundary check | clean — no build files, no forbidden packages |
| `grep "always 'false'"` | 0 hits on touched files |
The worker was honest this time: everything it claimed checked out. Cost $0.28,
6 turns, one quota wait that `run-task.sh` resumed correctly.
**The orchestrator added one thing the worker's brief didn't cover:** the
manual-ISBN dialog silently discarded an unparseable entry — the same silent
failure this wave existed to eliminate, sitting just outside the prompt's scope.
It now marks the field in error and disables "Look up" until the checksum passes.
Lesson for future prompts: scope a wave by *failure class*, not by file list, or
the instances of the class that live outside the listed files survive.
### HAZARD #8 — the wave-guard can die without writing its sentinel
`logs/WAVE5-DONE` was written BY HAND. The guard renewed at 11:13, the worker
succeeded at 11:21, and the guard neither wrote the sentinel nor logged its
"guard exiting" trap line — it was killed outright. The lease expired on its own
an hour later.
**This breaks the first-command heuristic at the top of this file.** "no sentinel
+ pgrep count 0 -> workers were KILLED" was WRONG here: the worker had finished
successfully. Use these instead, in this order:
1. `ls -l logs/<name>.json` — 0 bytes means killed; non-zero means it finished.
2. `tail logs/<name>.state` — says SUCCESS / GIVING UP / WALL CLOCK explicitly.
3. `git status --porcelain` — is there actually work in the tree?
The sentinel is a convenience, not the record of truth. `logs/<name>.state` is.
## Wave 6 — second on-device feedback round: COMPLETE, verified 2026-09-09
The user tested the phone build again and sent eight items. All eight are done.
Two Sonnet workers (`tasks/H1-screens.txt`, `tasks/H2-picker.txt`) took the UI
work; the ORCHESTRATOR did the retry/backoff work itself in `data/metadata` and
`AppContainer`, because it needed the live measurement below to design it.
| Check | Result |
|---|---|
| `./tasks/gw assembleDebug` | exit 0 |
| `./tasks/gw testDebugUnitTest` | exit 0 — **172 tests**, 1 skipped, 0 failures (was 138) |
| `./tasks/gw verifyPaparazziDebug` | exit 0 |
| `grep "always 'false'"` on a `--rerun-tasks` rebuild | **0 hits** |
| `./tasks/gw assembleRelease` | exit 0 — 41,810,740 bytes, V2 signer `CN=Bookshelf, O=Montanaro` |
| boundary check | clean — neither worker touched a build file or the other's packages |
### The ghost bookcase was an inset bug, not a data bug
`LocationsScreen`'s list branch dropped the Scaffold's `innerPadding` while its
empty-state branch applied it, so the FIRST bookcase row rendered underneath the
top app bar and was invisible. Every symptom the user described follows from that:
invisible first bookcase, no empty state on re-entry (the list was genuinely
non-empty), a second bookcase created, both showing in the filter menu. **Both
records were always real and healthy** — the user should delete the spare.
Every other screen was checked for the same class of bug; Locations was the only
one. Fix: fold `innerPadding` into the LazyColumn's `contentPadding` (NOT
`Modifier.padding`, which would clip the scroll area instead of insetting it).
### Metadata: measured, not guessed
See `docs/METADATA-SOURCES.md` § "Measured again 2026-09-09" for the full data.
Two things that change how you should think about this app:
1. **Google Books keyless is dead for everyone, permanently.** The user's
residential-IP test returned a quota error naming `project_number:624717413613`
— a shared anonymous *project*, not an IP. The old note in METADATA-SOURCES.md
guessing that a residential IP "may well get answers" is now marked CORRECTED
in place. Because `combine()` turns any Failed-with-no-Found into `Unavailable`,
this standing failure meant **every** Open Library hiccup surfaced as
"one or more sources couldn't be reached". The app has been single-sourced all
along. **The user has deliberately deferred the API key — do not add it unasked.**
2. **Our own timeouts were manufacturing failures.** 30 live requests: 13% failed,
all fast TLS resets (<2.5s); successes had a median of 4.3s but a max of 22.0s,
and **2 of 26 successes exceeded the old 12s `callTimeout`**. Timeouts are now
25s/20s/20s. Failures are fast and successes are slow, so a short timeout buys
nothing on the failure path and costs real successes on the slow path.
`RetryPolicy` + `withRetry` (new, `data/metadata/`) retry TRANSPORT and
SERVER_ERROR only. It deliberately does NOT retry:
- **TIMEOUT** — the budget is already spent; retrying could triple the wait.
- **RATE_LIMITED** — hammering a quota is how an intermittent block becomes a
permanent one, and METADATA-SOURCES.md records that happening to this project's
IP. Revisit when the Google Books key lands: a *keyed* 429 is a per-second limit
and does deserve one Retry-After-respecting retry.
`SourceResult.Failed` now carries a `FailureKind` alongside its human `reason`, and
`reason` names the specific exception ("tls connection reset, 3 attempts") instead
of a generic "network error". **That string is now rendered on the scan sheet and
is our ONLY diagnostic channel from a real phone.** Nothing may parse it.
### Known-soft spots in wave 6 — do not mistake these for verified
1. **The Paparazzi "regression" snapshot for the ghost bookcase is a lookalike,
not the real screen.** `LocationsScreenPaparazziTest` hand-rolls its own
Scaffold+LazyColumn copy because the real `LocationsScreen` needs an
`AppContainer` (Room + DataStore). The orchestrator verified the REAL fix by
reading the diff; the PNG only proves the test's copy is right, and the two can
drift — the copy already omits the bottom inset the real screen adds. Splitting
a stateless `LocationsContent(state, callbacks)` out of the screen would make
this snapshot genuine. Worth doing before anyone trusts it as regression cover.
2. **The auto-focus calls are unverified.** Three dialogs now do
`LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } }`. That
is the idiomatic form, but there is no emulator here and `runCatching` means a
too-early call fails SILENTLY rather than crashing. If a dialog opens unfocused
on the phone, that is why; the fix is to await a frame before requesting.
3. **The shelf picker opens as a bottom sheet stacked on top of the save sheet**
(H2's own flagged judgement call). It renders correctly in Paparazzi but
sheet-over-sheet is awkward on real Android. Watch it on the device.
### Worker lessons (both are repeats — the prompts already forbade them)
- **H1 backgrounded a Gradle build and ended its turn**, exactly the wave-4
failure, despite an explicit foreground-only instruction AND
`CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0` being set in `run-task.sh`. Its final
message was "I'll wait for this background build to complete." `run-task.sh`
still recorded SUCCESS because the process exited 0. **`.state` saying SUCCESS
means the process exited cleanly, NOT that the worker finished its task** — read
`logs/<name>.summary` and check that the result is an actual report. H1's work
was fine, but nobody verified it except the orchestrator.
- H2 (108 turns, $4.00) followed the brief closely, ran builds in the foreground,
and reported honestly, including flagging its own stacked-sheet judgement call.
Cost ratio to H1 ($0.66, 5 turns) is roughly the ratio of work actually done.
+77
View File
@@ -97,6 +97,10 @@ Worth saying plainly, because it bounds how much weight the numbers carry:
this host sharing a quota pool with other tenants; **your phone, on a
residential or mobile IP, may well get answers.** That's precisely why the
app needs to be able to tell us which it got.
> **CORRECTED 2026-09-09 — this guess was WRONG.** The user tested from a
> residential IP and got the same refusal, naming a shared *project* quota
> rather than an IP one. See "Measured again 2026-09-09" at the end of this
> file. Do not act on the sentence above.
- I don't know which three ISBNs you scanned. If you still have the books to
hand, those three numbers are worth more than another 60 sampled ones.
@@ -215,3 +219,76 @@ One thing worth deciding separately: 17% of books legitimately have no cover art
anywhere. The placeholder now looks deliberate rather than broken, but if you
want covers on everything, that's a different feature — photograph the book,
store it as the cover — and not a metadata-source problem at all.
## Measured again 2026-09-09, after the user tested from a residential IP
Two things were settled that the first round could only guess at.
### Google Books keyless is dead everywhere, not just from this box
The user ran the app's exact Google Books call from their home connection and got:
Quota exceeded for quota metric 'Queries' and limit 'Queries per day'
of service 'books.googleapis.com' for consumer 'project_number:624717413613'
That names a **Google Cloud project, not an IP**. Every keyless caller on the
internet is billed to that one shared anonymous project and its daily quota is
exhausted. So:
- The earlier caveat — "your phone, on a residential or mobile IP, may well get
answers" — is **WRONG**. Delete it from your mental model. It was tested and it
is not true.
- Backoff cannot help. This is a daily quota, not a per-second rate limit.
- A free API key is the only fix, and it is a complete one: it moves the app into
its own project with its own quota (free tier 1,000 req/day).
The knock-on is the part that actually hurt the user. `MetadataRepository.combine`
turns "any source Failed, none Found" into `Unavailable`. Google Books is a
PERMANENT standing failure, so **every** Open Library hiccup became `Unavailable`.
The app has effectively been single-sourced this whole time while reporting
failures as though two sources had been consulted.
**The user has deliberately deferred the API key.** Do not implement it unasked.
### Open Library: 13% failure, and our own timeout was manufacturing more
30 requests, the exact call `OpenLibraryClient` makes, 1.5s apart, from the sprite:
| | |
|---|---|
| failure rate | **13%** (4 of 30) |
| every failure | curl exit 35 — TLS-stage `Connection reset by peer` |
| failure latency | 0.23s, 0.31s, 0.59s, 2.46s — **all fast** |
| success latency | median **4.3s**, p75 6.0s, p90 **9.2s**, max **22.0s** |
| successes over the old 12s callTimeout | **2 of 26 (8%)** |
Two conclusions, and they point in opposite directions:
1. **Failures are cheap and transient**, so retrying is nearly free. 13% → ~1.7%
at two attempts → ~0.2% at three. This is why `RetryPolicy` exists and why its
backoff is milliseconds rather than the conventional seconds.
2. **Successes are slow and long-tailed**, and the app's own
`callTimeout(12s)`/`connectTimeout(10s)` were cutting off roughly 8% of
lookups that were about to succeed — then reporting them to the user as
"couldn't be reached". The app was generating a meaningful share of its own
failures. Timeouts are now 25s/20s/20s, above the 22.0s worst observed success.
The asymmetry is the whole design: a short timeout buys nothing on the failure
path (failures return in under 2.5s regardless) and costs real successes on the
slow path. That is also why `RetryPolicy.isRetryable` refuses to repeat a
TIMEOUT — a timeout means the budget was already spent, and the evidence says
slow requests mostly succeed if you let them finish.
Connection reuse is visible in the data and matters in real use: cold connects
ran 2-19s while warm ones ran 0.07s. OkHttp pools connections for 5 minutes, so
scanning a box of books in sequence stays on the fast path after the first book.
### What still is not known
- All 30 requests came from this datacenter IP. The user's phone may see a
different failure rate. The reason string now shown on the scan sheet
(`SourceResult.Failed.reason`, e.g. "tls connection reset, 3 attempts") is how
we find out — it is the only diagnostic channel we have from a real device.
- The user's own 3-request sample showed 2 failures. That is consistent with 13%
(p ~ 5%) but does not confirm it. If their phone reports "3 attempts" often,
their network is worse than this one and the retry count deserves revisiting.
+1
View File
@@ -0,0 +1 @@
9b656dc8-a225-47ec-b6ce-549241854de6
+19
View File
@@ -0,0 +1,19 @@
=== G-diagnostics attempts=2 quota_waits=1 hard_fails=0 ===
cost=$0.28007899999999997 turns=6 err=?
--- result (tail) ---
` case. `byIsbnCoverUrl` untouched.
**AppContainer.kt** — `metadataHttpClient` now has `callTimeout(12s)/connectTimeout(10s)/readTimeout(10s)`. PocketBase `okHttpClient` untouched.
**ui.scan:**
- `ScanCodeFilter.kt` — `accept()` returns `ScanOutcome { Isbn, NotAnIsbn, Ignored }`; one shared debounce mechanism throttles repeats of valid *and* rejected codes alike.
- `ScannerController.kt` — new `rejectedCodes: SharedFlow<String>` routed alongside `scanResults`.
- `ScanModels.kt` — `ScanSheetState.NotFound` gained `viaLookupFailure: Boolean = false`; added `ScanSheetState.LookupFailed(isbn13, reason)`; `ScanMetadataOutcome.from` maps all three `LookupResult` cases.
- `ScanViewModel.kt` — `retryLookup`/`enterByHand` added; lookup logic factored so Retry bypasses the "sheet already showing" guard; `rejectedMessage: StateFlow<String?>` with a 3s auto-clear timer.
- `ScanScreen.kt` — new `LookupFailedSheet` (Retry/Enter by hand/Skip, dismissible, never claims the book unknown); `ManualEntrySheet` reworded via `authoritative: Boolean`; new `RejectedBarcodeBanner` shown over the camera.
**Tests:** classify() cases for both clients, the full 3×3 `combine` matrix, `ScanCodeFilter` throttle tests with an injected clock, `ScannerController` rejected-code routing, updated `ScanModelsTest`, and two new Paparazzi snapshots (`LookupFailedSheet`, rejected-barcode banner) recorded in both light and dark — all visually verified.
No warnings (Kotlin or otherwise) were introduced in any file I touched; a clean `--rerun` compile shows only pre-existing, unrelated deprecation warnings elsewhere in the codebase.
Nothing was left undone relative to the original instructions, and no constraints (forbidden files, no commits, foreground builds, no MockWebServer) were violated.
+1
View File
@@ -0,0 +1 @@
430e1016-0c8c-47a5-905b-ad106cbb6b19
+4
View File
@@ -0,0 +1,4 @@
=== H1-screens attempts=1 quota_waits=0 hard_fails=0 ===
cost=$0.6623552 turns=5 err=?
--- result (tail) ---
I'll wait for this background build to complete before continuing.
+1
View File
@@ -0,0 +1 @@
67bf128a-9c53-4fd2-b9ce-10b99f4a385d
+10
View File
@@ -0,0 +1,10 @@
=== H2-picker attempts=1 quota_waits=0 hard_fails=0 ===
cost=$3.9990499999999995 turns=108 err=?
--- result (tail) ---
succeeded. New `shelf-picker-grouped-{light,dark}` snapshot shows two bookcases (Living Room with two shelves, one selected/bold/checked; Study empty with "No shelves yet"), plus the "Recent" section and "Not shelved" — verified visually. Existing `scan-searching-sheet` and `scan-lookup-failed-sheet` snapshots regenerated and confirmed correct (no stray "lower the book" line; reason string now visible).
- Grepped all Kotlin compiler warnings from a forced rebuild: zero `always 'false'` hits anywhere, including my files.
- `git status --porcelain`: only my owned files changed by me (`data/prefs/SettingsStore.kt`, `ui/scan/ScanScreen.kt`, `ui/scan/ScanViewModel.kt`, `ui/detail/DetailScreen.kt`, `ui/detail/DetailViewModel.kt`, new `ui/components/ShelfPickerSheet.kt`, and new/edited files under `app/app/src/test/**`). Everything else showing modified/untracked (`AppContainer.kt`, `data/metadata/**`, `ui/locations/**`, `ui/library/**`, `res/drawable/**`, `docs/METADATA-SOURCES.md`, locations snapshots) is the orchestrator's/other worker's concurrent work — untouched by me, left as-is.
**Didn't do / judgment calls worth flagging**
- Nothing skipped from the spec. One design decision: `ShelfPickerSheet` owns its own `ModalBottomSheet`, so tapping "Choose a shelf"/"Change" opens it as a second, stacked sheet over the already-open save/detail sheet — this matches "presented as a Material 3 ModalBottomSheet" literally rather than inlining the list in the existing sheet.
- Added tests beyond the stated minimum: `SettingsStoreTest` (round-trip, overwrite, clear, clearAuth), `ShelfPickerSheetTest` (`resolveRecentShelf` — null/missing-shelf/missing-bookcase/found cases), `ScanViewModelTest` and `DetailViewModelTest` (remember-on-save/move, "Not shelved" doesn't overwrite).
+22
View File
@@ -0,0 +1,22 @@
=== WAVE5-DONE written BY HAND by the orchestrator, 2026-09-09 ===
NOT written by tasks/wave-guard.sh. The guard renewed its lease at 11:13, the
worker reported SUCCESS at 11:21, and the guard should have noticed within 30s
and written this file. It never did, and it left no "guard exiting" line either,
so it was killed outright rather than exiting through its TERM/INT trap. The
lease then expired on its own at ~12:13.
READ THIS BEFORE TRUSTING THE FIRST-COMMAND HEURISTIC AT THE TOP OF HANDOFF.md:
"no sentinel + pgrep 0 -> workers were KILLED" would have been WRONG here. The
worker finished successfully; only the guard died. The reliable signal is the
size of logs/<name>.json (5695 bytes here — a killed worker leaves 0) plus the
tail of logs/<name>.state (which says SUCCESS).
G-diagnostics: SUCCESS after 2 attempts, 1 quota wait. cost=$0.28, turns=6.
Independently re-verified by the orchestrator:
./tasks/gw assembleDebug exit 0
./tasks/gw testDebugUnitTest exit 0 - 138 tests, 1 skipped, 0 failures (was 107)
./tasks/gw verifyPaparazziDebug exit 0
./tasks/gw assembleRelease exit 0 - 41,793,760 bytes, V2 signer CN=Bookshelf
boundary check clean - no build files, no forbidden packages
grep "always 'false'" 0 hits on touched files
Accepted and committed as 93f972b.
+14
View File
@@ -0,0 +1,14 @@
=== WAVE6-DONE written 2026-09-09T15:31:35+00:00 ===
Workers finished. The orchestrator was NOT necessarily alive for this.
--- H1-screens ---
[2026-09-09T15:05:30+00:00] H1-screens: SUCCESS after 1 attempt(s), 0 quota wait(s)
cost=$0.6623552 turns=5
--- H2-picker ---
[2026-09-09T15:31:29+00:00] H2-picker: SUCCESS after 1 attempt(s), 0 quota wait(s)
cost=$3.9990499999999995 turns=108
NEXT: orchestrator must independently verify before accepting:
cd ~/bookshelf && ./tasks/gw assembleDebug && ./tasks/gw testDebugUnitTest
git status --porcelain # boundary check: who touched what
+137
View File
@@ -0,0 +1,137 @@
You are a Sonnet worker on the Bookshelf Android app (~/bookshelf). Read
`docs/SPEC.md` first — it is the authoritative product contract and it wins over
anything you infer from the code. Do not restate it, do not let it drift.
## Ground rules (violating these fails the wave)
- Build ONLY with `./tasks/gw <task>` — never `./gradlew`. A second worker shares
this Gradle project dir and concurrent invocations clobber each other. `tasks/gw`
is a flock-serialized wrapper.
- Run builds in the FOREGROUND. Never background a Gradle build and end your turn
saying you'll report later — `claude -p` kills background tasks and you will
never report at all. Builds take up to 10 minutes; just wait.
- You own EXACTLY these files:
app/app/src/main/java/org/modg/bookshelf/ui/locations/LocationsScreen.kt
app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryScreen.kt
app/app/src/main/res/drawable/ic_shelves.xml (new file, create it)
app/app/src/test/** (tests you add)
Touch NOTHING else. Specifically forbidden: any build file
(`app/build.gradle.kts`, `gradle/libs.versions.toml`, `settings.gradle.kts`),
`data/**`, `ui/scan/**`, `ui/detail/**`, `ui/components/**`, `ui/nav/**`,
`ui/settings/**`, `ui/setup/**`, `AppContainer.kt`. Another worker and the
orchestrator own those RIGHT NOW and are editing them concurrently.
- Do not change any public composable signature. `ui/nav/BookshelfNavHost.kt`
calls these screens and you may not edit it.
## Task 1 — the ghost-bookcase bug (highest priority, a real user-facing defect)
`LocationsScreen.kt` line ~97. The Scaffold hands `content` an `innerPadding` that
accounts for the top app bar. The empty-state branch applies it; the list branch
does NOT:
) { innerPadding ->
PaperSurface(...) {
if (state.bookcases.isEmpty()) {
EmptyState(modifier = Modifier.padding(innerPadding), ...) // correct
} else {
LazyColumn(contentPadding = PaddingValues(bottom = 96.dp)) { // BUG
So the first bookcase row renders UNDERNEATH the app bar and is invisible. A user
created a bookcase, could not see it, created a second one, and ended up with two
real bookcases and no idea why. Fix it by folding `innerPadding` into the
LazyColumn's `contentPadding` so the existing 96.dp bottom inset is PRESERVED and
added to, not replaced — the bottom inset is what keeps the last row clear of the
FAB. Something equivalent to:
contentPadding = PaddingValues(
top = innerPadding.calculateTopPadding(),
bottom = innerPadding.calculateBottomPadding() + 96.dp,
)
Using `contentPadding` rather than `Modifier.padding` is deliberate: it keeps the
list scrolling under the bar instead of clipping the scroll area.
VERIFY THIS SPECIFICALLY: add a Paparazzi snapshot of `LocationsScreen` in a state
with exactly ONE bookcase, and confirm in the rendered PNG that the bookcase row is
fully visible below the app bar. A one-bookcase list is the exact case that was
broken and it must be the case you prove fixed. If the existing Paparazzi harness
makes rendering this screen with seeded state impractical, say so plainly in your
report rather than skipping it silently.
## Task 2 — auto-focus the first field in the location dialogs
In `LocationsScreen.kt`, `BookcaseEditDialog` (~line 276) and `ShelfEditDialog`
(~line 299) each open with an unfocused `OutlinedTextField`. The first field
should take focus and raise the keyboard when the dialog appears. Use a
`FocusRequester` + `LaunchedEffect(Unit) { focusRequester.requestFocus() }`.
Bookcase dialog: focus "Name" (not "Note"). Shelf dialog: focus "Label".
Guard the requestFocus call so it cannot throw if the node isn't attached yet.
## Task 3 — library filter empty state
`LibraryScreen.kt` ~line 204. The filter DropdownMenu always offers "All books"
first, then a flat list of bookcases and shelves. When there are NO bookcases and
NO shelves, the menu contains only "All books" — which is already the active state
and cannot be changed, so it is a menu with nothing in it.
When `bookcases` and `shelves` are both empty, replace the menu contents with a
single DISABLED item reading "Add a bookcase to enable filtering". Keep the
toolbar filter icon visible and enabled (it is how the feature is discovered) —
only the menu's contents change. When locations DO exist, behaviour is unchanged.
## Task 4 — replace the Warehouse icon with a real bookcase
`LibraryScreen.kt` line ~100 uses `Icons.Outlined.Warehouse` for the button that
opens Locations. It renders as a barn and reads wrong. `material-icons-extended`
1.7.8 has no bookcase glyph (I checked all 1932 outlined icons), so use Material
Symbols' `shelves`, which is a bookcase frame with shelves and books on them.
Create `app/app/src/main/res/drawable/ic_shelves.xml` with EXACTLY this content.
This is the SVG path verbatim from Google's CDN. Do not re-derive it, do not
"simplify" it, and do not convert its relative (lowercase) commands to absolute
ones — Android's pathData parser accepts SVG syntax as-is. Material Symbols ship with
`viewBox="0 -960 960 960"` — a negative Y origin that Android `<vector>` has no
equivalent for — and the `<group android:translateY="960">` is what compensates.
Removing it renders an empty icon.
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols "shelves" (Apache 2.0). Source viewBox is
"0 -960 960 960"; Android has no viewport origin, so the group
translate is load-bearing. Do not flatten it. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<group android:translateY="960">
<path
android:fillColor="#FF000000"
android:pathData="M120-40v-880h80v80h560v-80h80v880h-80v-80H200v80h-80Zm80-480h80v-160h240v160h240v-240H200v240Zm0 320h240v-160h240v160h80v-240H200v240Zm160-320h80v-80h-80v80Zm160 320h80v-80h-80v80Z" />
</group>
</vector>
Then swap the icon at the call site:
Icon(painterResource(R.drawable.ic_shelves), contentDescription = "Bookcases & shelves")
Keep the existing contentDescription text. `Icon` applies its own tint over a
Painter exactly as it does over an ImageVector, so the icon still picks up the
theme colour — do NOT hardcode a colour at the call site. You will need imports
for `androidx.compose.ui.res.painterResource` and `org.modg.bookshelf.R`, and the
`Icons.Outlined.Warehouse` import becomes unused — remove it.
## Verify before you report (all in the FOREGROUND)
./tasks/gw assembleDebug
./tasks/gw testDebugUnitTest
./tasks/gw recordPaparazziDebug
git status --porcelain
- assembleDebug and testDebugUnitTest must exit 0. The test count is 138 today and
must not go DOWN.
- Grep your build output for the string `always 'false'`. That Kotlin warning class
silently blanked every book cover in this app for months by making a `when`
branch dead code that still compiled. Zero hits on files you touched.
- `git status --porcelain` must show ONLY the files you own. If it shows others,
you have broken the boundary — report it, do not revert someone else's work.
- Do not commit. The orchestrator commits after verifying.
## Report
Finish with a plain report: what you changed per task, the exact exit codes and
test counts, whether the one-bookcase Paparazzi render actually proves task 1, and
anything you could NOT do. Do not claim success you did not verify — several
previous workers on this project over-claimed and were caught.
+143
View File
@@ -0,0 +1,143 @@
You are a Sonnet worker on the Bookshelf Android app (~/bookshelf). Read
`docs/SPEC.md` first — it is the authoritative product contract and it wins over
anything you infer from the code. Do not restate it, do not let it drift.
## Ground rules (violating these fails the wave)
- Build ONLY with `./tasks/gw <task>` — never `./gradlew`. Another worker shares
this Gradle project dir and concurrent invocations clobber each other.
`tasks/gw` is a flock-serialized wrapper.
- Run builds in the FOREGROUND. Never background a Gradle build and end your turn
saying you'll report later — `claude -p` kills background tasks and you will
never report at all. Builds take up to 10 minutes; just wait.
- You own EXACTLY these files:
ui/components/ShelfPickerSheet.kt (new file, create it)
ui/scan/ScanScreen.kt
ui/scan/ScanViewModel.kt
ui/detail/DetailScreen.kt
ui/detail/DetailViewModel.kt
data/prefs/SettingsStore.kt
app/app/src/test/** (tests you add)
(paths relative to app/app/src/main/java/org/modg/bookshelf/)
Touch NOTHING else. Specifically forbidden: any build file
(`app/build.gradle.kts`, `gradle/libs.versions.toml`), `data/metadata/**`
(the ORCHESTRATOR is editing that right now, in this same working tree),
`ui/locations/**`, `ui/library/**`, `ui/nav/**`, `ui/settings/**`,
`ui/setup/**`, `AppContainer.kt`, and every file in `ui/components/` EXCEPT the
new `ShelfPickerSheet.kt` you create.
- Do not change any public composable signature. `ui/nav/BookshelfNavHost.kt`
calls these screens and you may not edit it.
- `AppContainer.settingsStore` is already a public val — you do NOT need to change
AppContainer to reach it.
## Background: what the user actually reported
They are shelving books a box at a time, scanning a run of books that all belong
on the SAME shelf. Two complaints:
1. The shelf dropdown lists every bookcase×shelf pair in one flat menu. With more
than a couple of bookcases that is unusable.
2. Re-picking the same shelf for every book in the box is tedious.
## Task 1 — a grouped shelf picker, replacing the flat dropdown
There are currently TWO near-duplicate flat pickers:
- `ShelfPicker` in `ScanScreen.kt` (~line 477)
- the location picker inside `DetailScreen.kt` (~line 273-310)
Both build a `DropdownMenu` containing "Not shelved" followed by one flat item per
(bookcase, shelf) pair, labelled "Bookcase • Shelf".
Replace BOTH with ONE new shared composable in `ui/components/ShelfPickerSheet.kt`,
presented as a Material 3 `ModalBottomSheet` rather than a dropdown. Contents, top
to bottom:
a. A "Recent" section — see task 2. Omit the whole section when there is no
remembered shelf, or when the remembered shelf no longer exists.
b. "Not shelved" as an always-available choice.
c. One section per bookcase, in `position` order. The bookcase name is a section
header (non-selectable — a bookcase is not a location a book can sit in;
only shelves are). Its shelves are listed under it in `position` order,
labelled with just the shelf label, since the header already gives the
bookcase. Make the currently-selected shelf visually distinct.
d. A bookcase with no shelves shows its header and an inline "No shelves yet"
hint, so an empty bookcase does not look like a rendering bug.
The sheet must scroll. Do NOT add a search field — the user explicitly deferred
that; we will add it later if the grouping alone proves insufficient.
Design language is in SPEC.md ("feels like books") and the existing components in
`ui/components/` are your reference for type, spacing and the gold hairline rules.
Reuse `GoldDivider` for section separation rather than inventing a new rule.
## Task 2 — remember the most recently used shelf
Add a `LAST_SHELF_ID` key to `SettingsStore` (a nullable String preference, with a
`Flow<String?>` reader, a setter, and a way to clear it), following the exact
shape of the keys already there. Clear it in `clearAuth()` alongside the rest —
signing out of a shared library should not leak the other account's shelf.
Write it whenever a book's shelf is set to a non-null value:
- `ScanViewModel` — on save, both from a metadata hit and from manual entry.
- `DetailViewModel` — when the user changes a book's location.
Setting a book to "Not shelved" must NOT overwrite the remembered shelf; it isn't
a shelf, and clobbering the memory with it would defeat the whole feature.
Surface it as the "Recent" section at the top of the picker, labelled with the
full "Bookcase • Shelf" text (the section has no bookcase header to lean on).
**Do NOT pre-select it.** The user considered and explicitly rejected
pre-selection: the risk of silently mis-shelving a book, when the user forgets to
change it, outweighs saving one tap. A new scan still starts with no shelf chosen;
the remembered shelf is one tap away at the top of the sheet, and that is all.
Note `ScanViewModel._selectedShelfId` already survives across saves within a single
scanning session (it is deliberately not reset in `recordSave`). Keep that. What
you are adding is persistence ACROSS sessions and screens.
## Task 3 — three small fixes in ScanScreen.kt
3a. In `SearchingSheet` (~line 268), delete the third Text, the one reading
"Barcode read — you can lower the book." The user says "Searching…" plus the
ISBN already carries it. Update the composable's KDoc, which currently
justifies that line — do not leave a comment explaining code that is gone.
3b. `LookupFailedSheet` (~line 435) takes a `reason` parameter and never renders
it. `ScanSheetState.LookupFailed(isbn13, reason)` already carries a diagnostic
string, and `MetadataRepository` already builds it as e.g.
"open library: network error; google books: http 429". It is currently dead —
it reaches the UI and is dropped on the floor.
Render it, below the existing explanatory paragraph, in
`MaterialTheme.typography.bodySmall` and `onSurfaceVariant`. This is the only
diagnostic channel we have from a real phone, so it must actually appear.
Keep the existing headline and paragraph as they are — the reason is
supplementary detail, not a replacement for plain-language copy.
NOTE: the orchestrator is concurrently making those reason strings more
specific. Do not depend on their exact wording — render whatever you are
given, and do not parse, match on, or reformat the string.
3c. `ManualIsbnDialog` (~line 512) opens with an unfocused text field. Auto-focus
it and raise the keyboard, via `FocusRequester` +
`LaunchedEffect(Unit) { focusRequester.requestFocus() }`. Guard the call so it
cannot throw if the node is not attached yet.
## Verify before you report (all in the FOREGROUND)
./tasks/gw assembleDebug
./tasks/gw testDebugUnitTest
./tasks/gw recordPaparazziDebug
git status --porcelain
- assembleDebug and testDebugUnitTest must exit 0. The test count is 138 today and
must not go DOWN. Add real tests for the logic you introduce — at minimum the
SettingsStore round-trip, that "Not shelved" does not overwrite the memory, and
that a remembered shelf which no longer exists is not offered. Assertion-free
tests are explicitly forbidden by SPEC's quality bar.
- Add a Paparazzi snapshot of the new picker sheet with at least two bookcases,
one of them empty, and a recent shelf present — that is the state the whole
change exists for, and no one has ever trusted a worker's word on how this app
looks.
- Grep your build output for the string `always 'false'`. That Kotlin warning class
silently blanked every book cover in this app for months by making a `when`
branch dead code that still compiled. Zero hits on files you touched.
- `git status --porcelain` will show files the orchestrator and the other worker
are editing (`data/metadata/**`, `ui/locations/**`, `ui/library/**`,
`res/drawable/**`). That is EXPECTED. Confirm only that no file outside your
own list was changed BY YOU. Never revert or "fix" someone else's work.
- Do not commit. The orchestrator commits after verifying.
## Report
Finish with a plain report: what you changed per task, exact exit codes and test
counts, what the new Paparazzi PNG shows, and anything you could NOT do. Do not
claim success you did not verify — several previous workers on this project
over-claimed and were caught.