Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd24ecbafd | ||
|
|
356f639cdd |
@@ -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
|
||||||
Icon(
|
// beside it. This is what makes an art-less cover still read as a book.
|
||||||
imageVector = if (errored) Icons.Outlined.BrokenImage else Icons.Outlined.AutoStories,
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
contentDescription = null,
|
Box(
|
||||||
tint = ink.copy(alpha = if (errored) 0.35f else 0.28f),
|
modifier = Modifier
|
||||||
modifier = Modifier.fillMaxSize(0.32f),
|
.fillMaxHeight()
|
||||||
|
.width(10.dp)
|
||||||
|
.background(spine.copy(alpha = 0.35f)),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(1.dp)
|
||||||
|
.background(gold.copy(alpha = 0.55f)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = if (errored) Icons.Outlined.BrokenImage else Icons.Outlined.AutoStories,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = ink.copy(alpha = 0.4f),
|
||||||
|
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(
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
@@ -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.**
|
||||||
|
|||||||
@@ -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 A–C, 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
|
||||||
|
~$15–50/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 A–D leave a real gap before
|
||||||
|
recommending it.
|
||||||
|
|
||||||
|
**Effort: hours. Cost: $180–600/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.
|
||||||