Compare commits

...
2 Commits
Author SHA1 Message Date
Spriteandclaude bd24ecbafd docs: first on-device test results and metadata-source research
Records the coil3 StateFlow trap (a Kotlin warning, not an error, that
silently blanked every book cover) so a future session greps for it, and
the two Open Library cover defects behind it.

METADATA-SOURCES.md answers the user's research question about
alternative lookup sources: measurements over a 60-ISBN sample drawn
from a third-party catalogue, the options with costs, and a
recommendation to fix diagnosis before buying coverage. Nothing there is
implemented — the user asked to be consulted first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDcPottghJXEvfYKqFM7zf
2026-09-09 10:13:36 +00:00
Spriteandclaude 356f639cdd Fix six on-device issues found in the first real phone test
The cover one is the interesting bug. BookCover branched on
`painter.state`, but in coil3 that is a StateFlow<State>, not a State —
so every `is AsyncImagePainter.State.X` arm was always false. A `when`
used as a statement needs no else, so it compiled clean and drew
NOTHING: no cover, no placeholder, no error icon. That is why a
successfully looked-up book showed as a bare title floating in space.
Collect the flow before matching on it.

Two more cover defects sat behind that one:
- OpenLibraryDtos synthesized covers.openlibrary.org/b/isbn/{isbn}-L.jpg
  unconditionally. For an edition with no art that URL answers 200 with a
  43-byte 1x1 transparent GIF (verified against the live service), which
  an image loader calls a successful load. So even with the state bug
  fixed it would have painted an invisible cover and never fallen back.
  Now the URL comes from OL's own `cover` object, which is present only
  when art actually exists.
- Because that URL was never blank, MetadataMerger's "fill blanks from
  the other source" rule could never reach Google Books' thumbnail. With
  OL reporting null, the fallback works, and MetadataRepository adds the
  by-ISBN URL as a genuine last resort — with `default=false`, so a miss
  is a 404 the loader can report instead of a blank image.

The placeholder itself is now drawn in every non-success state (loading
included, where it previously drew an empty box) and reads as a book:
mahogany spine strip, gold hairline, letterpress panel.

Also:
- SyncStatusBar owns its navigation-bar inset and takes wider horizontal
  padding, so it clears a phone's rounded display corners; it moves into
  Scaffold's bottomBar slot so its height reaches the content padding.
- SetupScreen takes safeDrawingPadding outside verticalScroll, so the IME
  shrinks the viewport instead of covering Password, plus Next/Next/Done
  IME actions.
- Library card titles drop to a 20sp line height with the author given
  its own 4dp gap: at titleSmall's 24sp leading a wrapped title left the
  author closer to the last title line than the title lines were to each
  other, so the byline read as part of the title.
- The scan sheet now names the ISBN it just read and says "Searching…",
  so a decoded barcode is legible as decoded and the user can lower the
  book instead of holding it to the camera at a bare spinner.

