Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd3a61fc33 | ||
|
|
93f972b7d1 |
@@ -1,6 +1,7 @@
|
|||||||
package org.modg.bookshelf
|
package org.modg.bookshelf
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
@@ -86,11 +87,19 @@ class AppContainer(private val context: Context) {
|
|||||||
|
|
||||||
val authRepository by lazy { AuthRepository(apiProvider, settingsStore) }
|
val authRepository by lazy { AuthRepository(apiProvider, settingsStore) }
|
||||||
|
|
||||||
// A bare client — deliberately NOT [okHttpClient] above, which carries our
|
// Deliberately NOT [okHttpClient] above, which carries our PocketBase bearer
|
||||||
// PocketBase bearer token via PbAuthInterceptor. Open Library/Google Books
|
// token via PbAuthInterceptor. Open Library/Google Books are third-party
|
||||||
// are third-party services; that token must never leave this device's
|
// services; that token must never leave this device's requests to our own
|
||||||
// requests to our own server.
|
// server. A call timeout is load-bearing here: with none, a stalled
|
||||||
private val metadataHttpClient: OkHttpClient by lazy { OkHttpClient() }
|
// connection hangs on OkHttp's default (unbounded) socket timeouts, and the
|
||||||
|
// user is standing at a bookshelf waiting on it.
|
||||||
|
private val metadataHttpClient: OkHttpClient by lazy {
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.callTimeout(12, TimeUnit.SECONDS)
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
val metadataRepository by lazy { MetadataRepository(metadataHttpClient, json) }
|
val metadataRepository by lazy { MetadataRepository(metadataHttpClient, json) }
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.modg.bookshelf.data.metadata
|
package org.modg.bookshelf.data.metadata
|
||||||
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.SerializationException
|
import kotlinx.serialization.SerializationException
|
||||||
@@ -10,7 +11,8 @@ import okhttp3.Request
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Google Books lookup — SPEC.md "Book metadata lookup" fallback source. No API key.
|
* Google Books lookup — SPEC.md "Book metadata lookup" fallback source. No API key.
|
||||||
* Never throws: network/parse failures fail soft and return null.
|
* Never throws: every outcome, including transport failure, comes back as a
|
||||||
|
* [SourceResult] rather than a swallowed null.
|
||||||
*/
|
*/
|
||||||
class GoogleBooksClient(
|
class GoogleBooksClient(
|
||||||
private val httpClient: OkHttpClient,
|
private val httpClient: OkHttpClient,
|
||||||
@@ -18,29 +20,42 @@ class GoogleBooksClient(
|
|||||||
) {
|
) {
|
||||||
private val json = Json(from = json) { ignoreUnknownKeys = true }
|
private val json = Json(from = json) { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
suspend fun lookup(isbn13: String): BookMetadata? = withContext(Dispatchers.IO) {
|
suspend fun lookup(isbn13: String): SourceResult = withContext(Dispatchers.IO) { fetch(isbn13) }
|
||||||
val body = fetchBody(isbn13) ?: return@withContext null
|
|
||||||
parseResponse(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fetchBody(isbn13: String): String? = try {
|
private fun fetch(isbn13: String): SourceResult = try {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url("https://www.googleapis.com/books/v1/volumes?q=isbn:$isbn13")
|
.url("https://www.googleapis.com/books/v1/volumes?q=isbn:$isbn13")
|
||||||
.build()
|
.build()
|
||||||
httpClient.newCall(request).execute().use { response ->
|
httpClient.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) null else response.body?.string()
|
classify(response.code, response.body.string())
|
||||||
}
|
}
|
||||||
|
} catch (e: SocketTimeoutException) {
|
||||||
|
SourceResult.Failed("timeout")
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
null
|
SourceResult.Failed("network error")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package-visible pure function — no socket involved — so it's exhaustively
|
||||||
|
* unit-testable offline (2xx-with-record, 2xx-without-record, 404, 429, 500,
|
||||||
|
* malformed body). [parseResponse] is defined in terms of this so the two
|
||||||
|
* can never disagree about what a body means.
|
||||||
|
*/
|
||||||
|
internal fun classify(httpCode: Int, body: String?): SourceResult {
|
||||||
|
if (httpCode !in 200..299) return SourceResult.Failed("http $httpCode")
|
||||||
|
if (body.isNullOrBlank()) return SourceResult.Failed("empty body")
|
||||||
|
return try {
|
||||||
|
val dto = json.decodeFromString(GoogleBooksResponseDto.serializer(), body)
|
||||||
|
val metadata = dto.items.firstOrNull()?.volumeInfo?.toBookMetadata()
|
||||||
|
if (metadata != null) SourceResult.Found(metadata) else SourceResult.NotFound
|
||||||
|
} catch (e: SerializationException) {
|
||||||
|
SourceResult.Failed("malformed json")
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
SourceResult.Failed("malformed json")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Package-visible for offline fixture tests — parses a raw response body with no network involved. */
|
/** Package-visible for offline fixture tests — parses a raw response body with no network involved. */
|
||||||
internal fun parseResponse(body: String): BookMetadata? = try {
|
internal fun parseResponse(body: String): BookMetadata? =
|
||||||
val dto = json.decodeFromString(GoogleBooksResponseDto.serializer(), body)
|
(classify(200, body) as? SourceResult.Found)?.metadata
|
||||||
dto.items.firstOrNull()?.volumeInfo?.toBookMetadata()
|
|
||||||
} catch (e: SerializationException) {
|
|
||||||
null
|
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package org.modg.bookshelf.data.metadata
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The combined outcome of a metadata lookup across both sources (SPEC.md "Book
|
||||||
|
* metadata lookup"). Produced by [MetadataRepository.lookup] from the two
|
||||||
|
* [SourceResult]s per [MetadataRepository.combine]'s rules:
|
||||||
|
* - any source [SourceResult.Found] -> [Found]
|
||||||
|
* - every source [SourceResult.NotFound] -> [NotFound]
|
||||||
|
* - otherwise (at least one [SourceResult.Failed], none Found) -> [Unavailable]
|
||||||
|
*
|
||||||
|
* The last rule is the whole point: one reachable source answering "no" is not
|
||||||
|
* authoritative while the other source couldn't be asked at all.
|
||||||
|
*/
|
||||||
|
sealed interface LookupResult {
|
||||||
|
data class Found(val metadata: BookMetadata) : LookupResult
|
||||||
|
data object NotFound : LookupResult
|
||||||
|
data class Unavailable(val reason: String) : LookupResult
|
||||||
|
}
|
||||||
@@ -7,9 +7,10 @@ import okhttp3.OkHttpClient
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Single entry point for book metadata lookup (SPEC.md "Book metadata lookup").
|
* Single entry point for book metadata lookup (SPEC.md "Book metadata lookup").
|
||||||
* Queries both sources concurrently and merges per [MetadataMerger]. Returns null
|
* Queries both sources concurrently and combines their [SourceResult]s into one
|
||||||
* if [isbn] doesn't checksum-validate or if both sources miss — callers (the scan
|
* [LookupResult] per [combine]. [isbn] must already be a checksum-valid ISBN-10/13
|
||||||
* screen) must then fall back to manual entry pre-filled with the scanned ISBN.
|
* by the time it reaches here — the scan layer is responsible for that, and an
|
||||||
|
* invalid one is a programming error at this layer, not a lookup outcome.
|
||||||
*/
|
*/
|
||||||
class MetadataRepository(
|
class MetadataRepository(
|
||||||
private val openLibraryClient: OpenLibraryClient,
|
private val openLibraryClient: OpenLibraryClient,
|
||||||
@@ -20,21 +21,46 @@ class MetadataRepository(
|
|||||||
GoogleBooksClient(httpClient, json),
|
GoogleBooksClient(httpClient, json),
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun lookup(isbn: String): BookMetadata? {
|
suspend fun lookup(isbn: String): LookupResult {
|
||||||
val isbn13 = IsbnUtils.toIsbn13(isbn) ?: return null
|
val isbn13 = checkNotNull(IsbnUtils.toIsbn13(isbn)) {
|
||||||
val merged = coroutineScope {
|
"MetadataRepository.lookup requires an already-validated ISBN-10/13; got: $isbn"
|
||||||
|
}
|
||||||
|
return 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())
|
combine(openLibrary.await(), googleBooks.await(), isbn13)
|
||||||
} ?: return null
|
|
||||||
return if (merged.coverUrl.isNullOrBlank()) {
|
|
||||||
merged.copy(coverUrl = byIsbnCoverUrl(isbn13))
|
|
||||||
} else {
|
|
||||||
merged
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
/**
|
||||||
|
* Combines the two sources' outcomes per SPEC.md's three-way rule: any Found
|
||||||
|
* wins (merged via [MetadataMerger] and given the last-resort cover), all
|
||||||
|
* NotFound is an honest miss, and anything else — at least one Failed and
|
||||||
|
* nothing Found — is Unavailable, because a source that couldn't be reached
|
||||||
|
* must never be reported as a book that doesn't exist. Package-visible and
|
||||||
|
* pure (no I/O) so the full 3x3 matrix is unit-testable offline.
|
||||||
|
*/
|
||||||
|
internal fun combine(openLibrary: SourceResult, googleBooks: SourceResult, isbn13: String): LookupResult {
|
||||||
|
val merged = MetadataMerger.merge(
|
||||||
|
(openLibrary as? SourceResult.Found)?.metadata,
|
||||||
|
(googleBooks as? SourceResult.Found)?.metadata,
|
||||||
|
)
|
||||||
|
if (merged != null) {
|
||||||
|
val withCover = if (merged.coverUrl.isNullOrBlank()) {
|
||||||
|
merged.copy(coverUrl = byIsbnCoverUrl(isbn13))
|
||||||
|
} else {
|
||||||
|
merged
|
||||||
|
}
|
||||||
|
return LookupResult.Found(withCover)
|
||||||
|
}
|
||||||
|
val failures = listOfNotNull(
|
||||||
|
(openLibrary as? SourceResult.Failed)?.reason?.let { "open library: $it" },
|
||||||
|
(googleBooks as? SourceResult.Failed)?.reason?.let { "google books: $it" },
|
||||||
|
)
|
||||||
|
return if (failures.isNotEmpty()) LookupResult.Unavailable(failures.joinToString("; ")) else LookupResult.NotFound
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last resort when neither source reported cover art, per SPEC's cover chain.
|
* 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
|
* `default=false` is load-bearing: without it this endpoint answers 200 with a
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.modg.bookshelf.data.metadata
|
package org.modg.bookshelf.data.metadata
|
||||||
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.SerializationException
|
import kotlinx.serialization.SerializationException
|
||||||
@@ -12,7 +13,8 @@ import okhttp3.Request
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Open Library lookup — SPEC.md "Book metadata lookup" primary source.
|
* Open Library lookup — SPEC.md "Book metadata lookup" primary source.
|
||||||
* Never throws: network/parse failures fail soft and return null.
|
* Never throws: every outcome, including transport failure, comes back as a
|
||||||
|
* [SourceResult] rather than a swallowed null.
|
||||||
*/
|
*/
|
||||||
class OpenLibraryClient(
|
class OpenLibraryClient(
|
||||||
private val httpClient: OkHttpClient,
|
private val httpClient: OkHttpClient,
|
||||||
@@ -21,30 +23,42 @@ class OpenLibraryClient(
|
|||||||
// Real responses carry fields this DTO doesn't model; never let an unknown key throw.
|
// Real responses carry fields this DTO doesn't model; never let an unknown key throw.
|
||||||
private val json = Json(from = json) { ignoreUnknownKeys = true }
|
private val json = Json(from = json) { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
suspend fun lookup(isbn13: String): BookMetadata? = withContext(Dispatchers.IO) {
|
suspend fun lookup(isbn13: String): SourceResult = withContext(Dispatchers.IO) { fetch(isbn13) }
|
||||||
val body = fetchBody(isbn13) ?: return@withContext null
|
|
||||||
parseResponse(body, isbn13)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fetchBody(isbn13: String): String? = try {
|
private fun fetch(isbn13: String): SourceResult = try {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url("https://openlibrary.org/api/books?bibkeys=ISBN:$isbn13&format=json&jscmd=data")
|
.url("https://openlibrary.org/api/books?bibkeys=ISBN:$isbn13&format=json&jscmd=data")
|
||||||
.build()
|
.build()
|
||||||
httpClient.newCall(request).execute().use { response ->
|
httpClient.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) null else response.body?.string()
|
classify(response.code, response.body.string(), isbn13)
|
||||||
}
|
}
|
||||||
|
} catch (e: SocketTimeoutException) {
|
||||||
|
SourceResult.Failed("timeout")
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
null
|
SourceResult.Failed("network error")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package-visible pure function — no socket involved — so it's exhaustively
|
||||||
|
* unit-testable offline (2xx-with-record, 2xx-without-record, 404, 429, 500,
|
||||||
|
* malformed body). [parseResponse] is defined in terms of this so the two
|
||||||
|
* can never disagree about what a body means.
|
||||||
|
*/
|
||||||
|
internal fun classify(httpCode: Int, body: String?, isbn13: String): SourceResult {
|
||||||
|
if (httpCode !in 200..299) return SourceResult.Failed("http $httpCode")
|
||||||
|
if (body.isNullOrBlank()) return SourceResult.Failed("empty body")
|
||||||
|
return try {
|
||||||
|
val root = json.parseToJsonElement(body).jsonObject
|
||||||
|
val entry = root["ISBN:$isbn13"]?.jsonObject ?: return SourceResult.NotFound
|
||||||
|
SourceResult.Found(json.decodeFromJsonElement<OpenLibraryBookDto>(entry).toBookMetadata(isbn13))
|
||||||
|
} catch (e: SerializationException) {
|
||||||
|
SourceResult.Failed("malformed json")
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
SourceResult.Failed("malformed json")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Package-visible for offline fixture tests — parses a raw response body with no network involved. */
|
/** Package-visible for offline fixture tests — parses a raw response body with no network involved. */
|
||||||
internal fun parseResponse(body: String, isbn13: String): BookMetadata? = try {
|
internal fun parseResponse(body: String, isbn13: String): BookMetadata? =
|
||||||
val root = json.parseToJsonElement(body).jsonObject
|
(classify(200, body, isbn13) as? SourceResult.Found)?.metadata
|
||||||
val entry = root["ISBN:$isbn13"]?.jsonObject ?: return null
|
|
||||||
json.decodeFromJsonElement<OpenLibraryBookDto>(entry).toBookMetadata(isbn13)
|
|
||||||
} catch (e: SerializationException) {
|
|
||||||
null
|
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package org.modg.bookshelf.data.metadata
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-source lookup outcome (SPEC.md "Book metadata lookup": "Lookup outcome is
|
||||||
|
* THREE-WAY, never a bare null"). [OpenLibraryClient] and [GoogleBooksClient] each
|
||||||
|
* produce one of these instead of collapsing "couldn't be reached" and "answered,
|
||||||
|
* doesn't have it" into the same `null`. [MetadataRepository] combines the two
|
||||||
|
* source results into a [LookupResult].
|
||||||
|
*/
|
||||||
|
sealed interface SourceResult {
|
||||||
|
data class Found(val metadata: BookMetadata) : SourceResult
|
||||||
|
|
||||||
|
/** The source answered (2xx) and, in good faith, has no record for this ISBN. */
|
||||||
|
data object NotFound : SourceResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The source could not be asked, or its answer couldn't be trusted: non-2xx,
|
||||||
|
* timeout, transport error, or a body that didn't parse. [reason] is a short
|
||||||
|
* diagnostic ("http 429", "timeout", "malformed json") for logs — never shown
|
||||||
|
* to the user verbatim.
|
||||||
|
*/
|
||||||
|
data class Failed(val reason: String) : SourceResult
|
||||||
|
}
|
||||||
@@ -2,11 +2,31 @@ package org.modg.bookshelf.ui.scan
|
|||||||
|
|
||||||
import org.modg.bookshelf.data.metadata.IsbnUtils
|
import org.modg.bookshelf.data.metadata.IsbnUtils
|
||||||
|
|
||||||
|
/** What a single decoded barcode read means, once debouncing has been applied. */
|
||||||
|
sealed interface ScanOutcome {
|
||||||
|
/** A checksum-valid ISBN-13, ready for metadata lookup. */
|
||||||
|
data class Isbn(val isbn13: String) : ScanOutcome
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoded fine, but its checksum rules it out as a book ISBN (SPEC.md
|
||||||
|
* "Barcode scanning": "ignore non-book barcodes" — but not silently, see
|
||||||
|
* [rawValue]). Carries the raw value so the camera screen can echo back what
|
||||||
|
* it read.
|
||||||
|
*/
|
||||||
|
data class NotAnIsbn(val rawValue: String) : ScanOutcome
|
||||||
|
|
||||||
|
/** A debounced repeat of the last code (valid or not) — emit no UI at all. */
|
||||||
|
data object Ignored : ScanOutcome
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure decode-to-ISBN pipeline shared by the analyzer and its tests: normalizes a raw
|
* Pure decode-to-outcome pipeline shared by the analyzer and its tests: normalizes
|
||||||
* barcode value, validates the ISBN-13 checksum (this is what makes EAN_8/UPC_A reads
|
* a raw barcode value, classifies it as a valid ISBN-13 or not (this is what makes
|
||||||
* fall out as "non-book barcodes" per SPEC.md "Barcode scanning" — they can never be
|
* EAN_8/UPC_A reads fall out as "non-book barcodes" per SPEC.md "Barcode scanning"
|
||||||
* 13 digits), and debounces repeat reads of the same code.
|
* — they can never be 13 digits), and debounces repeats of the same code, whether
|
||||||
|
* or not it validated. The debounce is what keeps a non-book barcode sitting in
|
||||||
|
* frame — which decodes on nearly every analyzed frame — from flickering a
|
||||||
|
* rejection message instead of showing it once.
|
||||||
*/
|
*/
|
||||||
class ScanCodeFilter(
|
class ScanCodeFilter(
|
||||||
private val debounceMillis: Long = 2000L,
|
private val debounceMillis: Long = 2000L,
|
||||||
@@ -15,15 +35,19 @@ class ScanCodeFilter(
|
|||||||
private var lastCode: String? = null
|
private var lastCode: String? = null
|
||||||
private var lastEmitMillis: Long = Long.MIN_VALUE
|
private var lastEmitMillis: Long = Long.MIN_VALUE
|
||||||
|
|
||||||
/** Returns the normalized ISBN-13 if [rawValue] is a valid, non-debounced hit; null otherwise. */
|
/** Classifies [rawValue] per [ScanOutcome], applying the debounce window. */
|
||||||
fun accept(rawValue: String?): String? {
|
fun accept(rawValue: String?): ScanOutcome {
|
||||||
val normalized = IsbnUtils.normalize(rawValue ?: return null)
|
val raw = rawValue ?: return ScanOutcome.Ignored
|
||||||
if (!IsbnUtils.isValidIsbn13(normalized)) return null
|
val normalized = IsbnUtils.normalize(raw)
|
||||||
val now = nowMillis()
|
val now = nowMillis()
|
||||||
if (normalized == lastCode && now - lastEmitMillis < debounceMillis) return null
|
if (normalized == lastCode && now - lastEmitMillis < debounceMillis) return ScanOutcome.Ignored
|
||||||
lastCode = normalized
|
lastCode = normalized
|
||||||
lastEmitMillis = now
|
lastEmitMillis = now
|
||||||
return normalized
|
return if (IsbnUtils.isValidIsbn13(normalized)) {
|
||||||
|
ScanOutcome.Isbn(normalized)
|
||||||
|
} else {
|
||||||
|
ScanOutcome.NotAnIsbn(raw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Allows the next scan of any code (including a repeat) to emit immediately, e.g. after Skip. */
|
/** Allows the next scan of any code (including a repeat) to emit immediately, e.g. after Skip. */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.modg.bookshelf.ui.scan
|
|||||||
|
|
||||||
import org.modg.bookshelf.data.local.BookEntity
|
import org.modg.bookshelf.data.local.BookEntity
|
||||||
import org.modg.bookshelf.data.metadata.BookMetadata
|
import org.modg.bookshelf.data.metadata.BookMetadata
|
||||||
|
import org.modg.bookshelf.data.metadata.LookupResult
|
||||||
|
|
||||||
/** SPEC.md scan screen: "Duplicate-ISBN warning if already owned." */
|
/** SPEC.md scan screen: "Duplicate-ISBN warning if already owned." */
|
||||||
sealed interface DuplicateStatus {
|
sealed interface DuplicateStatus {
|
||||||
@@ -27,14 +28,30 @@ sealed interface ScanSheetState {
|
|||||||
*/
|
*/
|
||||||
data class Loading(val isbn13: String) : ScanSheetState
|
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
|
|
||||||
|
/**
|
||||||
|
* Every source answered and none had the book (SPEC: an honest "no", not a
|
||||||
|
* cover story for a failed request). [viaLookupFailure] is true only when the
|
||||||
|
* user reached this form through [LookupFailed]'s "Enter by hand" escape hatch
|
||||||
|
* rather than a genuine miss, so the sheet's copy can stop short of claiming
|
||||||
|
* the book is unknown.
|
||||||
|
*/
|
||||||
|
data class NotFound(val isbn13: String, val viaLookupFailure: Boolean = false) : ScanSheetState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At least one source couldn't be reached (non-2xx, timeout, transport error)
|
||||||
|
* and neither had the book — SPEC: "must NOT claim the book is unknown". Offers
|
||||||
|
* retry, manual entry, and skip instead of a manual-entry form captioned as a miss.
|
||||||
|
*/
|
||||||
|
data class LookupFailed(val isbn13: String, val reason: String) : ScanSheetState
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Combines a metadata lookup result with duplicate status into the sheet state to show. */
|
/** Combines a metadata lookup result with duplicate status into the sheet state to show. */
|
||||||
object ScanMetadataOutcome {
|
object ScanMetadataOutcome {
|
||||||
fun from(isbn13: String, metadata: BookMetadata?, duplicate: DuplicateStatus): ScanSheetState = when (metadata) {
|
fun from(isbn13: String, result: LookupResult, duplicate: DuplicateStatus): ScanSheetState = when (result) {
|
||||||
null -> ScanSheetState.NotFound(isbn13)
|
is LookupResult.Found -> ScanSheetState.Found(isbn13, result.metadata, duplicate)
|
||||||
else -> ScanSheetState.Found(isbn13, metadata, duplicate)
|
is LookupResult.NotFound -> ScanSheetState.NotFound(isbn13)
|
||||||
|
is LookupResult.Unavailable -> ScanSheetState.LookupFailed(isbn13, result.reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||||
import androidx.compose.material.icons.outlined.FlashOff
|
import androidx.compose.material.icons.outlined.FlashOff
|
||||||
@@ -44,6 +45,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
@@ -58,6 +60,7 @@ import org.modg.bookshelf.AppContainer
|
|||||||
import org.modg.bookshelf.data.local.BookcaseEntity
|
import org.modg.bookshelf.data.local.BookcaseEntity
|
||||||
import org.modg.bookshelf.data.local.ShelfEntity
|
import org.modg.bookshelf.data.local.ShelfEntity
|
||||||
import org.modg.bookshelf.data.metadata.BookMetadata
|
import org.modg.bookshelf.data.metadata.BookMetadata
|
||||||
|
import org.modg.bookshelf.data.metadata.IsbnUtils
|
||||||
import org.modg.bookshelf.ui.components.BookCover
|
import org.modg.bookshelf.ui.components.BookCover
|
||||||
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
||||||
import org.modg.bookshelf.ui.components.EmptyState
|
import org.modg.bookshelf.ui.components.EmptyState
|
||||||
@@ -89,6 +92,7 @@ fun ScanScreen(
|
|||||||
|
|
||||||
val sheetState by viewModel.sheetState.collectAsState()
|
val sheetState by viewModel.sheetState.collectAsState()
|
||||||
val sessionState by viewModel.sessionState.collectAsState()
|
val sessionState by viewModel.sessionState.collectAsState()
|
||||||
|
val rejectedMessage by viewModel.rejectedMessage.collectAsState()
|
||||||
val torchEnabled by viewModel.scannerController.torchEnabled.collectAsState()
|
val torchEnabled by viewModel.scannerController.torchEnabled.collectAsState()
|
||||||
val bookcases by viewModel.bookcases.collectAsState()
|
val bookcases by viewModel.bookcases.collectAsState()
|
||||||
val shelves by viewModel.shelves.collectAsState()
|
val shelves by viewModel.shelves.collectAsState()
|
||||||
@@ -128,6 +132,12 @@ fun ScanScreen(
|
|||||||
CameraPreview(controller = viewModel.scannerController, torchEnabled = torchEnabled)
|
CameraPreview(controller = viewModel.scannerController, torchEnabled = torchEnabled)
|
||||||
ScanReticle(modifier = Modifier.align(Alignment.Center))
|
ScanReticle(modifier = Modifier.align(Alignment.Center))
|
||||||
SessionBadge(count = sessionState.savedCount, modifier = Modifier.align(Alignment.TopCenter).padding(16.dp))
|
SessionBadge(count = sessionState.savedCount, modifier = Modifier.align(Alignment.TopCenter).padding(16.dp))
|
||||||
|
rejectedMessage?.let { message ->
|
||||||
|
RejectedBarcodeBanner(
|
||||||
|
message = message,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
PermissionDeniedContent(onRequestAgain = { permissionState.launchPermissionRequest() })
|
PermissionDeniedContent(onRequestAgain = { permissionState.launchPermissionRequest() })
|
||||||
}
|
}
|
||||||
@@ -160,6 +170,7 @@ fun ScanScreen(
|
|||||||
) {
|
) {
|
||||||
ManualEntrySheet(
|
ManualEntrySheet(
|
||||||
isbn13 = state.isbn13,
|
isbn13 = state.isbn13,
|
||||||
|
authoritative = !state.viaLookupFailure,
|
||||||
bookcases = bookcases,
|
bookcases = bookcases,
|
||||||
shelves = shelves,
|
shelves = shelves,
|
||||||
selectedShelfId = selectedShelfId,
|
selectedShelfId = selectedShelfId,
|
||||||
@@ -168,6 +179,17 @@ fun ScanScreen(
|
|||||||
onSkip = { viewModel.skip() },
|
onSkip = { viewModel.skip() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is ScanSheetState.LookupFailed -> ModalBottomSheet(
|
||||||
|
onDismissRequest = { viewModel.dismissSheet() },
|
||||||
|
sheetState = rememberModalBottomSheetState(),
|
||||||
|
) {
|
||||||
|
LookupFailedSheet(
|
||||||
|
isbn13 = state.isbn13,
|
||||||
|
onRetry = { viewModel.retryLookup(state.isbn13) },
|
||||||
|
onEnterByHand = { viewModel.enterByHand(state.isbn13) },
|
||||||
|
onSkip = { viewModel.skip() },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showManualEntry) {
|
if (showManualEntry) {
|
||||||
@@ -340,9 +362,16 @@ internal fun FoundBookSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when every source answered and none had the book — SPEC: word this as the
|
||||||
|
* authoritative negative it is, not as a generic failure. [authoritative] is false
|
||||||
|
* only when this form was reached via [LookupFailedSheet]'s "Enter by hand", where
|
||||||
|
* the lookup never completed and the copy must not imply the book is unknown.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ManualEntrySheet(
|
internal fun ManualEntrySheet(
|
||||||
isbn13: String,
|
isbn13: String,
|
||||||
|
authoritative: Boolean,
|
||||||
bookcases: List<BookcaseEntity>,
|
bookcases: List<BookcaseEntity>,
|
||||||
shelves: List<ShelfEntity>,
|
shelves: List<ShelfEntity>,
|
||||||
selectedShelfId: String?,
|
selectedShelfId: String?,
|
||||||
@@ -354,9 +383,12 @@ internal fun ManualEntrySheet(
|
|||||||
var authors by remember { mutableStateOf("") }
|
var authors by remember { mutableStateOf("") }
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
Text(text = "No match found", style = MaterialTheme.typography.titleLarge)
|
|
||||||
Text(
|
Text(
|
||||||
text = "ISBN $isbn13 — enter the details by hand.",
|
text = if (authoritative) "Not in Open Library or Google Books" else "Enter the details by hand",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (authoritative) "ISBN $isbn13 — enter the details by hand." else "ISBN $isbn13",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
@@ -393,6 +425,54 @@ internal fun ManualEntrySheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when a lookup couldn't be completed — SPEC: "a retry affordance, with
|
||||||
|
* manual entry as the escape hatch; it must NOT claim the book is unknown." No
|
||||||
|
* shelf picker here: saving isn't offered from this sheet, only a path onward to
|
||||||
|
* one that does (Retry, or the manual-entry form via "Enter by hand").
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun LookupFailedSheet(
|
||||||
|
isbn13: String,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onEnterByHand: () -> Unit,
|
||||||
|
onSkip: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
|
Text(text = "Couldn't complete the lookup", style = MaterialTheme.typography.titleLarge)
|
||||||
|
Text(
|
||||||
|
text = "ISBN $isbn13 — one or more sources couldn't be reached. " +
|
||||||
|
"This doesn't mean the book is unknown.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
|
||||||
|
)
|
||||||
|
PrimaryButton(text = "Retry", onClick = onRetry, modifier = Modifier.fillMaxWidth())
|
||||||
|
Row(modifier = Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
SecondaryButton(text = "Enter by hand", onClick = onEnterByHand, modifier = Modifier.weight(1f))
|
||||||
|
SecondaryButton(text = "Skip", onClick = onSkip, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transient banner for a decoded-but-rejected barcode (SPEC.md "Barcode scanning":
|
||||||
|
* "A rejected barcode is NOT silent"). [ScanViewModel] owns the debounce (via
|
||||||
|
* [ScanCodeFilter]) and the auto-clear timer behind [message] — this composable
|
||||||
|
* just renders whatever it's handed.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun RejectedBarcodeBanner(message: String, modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = modifier
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(20.dp))
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ShelfPicker(
|
internal fun ShelfPicker(
|
||||||
bookcases: List<BookcaseEntity>,
|
bookcases: List<BookcaseEntity>,
|
||||||
@@ -431,19 +511,37 @@ internal fun ShelfPicker(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit) {
|
private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit) {
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
|
// Validate here rather than letting ScanViewModel.manualIsbnEntered drop an
|
||||||
|
// unparseable ISBN on the floor. A typed-in check digit is easy to get wrong,
|
||||||
|
// and a dialog whose button does nothing is the same silent failure this whole
|
||||||
|
// screen was just rebuilt to eliminate.
|
||||||
|
val isbn13 = remember(text) { IsbnUtils.toIsbn13(text) }
|
||||||
|
val malformed = text.isNotBlank() && isbn13 == null
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Enter ISBN") },
|
title = { Text("Enter ISBN") },
|
||||||
text = {
|
text = {
|
||||||
|
Column {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
label = { Text("ISBN-10 or ISBN-13") },
|
label = { Text("ISBN-10 or ISBN-13") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
isError = malformed,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
)
|
)
|
||||||
|
if (malformed) {
|
||||||
|
Text(
|
||||||
|
text = "That isn't a valid ISBN — check the digits.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
PrimaryButton(text = "Look up", onClick = { onSubmit(text) }, enabled = text.isNotBlank())
|
PrimaryButton(text = "Look up", onClick = { onSubmit(text) }, enabled = isbn13 != null)
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
SecondaryButton(text = "Cancel", onClick = onDismiss)
|
SecondaryButton(text = "Cancel", onClick = onDismiss)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package org.modg.bookshelf.ui.scan
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -35,6 +37,11 @@ class ScanViewModel(
|
|||||||
private val _sessionState = MutableStateFlow(ScanSessionState())
|
private val _sessionState = MutableStateFlow(ScanSessionState())
|
||||||
val sessionState: StateFlow<ScanSessionState> = _sessionState.asStateFlow()
|
val sessionState: StateFlow<ScanSessionState> = _sessionState.asStateFlow()
|
||||||
|
|
||||||
|
private val _rejectedMessage = MutableStateFlow<String?>(null)
|
||||||
|
/** Transient "read but not a book barcode" message for the camera overlay; auto-clears. */
|
||||||
|
val rejectedMessage: StateFlow<String?> = _rejectedMessage.asStateFlow()
|
||||||
|
private var rejectedMessageClearJob: Job? = null
|
||||||
|
|
||||||
val bookcases: StateFlow<List<BookcaseEntity>> = locationRepository.observeBookcases()
|
val bookcases: StateFlow<List<BookcaseEntity>> = locationRepository.observeBookcases()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
val shelves: StateFlow<List<ShelfEntity>> = locationRepository.observeShelves()
|
val shelves: StateFlow<List<ShelfEntity>> = locationRepository.observeShelves()
|
||||||
@@ -48,14 +55,41 @@ class ScanViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
scannerController.scanResults.collect { isbn13 -> onScanned(isbn13) }
|
scannerController.scanResults.collect { isbn13 -> onScanned(isbn13) }
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
scannerController.rejectedCodes.collect { rawValue -> showRejectedMessage(rawValue) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
runLookup(isbn13)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runLookup(isbn13: String) {
|
||||||
_sheetState.value = ScanSheetState.Loading(isbn13)
|
_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 result = metadataRepository.lookup(isbn13)
|
||||||
_sheetState.value = ScanMetadataOutcome.from(isbn13, metadata, duplicate)
|
_sheetState.value = ScanMetadataOutcome.from(isbn13, result, duplicate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [ScanSheetState.LookupFailed]'s Retry — re-enters loading and re-runs the same lookup. */
|
||||||
|
fun retryLookup(isbn13: String) {
|
||||||
|
viewModelScope.launch { runLookup(isbn13) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [ScanSheetState.LookupFailed]'s "Enter by hand" — falls through to the manual-entry form. */
|
||||||
|
fun enterByHand(isbn13: String) {
|
||||||
|
_sheetState.value = ScanSheetState.NotFound(isbn13, viaLookupFailure = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throttled per [ScanCodeFilter]'s debounce; this just owns the auto-clear timer on top. */
|
||||||
|
private fun showRejectedMessage(rawValue: String) {
|
||||||
|
rejectedMessageClearJob?.cancel()
|
||||||
|
_rejectedMessage.value = "Read $rawValue — not a book barcode"
|
||||||
|
rejectedMessageClearJob = viewModelScope.launch {
|
||||||
|
delay(REJECTED_MESSAGE_MILLIS)
|
||||||
|
_rejectedMessage.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The manual-ISBN-entry escape hatch (SPEC: for when a barcode won't scan). */
|
/** The manual-ISBN-entry escape hatch (SPEC: for when a barcode won't scan). */
|
||||||
@@ -114,4 +148,9 @@ class ScanViewModel(
|
|||||||
_sheetState.value = ScanSheetState.Hidden
|
_sheetState.value = ScanSheetState.Hidden
|
||||||
scannerController.resetDebounce()
|
scannerController.resetDebounce()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** How long a rejected-barcode message stays on screen before it auto-clears. */
|
||||||
|
const val REJECTED_MESSAGE_MILLIS = 3000L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ class ScannerController(
|
|||||||
/** Emits a debounced, checksum-valid ISBN-13 each time [IsbnBarcodeAnalyzer] sees a new book barcode. */
|
/** Emits a debounced, checksum-valid ISBN-13 each time [IsbnBarcodeAnalyzer] sees a new book barcode. */
|
||||||
val scanResults: SharedFlow<String> = _scanResults.asSharedFlow()
|
val scanResults: SharedFlow<String> = _scanResults.asSharedFlow()
|
||||||
|
|
||||||
|
private val _rejectedCodes = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||||
|
/**
|
||||||
|
* Emits the raw value of a debounced, non-book barcode read (SPEC.md "Barcode
|
||||||
|
* scanning": "A rejected barcode is NOT silent"). [ScanOutcome.Ignored] reads
|
||||||
|
* (debounced repeats) never reach here.
|
||||||
|
*/
|
||||||
|
val rejectedCodes: SharedFlow<String> = _rejectedCodes.asSharedFlow()
|
||||||
|
|
||||||
private val _torchEnabled = MutableStateFlow(false)
|
private val _torchEnabled = MutableStateFlow(false)
|
||||||
val torchEnabled: StateFlow<Boolean> = _torchEnabled.asStateFlow()
|
val torchEnabled: StateFlow<Boolean> = _torchEnabled.asStateFlow()
|
||||||
|
|
||||||
@@ -27,7 +35,11 @@ class ScannerController(
|
|||||||
|
|
||||||
/** Called by [IsbnBarcodeAnalyzer] for every decoded barcode's raw value. */
|
/** Called by [IsbnBarcodeAnalyzer] for every decoded barcode's raw value. */
|
||||||
fun onBarcodeScanned(rawValue: String?) {
|
fun onBarcodeScanned(rawValue: String?) {
|
||||||
codeFilter.accept(rawValue)?.let { _scanResults.tryEmit(it) }
|
when (val outcome = codeFilter.accept(rawValue)) {
|
||||||
|
is ScanOutcome.Isbn -> _scanResults.tryEmit(outcome.isbn13)
|
||||||
|
is ScanOutcome.NotAnIsbn -> _rejectedCodes.tryEmit(outcome.rawValue)
|
||||||
|
ScanOutcome.Ignored -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleTorch() {
|
fun toggleTorch() {
|
||||||
|
|||||||
@@ -46,6 +46,47 @@ class GoogleBooksClientTest {
|
|||||||
assertNull(client.parseResponse(body))
|
assertNull(client.parseResponse(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- classify(): the three-way per-source outcome (SPEC.md "Book metadata lookup"). ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `classify reports Found for a 2xx response with a record`() {
|
||||||
|
val result = client.classify(200, fixture("googlebooks_success.json"))
|
||||||
|
|
||||||
|
val found = result as? SourceResult.Found
|
||||||
|
checkNotNull(found) { "expected Found, got $result" }
|
||||||
|
assertEquals("Effective Java", found.metadata.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `classify reports NotFound for a 2xx response with no items`() {
|
||||||
|
val result = client.classify(200, fixture("googlebooks_no_items.json"))
|
||||||
|
assertEquals(SourceResult.NotFound, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `classify reports Failed for a 404`() {
|
||||||
|
val result = client.classify(404, null)
|
||||||
|
assertEquals(SourceResult.Failed("http 404"), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `classify reports Failed for a 429 keyless-quota response, distinctly from NotFound`() {
|
||||||
|
val result = client.classify(429, null)
|
||||||
|
assertEquals(SourceResult.Failed("http 429"), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `classify reports Failed for a 500`() {
|
||||||
|
val result = client.classify(500, "Internal Server Error")
|
||||||
|
assertEquals(SourceResult.Failed("http 500"), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `classify reports Failed for a malformed body even on a 2xx status`() {
|
||||||
|
val result = client.classify(200, fixture("malformed.json"))
|
||||||
|
assertEquals(SourceResult.Failed("malformed json"), result)
|
||||||
|
}
|
||||||
|
|
||||||
private fun fixture(name: String): String =
|
private fun fixture(name: String): String =
|
||||||
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
|
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
|
||||||
.bufferedReader()
|
.bufferedReader()
|
||||||
|
|||||||
@@ -6,6 +6,80 @@ import org.junit.Test
|
|||||||
|
|
||||||
class MetadataRepositoryTest {
|
class MetadataRepositoryTest {
|
||||||
|
|
||||||
|
private val isbn13 = "9780201558029"
|
||||||
|
private val openLibraryHit = SourceResult.Found(BookMetadata(title = "Open Library Title", isbn13 = isbn13))
|
||||||
|
private val googleBooksHit = SourceResult.Found(BookMetadata(title = "Google Books Title", isbn13 = isbn13))
|
||||||
|
|
||||||
|
// --- The full 3x3 (Open Library outcome x Google Books outcome) combination matrix
|
||||||
|
// from SPEC.md "Book metadata lookup": any Found wins, all-NotFound is an honest
|
||||||
|
// miss, anything else with a Failed in it is Unavailable — never a false negative. ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Found + Found merges and returns Found`() {
|
||||||
|
val result = MetadataRepository.combine(openLibraryHit, googleBooksHit, isbn13)
|
||||||
|
assertTrue(result is LookupResult.Found)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Found + NotFound returns Found from the one source that had it`() {
|
||||||
|
val result = MetadataRepository.combine(openLibraryHit, SourceResult.NotFound, isbn13)
|
||||||
|
assertEquals("Open Library Title", (result as LookupResult.Found).metadata.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Found + Failed returns Found -- a reachable hit is not overruled by the other failing`() {
|
||||||
|
val result = MetadataRepository.combine(openLibraryHit, SourceResult.Failed("http 429"), isbn13)
|
||||||
|
assertEquals("Open Library Title", (result as LookupResult.Found).metadata.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `NotFound + Found returns Found from the one source that had it`() {
|
||||||
|
val result = MetadataRepository.combine(SourceResult.NotFound, googleBooksHit, isbn13)
|
||||||
|
assertEquals("Google Books Title", (result as LookupResult.Found).metadata.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `NotFound + NotFound returns NotFound -- both sources answered and neither had it`() {
|
||||||
|
val result = MetadataRepository.combine(SourceResult.NotFound, SourceResult.NotFound, isbn13)
|
||||||
|
assertEquals(LookupResult.NotFound, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `NotFound + Failed returns Unavailable -- one honest miss is not authoritative alone`() {
|
||||||
|
val result = MetadataRepository.combine(SourceResult.NotFound, SourceResult.Failed("timeout"), isbn13)
|
||||||
|
assertTrue(result is LookupResult.Unavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Failed + Found returns Found from the one source that had it`() {
|
||||||
|
val result = MetadataRepository.combine(SourceResult.Failed("http 500"), googleBooksHit, isbn13)
|
||||||
|
assertEquals("Google Books Title", (result as LookupResult.Found).metadata.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Failed + NotFound returns Unavailable -- one honest miss is not authoritative alone`() {
|
||||||
|
val result = MetadataRepository.combine(SourceResult.Failed("timeout"), SourceResult.NotFound, isbn13)
|
||||||
|
assertTrue(result is LookupResult.Unavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Failed + Failed returns Unavailable and preserves both reasons`() {
|
||||||
|
val result = MetadataRepository.combine(SourceResult.Failed("http 429"), SourceResult.Failed("timeout"), isbn13)
|
||||||
|
|
||||||
|
val unavailable = result as? LookupResult.Unavailable
|
||||||
|
checkNotNull(unavailable) { "expected Unavailable, got $result" }
|
||||||
|
assertTrue(unavailable.reason.contains("http 429"))
|
||||||
|
assertTrue(unavailable.reason.contains("timeout"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a Found result without cover art falls back to the by-isbn cover url`() {
|
||||||
|
val bare = SourceResult.Found(BookMetadata(title = "No Cover", isbn13 = isbn13))
|
||||||
|
val result = MetadataRepository.combine(bare, SourceResult.NotFound, isbn13)
|
||||||
|
|
||||||
|
assertEquals(MetadataRepository.byIsbnCoverUrl(isbn13), (result as LookupResult.Found).metadata.coverUrl)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `default=false` is the whole point of this URL. Without it covers.openlibrary.org
|
* `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
|
* answers 200 with a 43-byte 1x1 transparent GIF for an edition it holds no art for
|
||||||
|
|||||||
@@ -105,6 +105,47 @@ class OpenLibraryClientTest {
|
|||||||
assertNull(client.parseResponse(body, "9780201558029"))
|
assertNull(client.parseResponse(body, "9780201558029"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- classify(): the three-way per-source outcome (SPEC.md "Book metadata lookup"). ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `classify reports Found for a 2xx response with a record`() {
|
||||||
|
val result = client.classify(200, fixture("openlibrary_success.json"), "9780201558029")
|
||||||
|
|
||||||
|
val found = result as? SourceResult.Found
|
||||||
|
checkNotNull(found) { "expected Found, got $result" }
|
||||||
|
assertEquals("Concrete Mathematics", found.metadata.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `classify reports NotFound for a 2xx response with no record for that isbn`() {
|
||||||
|
val result = client.classify(200, fixture("openlibrary_not_found.json"), "9780201558029")
|
||||||
|
assertEquals(SourceResult.NotFound, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `classify reports Failed for a 404`() {
|
||||||
|
val result = client.classify(404, null, "9780201558029")
|
||||||
|
assertEquals(SourceResult.Failed("http 404"), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `classify reports Failed for a 429, distinctly from NotFound`() {
|
||||||
|
val result = client.classify(429, null, "9780201558029")
|
||||||
|
assertEquals(SourceResult.Failed("http 429"), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `classify reports Failed for a 500`() {
|
||||||
|
val result = client.classify(500, "Internal Server Error", "9780201558029")
|
||||||
|
assertEquals(SourceResult.Failed("http 500"), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `classify reports Failed for a malformed body even on a 2xx status`() {
|
||||||
|
val result = client.classify(200, fixture("malformed.json"), "9780201558029")
|
||||||
|
assertEquals(SourceResult.Failed("malformed json"), result)
|
||||||
|
}
|
||||||
|
|
||||||
private fun fixture(name: String): String =
|
private fun fixture(name: String): String =
|
||||||
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
|
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
|
||||||
.bufferedReader()
|
.bufferedReader()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.modg.bookshelf.ui.scan
|
package org.modg.bookshelf.ui.scan
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ScanCodeFilterTest {
|
class ScanCodeFilterTest {
|
||||||
@@ -9,45 +8,45 @@ class ScanCodeFilterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `accepts a checksum-valid isbn13`() {
|
fun `accepts a checksum-valid isbn13`() {
|
||||||
val filter = ScanCodeFilter()
|
val filter = ScanCodeFilter()
|
||||||
assertEquals("9780201558029", filter.accept("9780201558029"))
|
assertEquals(ScanOutcome.Isbn("9780201558029"), filter.accept("9780201558029"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rejects a checksum-invalid isbn13-shaped code`() {
|
fun `rejects a checksum-invalid isbn13-shaped code as not-an-isbn`() {
|
||||||
val filter = ScanCodeFilter()
|
val filter = ScanCodeFilter()
|
||||||
assertNull(filter.accept("9780201558020"))
|
assertEquals(ScanOutcome.NotAnIsbn("9780201558020"), filter.accept("9780201558020"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rejects non-book barcode lengths such as EAN-8 or UPC-A`() {
|
fun `rejects non-book barcode lengths such as EAN-8 or UPC-A as not-an-isbn`() {
|
||||||
val filter = ScanCodeFilter()
|
val filter = ScanCodeFilter()
|
||||||
assertNull(filter.accept("12345670")) // EAN-8 shaped
|
assertEquals(ScanOutcome.NotAnIsbn("12345670"), filter.accept("12345670")) // EAN-8 shaped
|
||||||
assertNull(filter.accept("012345678905")) // UPC-A shaped, 12 digits
|
assertEquals(ScanOutcome.NotAnIsbn("012345678905"), filter.accept("012345678905")) // UPC-A shaped, 12 digits
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rejects null raw value`() {
|
fun `ignores null raw value`() {
|
||||||
assertNull(ScanCodeFilter().accept(null))
|
assertEquals(ScanOutcome.Ignored, ScanCodeFilter().accept(null))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `debounces a repeat of the same code within the window`() {
|
fun `debounces a repeat of the same valid code within the window`() {
|
||||||
var now = 0L
|
var now = 0L
|
||||||
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
|
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
|
||||||
|
|
||||||
assertEquals("9780201558029", filter.accept("9780201558029"))
|
assertEquals(ScanOutcome.Isbn("9780201558029"), filter.accept("9780201558029"))
|
||||||
now += 500
|
now += 500
|
||||||
assertNull(filter.accept("9780201558029"))
|
assertEquals(ScanOutcome.Ignored, filter.accept("9780201558029"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `re-emits the same code once the debounce window elapses`() {
|
fun `re-emits the same valid code once the debounce window elapses`() {
|
||||||
var now = 0L
|
var now = 0L
|
||||||
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
|
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
|
||||||
|
|
||||||
assertEquals("9780201558029", filter.accept("9780201558029"))
|
assertEquals(ScanOutcome.Isbn("9780201558029"), filter.accept("9780201558029"))
|
||||||
now += 2001
|
now += 2001
|
||||||
assertEquals("9780201558029", filter.accept("9780201558029"))
|
assertEquals(ScanOutcome.Isbn("9780201558029"), filter.accept("9780201558029"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -55,9 +54,9 @@ class ScanCodeFilterTest {
|
|||||||
var now = 0L
|
var now = 0L
|
||||||
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
|
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
|
||||||
|
|
||||||
assertEquals("9780201558029", filter.accept("9780201558029"))
|
assertEquals(ScanOutcome.Isbn("9780201558029"), filter.accept("9780201558029"))
|
||||||
now += 10
|
now += 10
|
||||||
assertEquals("9780134685991", filter.accept("9780134685991"))
|
assertEquals(ScanOutcome.Isbn("9780134685991"), filter.accept("9780134685991"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -65,8 +64,56 @@ class ScanCodeFilterTest {
|
|||||||
var now = 0L
|
var now = 0L
|
||||||
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
|
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
|
||||||
|
|
||||||
assertEquals("9780201558029", filter.accept("9780201558029"))
|
assertEquals(ScanOutcome.Isbn("9780201558029"), filter.accept("9780201558029"))
|
||||||
filter.reset()
|
filter.reset()
|
||||||
assertEquals("9780201558029", filter.accept("9780201558029"))
|
assertEquals(ScanOutcome.Isbn("9780201558029"), filter.accept("9780201558029"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rejected-code throttle (SPEC.md "Barcode scanning": a non-book barcode sitting
|
||||||
|
// in frame decodes on almost every analyzed frame, so this must not flicker). ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a rejected code emits once, then is throttled on every subsequent frame within the window`() {
|
||||||
|
var now = 0L
|
||||||
|
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
|
||||||
|
|
||||||
|
assertEquals(ScanOutcome.NotAnIsbn("12345670"), filter.accept("12345670"))
|
||||||
|
// Simulate several more analyzed frames, all still within the debounce window.
|
||||||
|
now += 50
|
||||||
|
assertEquals(ScanOutcome.Ignored, filter.accept("12345670"))
|
||||||
|
now += 50
|
||||||
|
assertEquals(ScanOutcome.Ignored, filter.accept("12345670"))
|
||||||
|
now += 1000
|
||||||
|
assertEquals(ScanOutcome.Ignored, filter.accept("12345670"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a rejected code re-emits once the debounce window elapses`() {
|
||||||
|
var now = 0L
|
||||||
|
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
|
||||||
|
|
||||||
|
assertEquals(ScanOutcome.NotAnIsbn("12345670"), filter.accept("12345670"))
|
||||||
|
now += 2001
|
||||||
|
assertEquals(ScanOutcome.NotAnIsbn("12345670"), filter.accept("12345670"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a rejected code does not suppress a genuinely different valid isbn read right after it`() {
|
||||||
|
var now = 0L
|
||||||
|
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
|
||||||
|
|
||||||
|
assertEquals(ScanOutcome.NotAnIsbn("12345670"), filter.accept("12345670"))
|
||||||
|
now += 10
|
||||||
|
assertEquals(ScanOutcome.Isbn("9780201558029"), filter.accept("9780201558029"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `switching between two different rejected codes does not throttle either`() {
|
||||||
|
var now = 0L
|
||||||
|
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
|
||||||
|
|
||||||
|
assertEquals(ScanOutcome.NotAnIsbn("12345670"), filter.accept("12345670"))
|
||||||
|
now += 10
|
||||||
|
assertEquals(ScanOutcome.NotAnIsbn("012345678905"), filter.accept("012345678905"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.modg.bookshelf.data.local.BookEntity
|
import org.modg.bookshelf.data.local.BookEntity
|
||||||
import org.modg.bookshelf.data.metadata.BookMetadata
|
import org.modg.bookshelf.data.metadata.BookMetadata
|
||||||
|
import org.modg.bookshelf.data.metadata.LookupResult
|
||||||
|
|
||||||
class ScanModelsTest {
|
class ScanModelsTest {
|
||||||
|
|
||||||
@@ -25,21 +26,27 @@ class ScanModelsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `metadata miss maps to NotFound regardless of duplicate status`() {
|
fun `an authoritative NotFound result maps to NotFound regardless of duplicate status`() {
|
||||||
val outcome = ScanMetadataOutcome.from("9780201558029", null, DuplicateStatus.New)
|
val outcome = ScanMetadataOutcome.from("9780201558029", LookupResult.NotFound, DuplicateStatus.New)
|
||||||
assertEquals(ScanSheetState.NotFound("9780201558029"), outcome)
|
assertEquals(ScanSheetState.NotFound("9780201558029"), outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `metadata hit maps to Found carrying the duplicate status through`() {
|
fun `a Found result maps to Found carrying the duplicate status through`() {
|
||||||
val metadata = BookMetadata(title = "Dune", isbn13 = "9780201558029")
|
val metadata = BookMetadata(title = "Dune", isbn13 = "9780201558029")
|
||||||
val duplicate = DuplicateStatus.AlreadyOwned("abc123", "Dune")
|
val duplicate = DuplicateStatus.AlreadyOwned("abc123", "Dune")
|
||||||
|
|
||||||
val outcome = ScanMetadataOutcome.from("9780201558029", metadata, duplicate)
|
val outcome = ScanMetadataOutcome.from("9780201558029", LookupResult.Found(metadata), duplicate)
|
||||||
|
|
||||||
assertEquals(ScanSheetState.Found("9780201558029", metadata, duplicate), outcome)
|
assertEquals(ScanSheetState.Found("9780201558029", metadata, duplicate), outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an Unavailable result maps to LookupFailed and never claims the book is unknown`() {
|
||||||
|
val outcome = ScanMetadataOutcome.from("9780201558029", LookupResult.Unavailable("http 429"), DuplicateStatus.New)
|
||||||
|
assertEquals(ScanSheetState.LookupFailed("9780201558029", "http 429"), outcome)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `session count starts at zero and increments per save, not per skip`() {
|
fun `session count starts at zero and increments per save, not per skip`() {
|
||||||
var session = ScanSessionState()
|
var session = ScanSessionState()
|
||||||
|
|||||||
@@ -46,6 +46,39 @@ class ScannerControllerTest {
|
|||||||
assertEquals(listOf("9780201558029"), received)
|
assertEquals(listOf("9780201558029"), received)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-book barcode emits on rejectedCodes, not scanResults`() = runTest {
|
||||||
|
val controller = ScannerController()
|
||||||
|
val scanned = mutableListOf<String>()
|
||||||
|
val rejected = mutableListOf<String>()
|
||||||
|
backgroundScope.launch { controller.scanResults.toList(scanned) }
|
||||||
|
backgroundScope.launch { controller.rejectedCodes.toList(rejected) }
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
controller.onBarcodeScanned("012345678905") // UPC-A shaped, fails the ISBN-13 checksum
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertTrue(scanned.isEmpty())
|
||||||
|
assertEquals(listOf("012345678905"), rejected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a debounced repeat reaches neither scanResults nor rejectedCodes`() = runTest {
|
||||||
|
val controller = ScannerController()
|
||||||
|
val scanned = mutableListOf<String>()
|
||||||
|
val rejected = mutableListOf<String>()
|
||||||
|
backgroundScope.launch { controller.scanResults.toList(scanned) }
|
||||||
|
backgroundScope.launch { controller.rejectedCodes.toList(rejected) }
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
controller.onBarcodeScanned("012345678905")
|
||||||
|
controller.onBarcodeScanned("012345678905") // same frame's worth of repeat reads
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertEquals(listOf("012345678905"), rejected)
|
||||||
|
assertTrue(scanned.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `torch toggles from its default off state`() {
|
fun `torch toggles from its default off state`() {
|
||||||
val controller = ScannerController()
|
val controller = ScannerController()
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import org.modg.bookshelf.data.metadata.BookMetadata
|
|||||||
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
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.LookupFailedSheet
|
||||||
|
import org.modg.bookshelf.ui.scan.RejectedBarcodeBanner
|
||||||
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.SearchingSheet
|
||||||
import org.modg.bookshelf.ui.scan.SessionBadge
|
import org.modg.bookshelf.ui.scan.SessionBadge
|
||||||
@@ -57,6 +59,14 @@ class ScanScreenPaparazziTest {
|
|||||||
@Test
|
@Test
|
||||||
fun scanSearchingSheetLight() = snapshotBoth("scan-searching-sheet") { SearchingSheetOverlay() }
|
fun scanSearchingSheetLight() = snapshotBoth("scan-searching-sheet") { SearchingSheetOverlay() }
|
||||||
|
|
||||||
|
/** Neither source could be reached — see [LookupFailedSheet]. Must not read as "not found". */
|
||||||
|
@Test
|
||||||
|
fun scanLookupFailedSheetLight() = snapshotBoth("scan-lookup-failed-sheet") { LookupFailedSheetOverlay() }
|
||||||
|
|
||||||
|
/** A decoded barcode that failed the ISBN-13 checksum — see [RejectedBarcodeBanner]. */
|
||||||
|
@Test
|
||||||
|
fun scanRejectedBarcodeLight() = snapshotBoth("scan-rejected-barcode") { RejectedBarcodeOverlay() }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReticleOverlay() = Shell {
|
private fun ReticleOverlay() = Shell {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
@@ -107,6 +117,35 @@ class ScanScreenPaparazziTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LookupFailedSheetOverlay() = Shell {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
) {
|
||||||
|
LookupFailedSheet(
|
||||||
|
isbn13 = "9780765326355",
|
||||||
|
onRetry = {},
|
||||||
|
onEnterByHand = {},
|
||||||
|
onSkip = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RejectedBarcodeOverlay() = Shell {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
ScanReticle(modifier = Modifier.align(Alignment.Center))
|
||||||
|
RejectedBarcodeBanner(
|
||||||
|
message = "Read 012345678905 — not a book barcode",
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Shell(overlay: @Composable () -> Unit) {
|
private fun Shell(overlay: @Composable () -> Unit) {
|
||||||
BookshelfScaffold(
|
BookshelfScaffold(
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -373,3 +373,41 @@ that.
|
|||||||
worth doing on its own merits but no longer the leading theory), R8 still off so
|
worth doing on its own merits but no longer the leading theory), R8 still off so
|
||||||
the release APK is 41.8MB and too large to send over the file channel (30MB cap),
|
the release APK is 41.8MB and too large to send over the file channel (30MB cap),
|
||||||
where the server will live, and the two account emails for `create-user.sh`.
|
where the server will live, and the two account emails for `create-user.sh`.
|
||||||
|
|
||||||
|
## Wave 5 — G-diagnostics: COMPLETE, verified by the orchestrator 2026-09-09
|
||||||
|
Commit `93f972b`. The app now distinguishes barcode-didn't-decode from
|
||||||
|
lookup-request-failed from genuinely-not-found; see the commit message and
|
||||||
|
`docs/METADATA-SOURCES.md`.
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `assembleDebug` | exit 0 |
|
||||||
|
| `testDebugUnitTest` | exit 0 — **138 tests**, 1 skipped, 0 failures (was 107) |
|
||||||
|
| `verifyPaparazziDebug` | exit 0 |
|
||||||
|
| `assembleRelease` | exit 0 — 41,793,760 bytes, V2 signer `CN=Bookshelf` |
|
||||||
|
| boundary check | clean — no build files, no forbidden packages |
|
||||||
|
| `grep "always 'false'"` | 0 hits on touched files |
|
||||||
|
|
||||||
|
The worker was honest this time: everything it claimed checked out. Cost $0.28,
|
||||||
|
6 turns, one quota wait that `run-task.sh` resumed correctly.
|
||||||
|
|
||||||
|
**The orchestrator added one thing the worker's brief didn't cover:** the
|
||||||
|
manual-ISBN dialog silently discarded an unparseable entry — the same silent
|
||||||
|
failure this wave existed to eliminate, sitting just outside the prompt's scope.
|
||||||
|
It now marks the field in error and disables "Look up" until the checksum passes.
|
||||||
|
Lesson for future prompts: scope a wave by *failure class*, not by file list, or
|
||||||
|
the instances of the class that live outside the listed files survive.
|
||||||
|
|
||||||
|
### HAZARD #8 — the wave-guard can die without writing its sentinel
|
||||||
|
`logs/WAVE5-DONE` was written BY HAND. The guard renewed at 11:13, the worker
|
||||||
|
succeeded at 11:21, and the guard neither wrote the sentinel nor logged its
|
||||||
|
"guard exiting" trap line — it was killed outright. The lease expired on its own
|
||||||
|
an hour later.
|
||||||
|
|
||||||
|
**This breaks the first-command heuristic at the top of this file.** "no sentinel
|
||||||
|
+ pgrep count 0 -> workers were KILLED" was WRONG here: the worker had finished
|
||||||
|
successfully. Use these instead, in this order:
|
||||||
|
1. `ls -l logs/<name>.json` — 0 bytes means killed; non-zero means it finished.
|
||||||
|
2. `tail logs/<name>.state` — says SUCCESS / GIVING UP / WALL CLOCK explicitly.
|
||||||
|
3. `git status --porcelain` — is there actually work in the tree?
|
||||||
|
The sentinel is a convenience, not the record of truth. `logs/<name>.state` is.
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
=== WAVE5-DONE written BY HAND by the orchestrator, 2026-09-09 ===
|
||||||
|
NOT written by tasks/wave-guard.sh. The guard renewed its lease at 11:13, the
|
||||||
|
worker reported SUCCESS at 11:21, and the guard should have noticed within 30s
|
||||||
|
and written this file. It never did, and it left no "guard exiting" line either,
|
||||||
|
so it was killed outright rather than exiting through its TERM/INT trap. The
|
||||||
|
lease then expired on its own at ~12:13.
|
||||||
|
|
||||||
|
READ THIS BEFORE TRUSTING THE FIRST-COMMAND HEURISTIC AT THE TOP OF HANDOFF.md:
|
||||||
|
"no sentinel + pgrep 0 -> workers were KILLED" would have been WRONG here. The
|
||||||
|
worker finished successfully; only the guard died. The reliable signal is the
|
||||||
|
size of logs/<name>.json (5695 bytes here — a killed worker leaves 0) plus the
|
||||||
|
tail of logs/<name>.state (which says SUCCESS).
|
||||||
|
|
||||||
|
G-diagnostics: SUCCESS after 2 attempts, 1 quota wait. cost=$0.28, turns=6.
|
||||||
|
Independently re-verified by the orchestrator:
|
||||||
|
./tasks/gw assembleDebug exit 0
|
||||||
|
./tasks/gw testDebugUnitTest exit 0 - 138 tests, 1 skipped, 0 failures (was 107)
|
||||||
|
./tasks/gw verifyPaparazziDebug exit 0
|
||||||
|
./tasks/gw assembleRelease exit 0 - 41,793,760 bytes, V2 signer CN=Bookshelf
|
||||||
|
boundary check clean - no build files, no forbidden packages
|
||||||
|
grep "always 'false'" 0 hits on touched files
|
||||||
|
Accepted and committed as 93f972b.
|
||||||
Reference in New Issue
Block a user