assembleDebug + assembleRelease exit 0; 106 unit tests, 0 failures;
verifyPaparazziDebug green against re-recorded snapshots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDcPottghJXEvfYKqFM7zf
2026-09-09 09:55:53 +00:00
34 changed files with 481 additions and 44 deletions
+1
View File
@@ -28,6 +28,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Bookshelf"> android:theme="@style/Theme.Bookshelf">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -22,10 +22,28 @@ class MetadataRepository(
suspend fun lookup(isbn: String): BookMetadata? { suspend fun lookup(isbn: String): BookMetadata? {
val isbn13 = IsbnUtils.toIsbn13(isbn) ?: return null val isbn13 = IsbnUtils.toIsbn13(isbn) ?: return null
return coroutineScope { val merged = coroutineScope {
val openLibrary = async { openLibraryClient.lookup(isbn13) } val openLibrary = async { openLibraryClient.lookup(isbn13) }
val googleBooks = async { googleBooksClient.lookup(isbn13) } val googleBooks = async { googleBooksClient.lookup(isbn13) }
MetadataMerger.merge(openLibrary.await(), googleBooks.await()) MetadataMerger.merge(openLibrary.await(), googleBooks.await())
} ?: return null
return if (merged.coverUrl.isNullOrBlank()) {
merged.copy(coverUrl = byIsbnCoverUrl(isbn13))
} else {
merged
} }
} }
companion object {
/**
* 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
* 1x1 transparent GIF for editions it has no art for, which an image loader
* treats as a successful load and paints as an invisible cover. With it, a
* miss is a 404, so [org.modg.bookshelf.ui.components.BookCover] can fall back
* to its placeholder.
*/
fun byIsbnCoverUrl(isbn13: String): String =
"https://covers.openlibrary.org/b/isbn/$isbn13-L.jpg?default=false"
}
} }
@@ -18,6 +18,7 @@ data class OpenLibraryBookDto(
@SerialName("publish_date") val publishDate: String? = null, @SerialName("publish_date") val publishDate: String? = null,
@SerialName("number_of_pages") val numberOfPages: Int? = null, @SerialName("number_of_pages") val numberOfPages: Int? = null,
val identifiers: OpenLibraryIdentifiersDto? = null, val identifiers: OpenLibraryIdentifiersDto? = null,
val cover: OpenLibraryCoverDto? = null,
) )
@Serializable @Serializable
@@ -26,13 +27,33 @@ data class OpenLibraryAuthorDto(val name: String? = null)
@Serializable @Serializable
data class OpenLibraryPublisherDto(val name: String? = null) data class OpenLibraryPublisherDto(val name: String? = null)
/**
* Present only when Open Library actually holds cover art for the edition, and so
* the only trustworthy "has a cover" signal from this API. A synthesized by-ISBN
* covers.openlibrary.org URL is NOT evidence of one: for an edition with no art it
* answers 200 with a 43-byte 1x1 transparent GIF (verified 2026-09-09), which any
* image loader reports as a successful load — the cover then renders as nothing at
* all and no error placeholder ever fires. Only `?default=false` turns that into a
* 404; see [MetadataRepository] for the last-resort URL that uses it.
*/
@Serializable
data class OpenLibraryCoverDto(
val small: String? = null,
val medium: String? = null,
val large: String? = null,
)
@Serializable @Serializable
data class OpenLibraryIdentifiersDto( data class OpenLibraryIdentifiersDto(
@SerialName("isbn_10") val isbn10: List<String> = emptyList(), @SerialName("isbn_10") val isbn10: List<String> = emptyList(),
@SerialName("isbn_13") val isbn13: List<String> = emptyList(), @SerialName("isbn_13") val isbn13: List<String> = emptyList(),
) )
/** Maps the OL DTO to the source-agnostic [BookMetadata], deriving the cover URL per SPEC. */ /**
* Maps the OL DTO to the source-agnostic [BookMetadata]. [coverUrl] stays null unless
* OL reports real cover art, so that the SPEC merge rule can fall through to Google
* Books' thumbnail instead of pinning a URL that resolves to a blank image.
*/
fun OpenLibraryBookDto.toBookMetadata(lookupIsbn13: String): BookMetadata = BookMetadata( fun OpenLibraryBookDto.toBookMetadata(lookupIsbn13: String): BookMetadata = BookMetadata(
isbn13 = identifiers?.isbn13?.firstOrNull() ?: lookupIsbn13, isbn13 = identifiers?.isbn13?.firstOrNull() ?: lookupIsbn13,
isbn10 = identifiers?.isbn10?.firstOrNull(), isbn10 = identifiers?.isbn10?.firstOrNull(),
@@ -43,5 +64,5 @@ fun OpenLibraryBookDto.toBookMetadata(lookupIsbn13: String): BookMetadata = Book
publishedDate = publishDate, publishedDate = publishDate,
pageCount = numberOfPages, pageCount = numberOfPages,
description = null, description = null,
coverUrl = "https://covers.openlibrary.org/b/isbn/$lookupIsbn13-L.jpg", coverUrl = cover?.large?.takeIf { it.isNotBlank() } ?: cover?.medium?.takeIf { it.isNotBlank() },
) )
@@ -3,15 +3,20 @@ package org.modg.bookshelf.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AutoStories import androidx.compose.material.icons.outlined.AutoStories
import androidx.compose.material.icons.outlined.BrokenImage import androidx.compose.material.icons.outlined.BrokenImage
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -27,9 +32,12 @@ const val BookCoverAspectRatio = 2f / 3f
/** /**
* A book cover image, always drawn at [BookCoverAspectRatio]. Covers are the * A book cover image, always drawn at [BookCoverAspectRatio]. Covers are the
* hero of this app's design — real art fills the whole shape edge to edge. * hero of this app's design — real art fills the whole shape edge to edge.
* When there's no [coverUrl], or the load fails, we fall back to the same * When there's no [coverUrl], or the load fails, or one is still in flight, we
* restrained "letterpress" placeholder: a paper-toned panel with a debossed * fall back to the same restrained "letterpress" placeholder: a paper-toned panel
* spine motif rather than a broken-image icon or empty grey box. * with a debossed spine motif rather than a broken-image icon or empty grey box.
* The placeholder is drawn in EVERY non-success state on purpose — a cover slot
* that renders nothing at all leaves the title floating in blank space, which is
* exactly what a transparent 1x1 stand-in cover used to produce.
*/ */
@Composable @Composable
fun BookCover( fun BookCover(
@@ -52,11 +60,16 @@ fun BookCover(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) { ) {
when (painter.state) { // painter.state is a StateFlow<State>, NOT a State. Branching on it
// directly compiles (a `when` used as a statement needs no else) but
// every branch is always false, so the slot draws nothing at all —
// no cover and no placeholder. Collect it before matching.
val state by painter.state.collectAsState()
when (state) {
is AsyncImagePainter.State.Error -> CoverPlaceholder(errored = true) is AsyncImagePainter.State.Error -> CoverPlaceholder(errored = true)
is AsyncImagePainter.State.Loading, is AsyncImagePainter.State.Loading,
is AsyncImagePainter.State.Empty, is AsyncImagePainter.State.Empty,
-> CoverPlaceholder(errored = false, loading = true) -> CoverPlaceholder(errored = false)
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent() is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
} }
} }
@@ -65,25 +78,39 @@ fun BookCover(
} }
@Composable @Composable
private fun CoverPlaceholder(errored: Boolean, loading: Boolean = false) { private fun CoverPlaceholder(errored: Boolean) {
val paperAlt = MaterialTheme.colorScheme.surfaceVariant val paperAlt = MaterialTheme.colorScheme.surfaceVariant
val ink = MaterialTheme.colorScheme.onSurfaceVariant val ink = MaterialTheme.colorScheme.onSurfaceVariant
val spine = MaterialTheme.colorScheme.primary
val gold = MaterialTheme.colorScheme.secondary
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(paperAlt) .background(paperAlt)
.border(width = 1.dp, color = ink.copy(alpha = 0.15f)) .border(width = 1.dp, color = ink.copy(alpha = 0.22f)),
.padding(2.dp)
.border(width = 1.dp, color = ink.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (!loading) { // A bound: a mahogany spine strip down the left edge with a gold hairline
// beside it. This is what makes an art-less cover still read as a book.
Row(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(10.dp)
.background(spine.copy(alpha = 0.35f)),
)
Box(
modifier = Modifier
.fillMaxHeight()
.width(1.dp)
.background(gold.copy(alpha = 0.55f)),
)
}
Icon( Icon(
imageVector = if (errored) Icons.Outlined.BrokenImage else Icons.Outlined.AutoStories, imageVector = if (errored) Icons.Outlined.BrokenImage else Icons.Outlined.AutoStories,
contentDescription = null, contentDescription = null,
tint = ink.copy(alpha = if (errored) 0.35f else 0.28f), tint = ink.copy(alpha = 0.4f),
modifier = Modifier.fillMaxSize(0.32f), modifier = Modifier.fillMaxSize(0.3f),
) )
} }
} }
}
@@ -2,7 +2,6 @@ package org.modg.bookshelf.ui.components
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
@@ -18,6 +17,12 @@ import androidx.compose.ui.Modifier
* screen title and a gold hairline rule underneath, plus optional nav/action * screen title and a gold hairline rule underneath, plus optional nav/action
* slots, FAB, and a bottom [SyncStatusBar] slot. Screens should reach for * slots, FAB, and a bottom [SyncStatusBar] slot. Screens should reach for
* this instead of a bare [Scaffold] so the chrome stays consistent. * this instead of a bare [Scaffold] so the chrome stays consistent.
*
* [syncStatusBar] goes in the Scaffold's own bottom-bar slot rather than in a
* hand-rolled Column under the content: that is what makes the height it
* occupies show up in the [PaddingValues] handed to [content], so a screen that
* applies them can't scroll its last row underneath the status line. The bar
* takes the navigation-bar inset itself (see [SyncStatusBar]).
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -49,14 +54,8 @@ fun BookshelfScaffold(
} }
}, },
floatingActionButton = floatingActionButton, floatingActionButton = floatingActionButton,
bottomBar = syncStatusBar,
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
content = { innerPadding -> content = content,
Column(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.weight(1f)) {
content(innerPadding)
}
syncStatusBar()
}
},
) )
} }
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -38,8 +39,13 @@ enum class SyncStatus {
} }
/** /**
* A slim, quiet status line — never a blocking banner or dialog. Sits at the * A slim, quiet status line — never a blocking banner or dialog. Sits in
* bottom of [BookshelfScaffold] screens. * [BookshelfScaffold]'s bottom-bar slot.
*
* The app draws edge to edge, so this is the one composable that sits against
* the very bottom of the display: it owns the navigation-bar inset itself, and
* its horizontal padding is deliberately wider than the app's usual 16dp so the
* dot and label clear a phone's rounded display corners.
*/ */
@Composable @Composable
fun SyncStatusBar( fun SyncStatusBar(
@@ -51,7 +57,8 @@ fun SyncStatusBar(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerLow) .background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(horizontal = 16.dp, vertical = 8.dp), .navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -39,6 +39,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
@@ -264,9 +265,14 @@ internal fun LibraryBookCard(book: BookEntity, onClick: () -> Unit) {
contentDescription = book.title, contentDescription = book.title,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
// titleSmall's 24sp line height is right for a paragraph and wrong here: on a
// wrapped two-line title it opened a bigger gap between the title's own lines
// than between the title and the author beneath it, so the author read as part
// of the title block. Tighten the leading and give the author its own gap, so
// the card groups as one title + one byline.
Text( Text(
text = book.title, text = book.title,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall.copy(lineHeight = 20.sp),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -280,6 +286,7 @@ internal fun LibraryBookCard(book: BookEntity, onClick: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp),
) )
} }
} }
@@ -18,7 +18,14 @@ object DuplicateCheck {
/** What the scan bottom sheet is currently showing. */ /** What the scan bottom sheet is currently showing. */
sealed interface ScanSheetState { sealed interface ScanSheetState {
data object Hidden : ScanSheetState data object Hidden : ScanSheetState
data object Loading : ScanSheetState
/**
* Carries [isbn13] so the sheet can name the code it just read. A bare spinner
* doesn't tell the user the barcode was recognised, and they keep holding the
* book up to the camera; echoing the number back is the signal that they can
* lower it.
*/
data class Loading(val isbn13: String) : ScanSheetState
data class Found(val isbn13: String, val metadata: BookMetadata, val duplicate: DuplicateStatus) : ScanSheetState data class Found(val isbn13: String, val metadata: BookMetadata, val duplicate: DuplicateStatus) : ScanSheetState
data class NotFound(val isbn13: String) : ScanSheetState data class NotFound(val isbn13: String) : ScanSheetState
} }
@@ -137,9 +137,7 @@ fun ScanScreen(
when (val state = sheetState) { when (val state = sheetState) {
is ScanSheetState.Hidden -> Unit is ScanSheetState.Hidden -> Unit
is ScanSheetState.Loading -> ModalBottomSheet(onDismissRequest = { }, sheetState = rememberModalBottomSheetState()) { is ScanSheetState.Loading -> ModalBottomSheet(onDismissRequest = { }, sheetState = rememberModalBottomSheetState()) {
Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { SearchingSheet(isbn13 = state.isbn13)
CircularProgressIndicator()
}
} }
is ScanSheetState.Found -> ModalBottomSheet( is ScanSheetState.Found -> ModalBottomSheet(
onDismissRequest = { viewModel.dismissSheet() }, onDismissRequest = { viewModel.dismissSheet() },
@@ -238,6 +236,40 @@ 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.
*/
@Composable
internal fun SearchingSheet(isbn13: String) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Text(
text = "Searching…",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 20.dp),
)
Text(
text = "ISBN $isbn13",
style = MaterialTheme.typography.bodyMedium,
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),
)
}
}
@Composable @Composable
internal fun SessionBadge(count: Int, modifier: Modifier = Modifier) { internal fun SessionBadge(count: Int, modifier: Modifier = Modifier) {
if (count == 0) return if (count == 0) return
@@ -52,7 +52,7 @@ class ScanViewModel(
private suspend fun onScanned(isbn13: String) { private suspend fun onScanned(isbn13: String) {
if (_sheetState.value !is ScanSheetState.Hidden) return // a sheet is already up for a previous hit if (_sheetState.value !is ScanSheetState.Hidden) return // a sheet is already up for a previous hit
_sheetState.value = ScanSheetState.Loading _sheetState.value = ScanSheetState.Loading(isbn13)
val duplicate = DuplicateCheck.check(bookRepository.findByIsbn13(isbn13)) val duplicate = DuplicateCheck.check(bookRepository.findByIsbn13(isbn13))
val metadata = metadataRepository.lookup(isbn13) val metadata = metadataRepository.lookup(isbn13)
_sheetState.value = ScanMetadataOutcome.from(isbn13, metadata, duplicate) _sheetState.value = ScanMetadataOutcome.from(isbn13, metadata, duplicate)
@@ -4,7 +4,9 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -15,6 +17,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -38,11 +43,23 @@ fun SetupScreen(onSetupComplete: () -> Unit, container: AppContainer) {
}, },
) )
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val focusManager = LocalFocusManager.current
val canSubmit = !state.isSubmitting &&
state.serverUrl.isNotBlank() &&
state.email.isNotBlank() &&
state.password.isNotBlank()
PaperSurface(modifier = Modifier.fillMaxSize()) { PaperSurface(modifier = Modifier.fillMaxSize()) {
// This screen has no Scaffold of its own, so it owns its window insets.
// safeDrawingPadding covers the IME as well as the system bars, and it sits
// OUTSIDE verticalScroll on purpose: the keyboard then shrinks the scrollable
// viewport rather than covering it, so Compose brings the newly focused field
// into view instead of leaving Password stranded behind the IME.
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.safeDrawingPadding()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(24.dp), .padding(24.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
@@ -61,7 +78,11 @@ fun SetupScreen(onSetupComplete: () -> Unit, container: AppContainer) {
label = { Text("Server URL") }, label = { Text("Server URL") },
placeholder = { Text("https://library.example.com") }, placeholder = { Text("https://library.example.com") },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
isError = state.urlError != null, isError = state.urlError != null,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@@ -81,7 +102,11 @@ fun SetupScreen(onSetupComplete: () -> Unit, container: AppContainer) {
onValueChange = viewModel::onEmailChanged, onValueChange = viewModel::onEmailChanged,
label = { Text("Email") }, label = { Text("Email") },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
isError = state.credentialsError != null, isError = state.credentialsError != null,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp), modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) )
@@ -91,7 +116,16 @@ fun SetupScreen(onSetupComplete: () -> Unit, container: AppContainer) {
label = { Text("Password") }, label = { Text("Password") },
singleLine = true, singleLine = true,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (canSubmit) viewModel.submit(onSetupComplete)
},
),
isError = state.credentialsError != null, isError = state.credentialsError != null,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp), modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) )
@@ -107,10 +141,7 @@ fun SetupScreen(onSetupComplete: () -> Unit, container: AppContainer) {
PrimaryButton( PrimaryButton(
text = if (state.isSubmitting) "Signing in…" else "Sign in", text = if (state.isSubmitting) "Signing in…" else "Sign in",
onClick = { viewModel.submit(onSetupComplete) }, onClick = { viewModel.submit(onSetupComplete) },
enabled = !state.isSubmitting && enabled = canSubmit,
state.serverUrl.isNotBlank() &&
state.email.isNotBlank() &&
state.password.isNotBlank(),
modifier = Modifier.padding(top = 24.dp), modifier = Modifier.padding(top = 24.dp),
) )
} }
@@ -0,0 +1,25 @@
package org.modg.bookshelf.data.metadata
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class MetadataRepositoryTest {
/**
* `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
* — an image loader calls that a successful load, so the cover slot renders empty
* and the placeholder never appears. With it, a miss is a 404 the loader can report.
*/
@Test
fun `by-isbn cover url disables the blank stand-in image`() {
val url = MetadataRepository.byIsbnCoverUrl("9780201558029")
assertEquals(
"https://covers.openlibrary.org/b/isbn/9780201558029-L.jpg?default=false",
url,
)
assertTrue("must opt out of the 1x1 stand-in", url.contains("default=false"))
}
}
@@ -29,7 +29,36 @@ class OpenLibraryClientTest {
assertEquals(672, result.pageCount) assertEquals(672, result.pageCount)
assertEquals("9780201558029", result.isbn13) assertEquals("9780201558029", result.isbn13)
assertEquals("0201558025", result.isbn10) assertEquals("0201558025", result.isbn10)
assertEquals("https://covers.openlibrary.org/b/isbn/9780201558029-L.jpg", result.coverUrl) assertEquals("https://covers.openlibrary.org/b/id/675832-L.jpg", result.coverUrl)
}
/**
* The by-ISBN cover endpoint answers 200 with a 1x1 transparent GIF for editions
* with no art, so synthesizing that URL here would hand the UI a cover that loads
* "successfully" and paints nothing. No `cover` object means no cover URL, which
* is what lets [MetadataMerger] fall through to Google Books' thumbnail.
*/
@Test
fun `leaves coverUrl null when the response reports no cover art`() {
val body = """
{"ISBN:9780201558029": {"title": "Concrete Mathematics", "publishers": [{"name": "AW"}]}}
""".trimIndent()
val result = checkNotNull(client.parseResponse(body, "9780201558029"))
assertEquals("Concrete Mathematics", result.title)
assertNull(result.coverUrl)
}
@Test
fun `falls back to the medium cover when no large one is offered`() {
val body = """
{"ISBN:9780201558029": {"title": "Concrete Mathematics",
"cover": {"small": "https://covers.openlibrary.org/b/id/675832-S.jpg",
"medium": "https://covers.openlibrary.org/b/id/675832-M.jpg"}}}
""".trimIndent()
val result = checkNotNull(client.parseResponse(body, "9780201558029"))
assertEquals("https://covers.openlibrary.org/b/id/675832-M.jpg", result.coverUrl)
} }
@Test @Test
@@ -28,6 +28,7 @@ import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.scan.DuplicateStatus import org.modg.bookshelf.ui.scan.DuplicateStatus
import org.modg.bookshelf.ui.scan.FoundBookSheet import org.modg.bookshelf.ui.scan.FoundBookSheet
import org.modg.bookshelf.ui.scan.ScanReticle import org.modg.bookshelf.ui.scan.ScanReticle
import org.modg.bookshelf.ui.scan.SearchingSheet
import org.modg.bookshelf.ui.scan.SessionBadge import org.modg.bookshelf.ui.scan.SessionBadge
import org.modg.bookshelf.ui.theme.BookshelfTheme import org.modg.bookshelf.ui.theme.BookshelfTheme
@@ -52,6 +53,10 @@ class ScanScreenPaparazziTest {
@Test @Test
fun scanFoundSheetLight() = snapshotBoth("scan-found-sheet") { FoundSheetOverlay() } fun scanFoundSheetLight() = snapshotBoth("scan-found-sheet") { FoundSheetOverlay() }
/** The state between "barcode decoded" and "metadata back" — see [SearchingSheet]. */
@Test
fun scanSearchingSheetLight() = snapshotBoth("scan-searching-sheet") { SearchingSheetOverlay() }
@Composable @Composable
private fun ReticleOverlay() = Shell { private fun ReticleOverlay() = Shell {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -89,6 +94,19 @@ class ScanScreenPaparazziTest {
} }
} }
@Composable
private fun SearchingSheetOverlay() = Shell {
Box(modifier = Modifier.fillMaxSize()) {
Surface(
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
color = MaterialTheme.colorScheme.surface,
) {
SearchingSheet(isbn13 = "9780765326355")
}
}
}
@Composable @Composable
private fun Shell(overlay: @Composable () -> Unit) { private fun Shell(overlay: @Composable () -> Unit) {
BookshelfScaffold( BookshelfScaffold(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

+49
View File
@@ -285,3 +285,52 @@ library, scaffold — each light + dark).
survive minification. survive minification.
4. Still-open user questions, unchanged: where the server will actually live, and 4. Still-open user questions, unchanged: where the server will actually live, and
the two account emails for `create-user.sh`. the two account emails for `create-user.sh`.
## First on-device test — 2026-09-09
The user installed the signed APK on a real phone. It runs. This closes the
"never been run" gap that waves 1-4 all carried. Six issues came back; five were
fixed directly by the orchestrator in commit `356f639` (they were small, and
spinning up Sonnet workers for two-line Compose edits costs more than it saves).
**The one worth remembering** — `BookCover` branched on `painter.state`, but in
coil3 that is a `StateFlow<State>`, not a `State`. Every `is
AsyncImagePainter.State.X` arm was therefore always false, and because a `when`
used as a statement needs no `else`, it compiled clean and drew NOTHING — no
cover, no placeholder, no error icon. Kotlin emitted "Check for instance is
always 'false'" as a *warning* on four consecutive lines and the build stayed
green. **Grep the build log for `always 'false'` before accepting a wave**; that
warning class is a silent-dead-code detector and this build had it for months.
Two more cover defects sat behind it, both verified against the live service:
- `covers.openlibrary.org/b/isbn/{isbn}-L.jpg` answers **200 with a 43-byte 1x1
transparent GIF** for an edition with no art. Any image loader calls that a
successful load. Only `?default=false` turns a miss into a 404.
- OL's DTO synthesized that URL unconditionally, so `MetadataMerger`'s
fill-blanks rule could never reach Google Books' thumbnail. The SPEC'd cover
fallback was dead code. Cover URLs now come from OL's own `cover` object.
Also fixed: sync bar clipped by rounded display corners (now owns its
navigation-bar inset, wider horizontal padding, moved into Scaffold's `bottomBar`
slot); setup screen's Password field hidden behind the IME (`safeDrawingPadding`
outside `verticalScroll`, plus Next/Next/Done IME actions and
`windowSoftInputMode=adjustResize`); library card titles reflowed (20sp leading,
author gets its own 4dp gap); scan sheet now names the ISBN and says
"Searching…" instead of showing a bare spinner.
| Check | Result |
|---|---|
| `./tasks/gw assembleDebug` | exit 0 |
| `./tasks/gw testDebugUnitTest` | exit 0 — 106 tests, 1 skipped, 0 failures |
| `./tasks/gw verifyPaparazziDebug` | exit 0 against re-recorded snapshots |
| `./tasks/gw assembleRelease` | exit 0 — 41,777,376 bytes |
| `apksigner verify` | V2 signer `CN=Bookshelf, O=Montanaro` — real release key |
### Open, not started: metadata coverage
The user reported 1 of 3 scans resolving, and asked for **research, not a
change**. Findings are in `docs/METADATA-SOURCES.md`. Headline: Open Library
answered 88% of a 60-ISBN sample, keyless Google Books returned **429 on 60 of
60** requests, and both clients collapse every non-200 into `null` — so a
rate-limited lookup reaches the user as "No match found." Recommended order is a
free Google Books API key, then distinguishing "couldn't ask" from "not found",
then retry/backoff, before adding any new source. **Awaiting the user's decision;
do not implement unasked.**
+166
View File
@@ -0,0 +1,166 @@
# Book metadata lookup — why scans miss, and what else we could ask
Research note, 2026-09-09. Written in response to "out of the 3 barcodes I've
scanned, only 1 has been discovered properly."
**Nothing in here has been implemented.** SPEC's two-source design (Open Library
primary, Google Books fallback) is unchanged. This is the evidence for deciding
whether to change it.
---
## Short version
Three separate defects were making lookups *look* far worse than the underlying
data actually is, and all three are now fixed (commit `356f639`). They are not
the same problem as "this book isn't in the database":
1. A cover that loaded fine still rendered as nothing, so a **successful** lookup
looked like a failed one. That alone could account for the book you did find
appearing broken.
2. Open Library's cover URL was synthesized for every book whether or not art
existed, and a missing cover comes back as a **200 with a 43-byte 1×1
transparent GIF** — a "successful" load that paints nothing.
3. Because that synthesized URL was never blank, the merge rule could never fall
through to Google Books' thumbnail. The documented fallback was dead code for
covers.
What is left is a real coverage question, and there the measurements point at one
thing above all others: **the Google Books fallback is probably not answering at
all.** Every keyless request from this machine returned HTTP 429, and the app
turns any non-200 into `null`, which the UI presents as "No match found — enter
the details by hand." A rate-limited lookup and a book that genuinely exists
nowhere are, right now, indistinguishable to both you and me.
My recommendation is to fix the diagnosis before buying more data. Details in
[Recommendation](#recommendation).
---
## What I measured
**Sample.** 60 ISBNs drawn from the Harvard Library catalog — deliberately a
third party, so the sample doesn't presuppose the answer by coming from one of
the two sources under test. Ten publishers, weighted toward the small Catholic
and homeschool presses that a MODG family's shelf actually carries (Ignatius,
TAN, Sophia Institute, Bethlehem Books, Baronius) alongside mainstream trade
(Penguin, Random House, Scholastic, Crossway, Loyola).
**Method.** Direct HTTP against each API, one ISBN at a time, 1.2 s apart. A
source "hits" only if it returns a usable title.
### Results
| Source | Hit | Miss | Error | Hit rate |
|---|---|---|---|---|
| Open Library Books API (what the app calls today) | 53 | 4 | 3 | **88%** |
| Google Books, keyless (the app's fallback) | 0 | 0 | **60 × HTTP 429** | **0%** |
| Harvard LibraryCloud | 56 | 4 | 0 | 93% * |
| Open Library cover art exists for the ISBN | 47 | 13 | 0 | 78% |
\* Harvard is where the sample came from, so its number is inflated by
construction. It's here to show the API works and answers by ISBN-13, not as a
fair comparison.
Two further observations from the same runs:
- **Concurrency is punished.** The same 60 ISBNs run six-at-a-time dropped Open
Library from 88% to 70%, entirely through transport errors. The app makes one
request per scan, so this doesn't bite in normal use — but it does mean
"Open Library missed" in a log is not proof the book is absent. By the end of
this research my own IP was refused outright for a while.
- **17% of successful Open Library lookups have no cover art at all** (9 of 53).
Even with everything working, roughly one book in six will legitimately show
the placeholder. That is a data fact, not a bug, and it's worth knowing before
you read a placeholder as a failure.
### What this does not tell us
Worth saying plainly, because it bounds how much weight the numbers carry:
- n = 60, and the sample comes from a research library. It under-represents
recent mass-market paperbacks, reprints and print-on-demand editions — which
is exactly where Open Library is thinnest. Real shelf coverage is probably
*below* 88%.
- Every request came from a datacenter IP. The Google Books 429 may partly be
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.
- 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.
---
## The options
### A. Give Google Books an API key
Free, 1,000 requests/day, no billing account required. Turns the fallback from
"silently 429" into a working source. Roughly a dozen lines: a key in
`local.properties``BuildConfig``&key=` on the query.
The key ships inside the APK and can be extracted, so restrict it to the Books
API in the Google Cloud console. At our volume, someone stealing it costs us
nothing but the quota.
**Effort: hours. Cost: free. Likely the single biggest win.**
### B. Tell the difference between "not found" and "couldn't ask"
Both clients collapse every non-200, timeout and parse failure into `null`, and
`MetadataRepository` collapses that into "no match", and the UI writes "No match
found." A book that's offline, rate-limited, or hit a 500 is reported to you as
a book that does not exist.
Distinguishing these gets you a retry button instead of a manual-entry form, and
gets me a real answer next time you say "it missed."
**Effort: half a day. Cost: free. Do this regardless of what else we choose.**
### C. Retry with backoff
One retry on 429/5xx, a couple of seconds apart. Standing at a bookshelf, a
two-second retry is invisible; a manual-entry form is not.
**Effort: an hour. Cost: free.**
### D. Add a third free source
Only worth doing after AC, when we can see what's actually still missing.
Ranked by what I'd try first:
| Source | Key? | Notes |
|---|---|---|
| **Open Library `search.json`** | No | Searches the whole OL index rather than the edition table the Books API reads. Cheapest possible fallback — same service, one more request, no new failure modes. |
| **Harvard LibraryCloud** | No | Free, no registration, answers by ISBN-13, verified working. Strong on older, scholarly and small-press books — the shape of gap we'd expect. Returns MODS; no cover art, and no ISBN-13 for pre-EAN books unless we convert. |
| **Library of Congress** | No | The SRU endpoint (port 210) is blocked from here; the `loc.gov` JSON API responds. Excellent for US imprints. Needs more probing before I'd commit. |
| **K10plus SRU** | No | Free German-led union catalogue, large and international. Cataloguing conventions differ enough that merging would need care. |
Dead ends, so nobody re-investigates them: **OCLC Classify** (retired 2021),
**Goodreads API** (retired 2020), **Amazon Product Advertising API** (requires an
affiliate account with qualifying sales), **WorldCat Search** (requires OCLC
membership — institutional pricing).
### E. Pay for ISBNdb
~$1550/month depending on tier. Genuinely better coverage than anything free,
including cover art, and a single clean API. It is also a subscription for a
two-person home library, and I'd want proof that AD leave a real gap before
recommending it.
**Effort: hours. Cost: $180600/year.**
---
## Recommendation
Do **A + B + C** together — they're cheap, they're independent of any decision
about new sources, and between them they cover the most likely explanation for
1-in-3. Then rescan the same books and let the app tell us what it actually got.
If a real gap survives that, add **Open Library `search.json`** (D) as an
in-family fallback before reaching for a new organisation's API, and Harvard
after that.
I'd hold off on **E** entirely until we have numbers from your own shelf. Paying
for coverage we might already have would be the wrong order.
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.