Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab83287b5d | ||
|
|
4695731f91 | ||
|
|
009cb9a7cc | ||
|
|
81f63bffc0 | ||
|
|
62cd61e707 | ||
|
|
ab1d294657 | ||
|
|
3c30a20324 | ||
|
|
cf82cbc484 | ||
|
|
b600df69c3 | ||
|
|
9b74f50565 | ||
|
|
0c0b8466cc | ||
|
|
007a1f426d | ||
|
|
34097269c9 | ||
|
|
1295d88d6e | ||
|
|
0455794603 | ||
|
|
93546ed724 | ||
|
|
1385ad286f |
No files matched your search
@@ -124,6 +124,34 @@ that keystore).
|
||||
|
||||
## Deploying the server
|
||||
|
||||
### Where this library's server actually runs today
|
||||
|
||||
The live instance runs **on the development sprite** and is **reachable from
|
||||
the open internet** over HTTPS, on a sprite-issued hostname. That is a
|
||||
deliberate interim choice — it is what the phones are pointed at — and it has
|
||||
one consequence worth stating plainly, because the rest of this README was
|
||||
written under the opposite assumption:
|
||||
|
||||
> **PocketBase's API rules are the only thing standing between this library
|
||||
> and the internet.** There is no home NAT, no VPN, and no reverse proxy in
|
||||
> front of it. The private-by-construction properties described above
|
||||
> (`createRule = null` on `users`, every collection requiring a logged-in
|
||||
> user, and the `pb_hooks` guard that turns an anonymous list into a 403
|
||||
> rather than an empty 200) are load-bearing rather than defence-in-depth.
|
||||
> Verify them against the *public* hostname, not `127.0.0.1` — localhost
|
||||
> cannot tell you what the world can reach. `server/README.md` has the curls.
|
||||
|
||||
The sprite suspends when idle and wakes on an incoming request, so the first
|
||||
call after a quiet period pays a cold start. The app is offline-first, so
|
||||
this shows up as a slower sync rather than a failure.
|
||||
|
||||
Moving to home hardware (a NUC, an old laptop, a Pi) behind Tailscale is
|
||||
still the intended end state, and the deploy docs below describe it. Nothing
|
||||
in the app has to change to make that move: the server URL is entered on the
|
||||
setup screen, never compiled in.
|
||||
|
||||
### Running it on your own hardware
|
||||
|
||||
See [`server/README.md`](server/README.md) for the schema and provisioning
|
||||
scripts, and [`server/deploy/`](server/deploy/) for running PocketBase
|
||||
long-term (systemd or Docker), reaching it from outside your home network
|
||||
@@ -153,17 +181,18 @@ meant to be, publicly distributed.
|
||||
|
||||
Read this before assuming more polish than exists:
|
||||
|
||||
- **The app has never run on a physical device or emulator.** This
|
||||
environment has no KVM, so there is no Android emulator available.
|
||||
Everything here was verified via `./gradlew assembleDebug`,
|
||||
`testDebugUnitTest` (JVM/Robolectric unit tests), and Paparazzi screenshot
|
||||
rendering (`src/test/snapshots/images/`) — real logic paths (ISBN
|
||||
checksums, metadata merging, sync conflict resolution, DAO queries) are
|
||||
unit-tested, and every screen has been rendered to a static PNG in both
|
||||
light and dark theme, but nothing has been tap-tested on an actual screen.
|
||||
Camera/barcode scanning in particular has only been exercised through unit
|
||||
tests of the pure logic (`IsbnBarcodeAnalyzer`/`ScanCodeFilter`), never a
|
||||
live camera.
|
||||
- **No automated testing runs on a device.** The app itself has been
|
||||
installed and used on a real phone since 2026-09-09, and several rounds of
|
||||
fixes came out of that; but this environment has no KVM, so there is no
|
||||
emulator, and every *automated* check is a JVM one. Verification is
|
||||
`./gradlew assembleDebug`, `testDebugUnitTest` (JVM/Robolectric unit
|
||||
tests), and Paparazzi screenshot rendering
|
||||
(`src/test/snapshots/images/`) — real logic paths (ISBN checksums,
|
||||
metadata merging, sync conflict resolution, DAO queries) are unit-tested,
|
||||
and every screen is rendered to a static PNG in both light and dark theme.
|
||||
Camera/barcode scanning is covered only through unit tests of the pure
|
||||
logic (`IsbnBarcodeAnalyzer`/`ScanCodeFilter`); the live camera path is
|
||||
exercised by hand on a phone or not at all.
|
||||
- **Sync has been round-tripped against a real PocketBase exactly once**
|
||||
(a live-server test covering auth, push with client-generated ids, pull,
|
||||
last-write-wins, tombstones, and a byte-for-byte cover round-trip — see
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.modg.bookshelf
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -11,7 +12,10 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import org.modg.bookshelf.data.local.BookshelfDatabase
|
||||
import org.modg.bookshelf.data.metadata.BookSearchRepository
|
||||
import org.modg.bookshelf.data.repo.CoverDownloader
|
||||
import org.modg.bookshelf.data.metadata.MetadataRepository
|
||||
import org.modg.bookshelf.diagnostics.CrashReporter
|
||||
import org.modg.bookshelf.data.prefs.SettingsStore
|
||||
import org.modg.bookshelf.data.remote.ApiProvider
|
||||
import org.modg.bookshelf.data.remote.PbAuthInterceptor
|
||||
@@ -41,6 +45,9 @@ class AppContainer(private val context: Context) {
|
||||
|
||||
val settingsStore = SettingsStore(context)
|
||||
|
||||
/** Installed by [BookshelfApplication.onCreate]; read by the settings screen's Diagnostics section. */
|
||||
val crashReporter by lazy { CrashReporter(context) }
|
||||
|
||||
private val database by lazy { BookshelfDatabase.build(context) }
|
||||
|
||||
val bookRepository by lazy { BookRepository(database.bookDao(), context) }
|
||||
@@ -116,6 +123,15 @@ class AppContainer(private val context: Context) {
|
||||
|
||||
val metadataRepository by lazy { MetadataRepository(metadataHttpClient, json, BuildConfig.GOOGLE_BOOKS_API_KEY) }
|
||||
|
||||
/**
|
||||
* Cover preloads for the save sheets. [metadataHttpClient], not [okHttpClient]: covers
|
||||
* come from third-party image hosts, which must never see our PocketBase token.
|
||||
*/
|
||||
val coverDownloader by lazy { CoverDownloader(metadataHttpClient, File(context.cacheDir, "cover-preload")) }
|
||||
|
||||
/** Library screen's online title/author search — shares the same client/timeouts/key as [metadataRepository]. */
|
||||
val bookSearchRepository by lazy { BookSearchRepository(metadataHttpClient, json, BuildConfig.GOOGLE_BOOKS_API_KEY) }
|
||||
|
||||
val syncEngine by lazy {
|
||||
SyncEngine(
|
||||
apiProvider = apiProvider,
|
||||
|
||||
@@ -13,5 +13,12 @@ class BookshelfApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
appContainer = AppContainer(this)
|
||||
// See CrashReporter's KDoc: born from an on-device crash right after
|
||||
// the previous commit that could never be reproduced off-device.
|
||||
appContainer.crashReporter.install()
|
||||
// Preloaded covers from a process that died with a save sheet open. Off the main
|
||||
// thread (file listing); files this process creates are newer and never swept.
|
||||
val processStart = System.currentTimeMillis()
|
||||
Thread { appContainer.coverDownloader.sweepStale(olderThanMillis = processStart) }.start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Process-lifetime cache of Open Library author key -> author name.
|
||||
*
|
||||
* Open Library's Editions API gives authors as references (`/authors/OL26320A`),
|
||||
* so every lookup that would otherwise report an author costs an extra request.
|
||||
* The user's access pattern makes that nearly free to avoid: books by one author
|
||||
* arrive in runs, because they sit next to each other on the shelf and get scanned
|
||||
* one after another. The second Tolkien of the evening resolves from memory.
|
||||
*
|
||||
* Deliberately unbounded. Entries are a short key and a short name — a few dozen
|
||||
* bytes each — and the population is the number of distinct authors in one home
|
||||
* library, so the ceiling is hundreds of entries, not a leak. Given that, an
|
||||
* eviction policy would be more code and more risk than the thing it prevents.
|
||||
*
|
||||
* Only successful resolutions are stored. A failed author fetch is left uncached on
|
||||
* purpose: it is usually a transient network failure, and caching it would make one
|
||||
* bad moment cost that author their name for the rest of the session.
|
||||
*
|
||||
* [ConcurrentHashMap] because [MetadataRepository] runs both sources concurrently
|
||||
* and a single lookup may resolve several authors; the worst a race can do is fetch
|
||||
* the same author twice and store the same answer twice.
|
||||
*/
|
||||
class AuthorNameCache {
|
||||
|
||||
private val names = ConcurrentHashMap<String, String>()
|
||||
|
||||
/** The cached name for an author key such as `/authors/OL26320A`, or null. */
|
||||
operator fun get(authorKey: String): String? = names[authorKey]
|
||||
|
||||
/** Records a successfully resolved name. Blank names are ignored, never stored. */
|
||||
fun put(authorKey: String, name: String) {
|
||||
if (name.isNotBlank()) names[authorKey] = name
|
||||
}
|
||||
|
||||
/** Entry count — for tests asserting that a second lookup did not re-fetch. */
|
||||
val size: Int get() = names.size
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* Entry point for the library screen's online title/author search — [MetadataRepository]'s
|
||||
* counterpart for search rather than by-ISBN lookup. Unlike [MetadataRepository.lookup],
|
||||
* this does NOT combine the two sources into one outcome: the library screen needs each
|
||||
* source's own [SearchSourceResult] so it can show one source's results alongside the
|
||||
* other's failure-with-retry (task: "per-source failure ... while still showing the
|
||||
* OTHER source's results"). [org.modg.bookshelf.data.metadata.SearchResultMerger] is
|
||||
* where the actual merge/dedupe happens, once local books are available to merge in too.
|
||||
*/
|
||||
class BookSearchRepository(
|
||||
private val openLibraryClient: OpenLibrarySearchClient,
|
||||
private val googleBooksClient: GoogleBooksSearchClient,
|
||||
) {
|
||||
constructor(httpClient: OkHttpClient, json: Json, googleBooksApiKey: String = "") : this(
|
||||
OpenLibrarySearchClient(httpClient, json),
|
||||
GoogleBooksSearchClient(httpClient, json, googleBooksApiKey),
|
||||
)
|
||||
|
||||
data class Results(val openLibrary: SearchSourceResult, val googleBooks: SearchSourceResult)
|
||||
|
||||
suspend fun search(query: String): Results = coroutineScope {
|
||||
val openLibrary = async { openLibraryClient.search(query) }
|
||||
val googleBooks = async { googleBooksClient.search(query) }
|
||||
Results(openLibrary.await(), googleBooks.await())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
@@ -17,8 +18,17 @@ import okhttp3.Request
|
||||
* key (the default) sends the request keyless, unchanged from before — this
|
||||
* matters for a fresh clone with no key configured, which must still build a
|
||||
* working app rather than a broken one.
|
||||
* Never throws: every outcome, including transport failure, comes back as a
|
||||
* [SourceResult] rather than a swallowed null.
|
||||
*
|
||||
* Genuinely never throws (verified 2026-09-12): every outcome, including
|
||||
* transport failure, comes back as a [SourceResult] rather than a swallowed
|
||||
* null or an exception reaching the caller. That claim used to be true only
|
||||
* for [IOException] — the first barcode scanned on a real phone right after
|
||||
* the Google Books key landed (commit 486f6eb) crashed the process a few
|
||||
* seconds into the lookup, and this path had never once executed in
|
||||
* production before that commit (a keyless Google Books call was a
|
||||
* guaranteed 429). Nobody knows what actually threw; [fetch]'s
|
||||
* [FailureKind.UNEXPECTED] arm exists so the next one, whatever it is,
|
||||
* becomes a shown-and-retryable-by-the-user failure instead of a crash.
|
||||
*/
|
||||
class GoogleBooksClient(
|
||||
private val httpClient: OkHttpClient,
|
||||
@@ -48,8 +58,20 @@ class GoogleBooksClient(
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
classify(response.code, response.body.string(), response.header("Retry-After"))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// Cancellation is normal control flow (the user dismissed the sheet
|
||||
// or left the screen), not a lookup failure — it must propagate,
|
||||
// never be reported as a Failed result. Checked first so the
|
||||
// Throwable arm below can never swallow it.
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
SourceResult.fromException(e)
|
||||
} catch (e: Throwable) {
|
||||
// Whatever this is, [fetch] wasn't written to expect it — see the
|
||||
// class KDoc on the 2026-09-12 crash this exists for. The message
|
||||
// is deliberately omitted: some exceptions embed the request URL
|
||||
// (and therefore the API key), same reasoning as [redact] below.
|
||||
SourceResult.Failed("unexpected: ${e.javaClass.simpleName}", FailureKind.UNEXPECTED)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -108,9 +130,5 @@ class GoogleBooksClient(
|
||||
* will. Package-visible so the scrub itself is unit-testable without a socket.
|
||||
*/
|
||||
internal fun redact(result: SourceResult): SourceResult =
|
||||
if (result is SourceResult.Failed && apiKey.isNotBlank() && result.reason.contains(apiKey)) {
|
||||
result.copy(reason = result.reason.replace(apiKey, "[REDACTED]"))
|
||||
} else {
|
||||
result
|
||||
}
|
||||
if (result is SourceResult.Failed) result.copy(reason = redactApiKey(result.reason, apiKey)) else result
|
||||
}
|
||||
@@ -38,7 +38,7 @@ data class GoogleBooksIndustryIdentifierDto(
|
||||
val identifier: String? = null,
|
||||
)
|
||||
|
||||
/** Maps the GB DTO to the source-agnostic [BookMetadata], forcing https + zoom=2 on the cover per SPEC. */
|
||||
/** Maps the GB DTO to the source-agnostic [BookMetadata], normalizing the cover per [normalizeCoverUrl]. */
|
||||
fun GoogleBooksVolumeInfoDto.toBookMetadata(): BookMetadata = BookMetadata(
|
||||
isbn13 = industryIdentifiers.firstOrNull { it.type == "ISBN_13" }?.identifier,
|
||||
isbn10 = industryIdentifiers.firstOrNull { it.type == "ISBN_10" }?.identifier,
|
||||
@@ -52,8 +52,81 @@ fun GoogleBooksVolumeInfoDto.toBookMetadata(): BookMetadata = BookMetadata(
|
||||
coverUrl = normalizeCoverUrl(imageLinks?.thumbnail ?: imageLinks?.smallThumbnail),
|
||||
)
|
||||
|
||||
private fun normalizeCoverUrl(raw: String?): String? {
|
||||
/**
|
||||
* Forces https, pins `zoom=1`, requests a wider rendition with `w`, and drops
|
||||
* `edge=curl`. Internal (not private) so [GoogleBooksSearchClient] reuses it too.
|
||||
*
|
||||
* **Do not restore `zoom=2`.** SPEC.md said to force it and that was wrong: for any
|
||||
* volume Google holds no full preview of — the metadata-only `...AAAACAAJ` records,
|
||||
* which is most small-press and older material — `zoom=2` is not a valid rendition,
|
||||
* and Google does not 404. It answers **200 with a placeholder image**: grey italic
|
||||
* "image not available" on white. Coil loads it successfully, so nothing downstream
|
||||
* can tell it from a cover, and [org.modg.bookshelf.ui.components.BookCover]'s own
|
||||
* placeholder never gets a chance to fire. Worse, the cover pipeline then uploads it
|
||||
* to PocketBase as the book's cover, where it sticks.
|
||||
*
|
||||
* Measured 2026-09-20 over 18 volumes with imageLinks: `zoom=2` returned that
|
||||
* placeholder for **11 of 18** (byte-identical, 15,567 bytes), while `zoom=1&w=400`
|
||||
* returned 18 distinct real covers. `zoom=0`, `3` and `6` are placeholders too —
|
||||
* the same artwork at 575x750 and 1280x1670 — so there is no "just use a bigger
|
||||
* zoom" fix, and detecting the placeholder by hash would mean chasing renditions.
|
||||
* Asking for a URL Google will actually serve is the fix.
|
||||
*
|
||||
* `w` is what buys resolution instead: Google honours it up to the source scan's
|
||||
* native width, so a volume with real art yields 400px and one with only a small
|
||||
* scan yields its own 300px rather than a placeholder. `edge=curl` draws a fake
|
||||
* page-curl onto the right edge of the image, which fights a design language whose
|
||||
* covers are meant to be the hero.
|
||||
*/
|
||||
internal fun normalizeCoverUrl(raw: String?): String? {
|
||||
if (raw.isNullOrBlank()) return null
|
||||
val https = raw.replaceFirst("http://", "https://")
|
||||
return if ("zoom=" in https) https.replace(Regex("zoom=\\d+"), "zoom=2") else "$https&zoom=2"
|
||||
val https = raw.replaceFirst("http://", "https://").replace("&edge=curl", "")
|
||||
val zoomed = if ("zoom=" in https) https.replace(Regex("zoom=\\d+"), "zoom=1") else "$https&zoom=1"
|
||||
// Anchored to a parameter boundary so a URL that merely contains "w=" inside
|
||||
// another parameter's value doesn't suppress the width we need.
|
||||
return if (Regex("[?&]w=").containsMatchIn(zoomed)) zoomed else "$zoomed&w=$GoogleBooksCoverWidth"
|
||||
}
|
||||
|
||||
/**
|
||||
* Requested cover width in pixels. 400 comfortably exceeds the widest slot the app
|
||||
* draws (a detail-screen cover on a large phone) without asking for a rendition
|
||||
* Google has to synthesize.
|
||||
*/
|
||||
internal const val GoogleBooksCoverWidth = 400
|
||||
|
||||
/**
|
||||
* Maps one volume to a [SearchHit] for the library search screen. [isbn13s] collects
|
||||
* every industry identifier this volume reports, normalized to ISBN-13 regardless of
|
||||
* its declared type (ISBN-10 converted) — a volume usually carries 0 or 1, unlike an
|
||||
* Open Library work's dozens, which is exactly what [SearchResultMerger]'s ISBN rule
|
||||
* depends on to tell the two sources apart.
|
||||
*/
|
||||
fun GoogleBooksVolumeInfoDto.toSearchHit(): SearchHit = SearchHit(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
authors = authors,
|
||||
year = plausibleYear(publishedDate),
|
||||
publisher = publisher,
|
||||
pageCount = pageCount,
|
||||
coverUrl = normalizeCoverUrl(imageLinks?.thumbnail ?: imageLinks?.smallThumbnail),
|
||||
isbn13s = industryIdentifiers.mapNotNull { it.identifier }.mapNotNull(IsbnUtils::toIsbn13).distinct(),
|
||||
)
|
||||
|
||||
/**
|
||||
* A [publishedDate] prefix is only trustworthy as a year if it's exactly four digits —
|
||||
* real GB data has been observed to return garbage like "101-01-01" (METADATA-SOURCES.md),
|
||||
* which this rejects because its first four characters ("101-") aren't all digits.
|
||||
*/
|
||||
internal fun plausibleYear(publishedDate: String?): Int? {
|
||||
val prefix = publishedDate?.take(4) ?: return null
|
||||
if (prefix.length != 4 || !prefix.all(Char::isDigit)) return null
|
||||
return prefix.toIntOrNull()?.takeIf { it in 1000..2999 }
|
||||
}
|
||||
|
||||
/**
|
||||
* [SourceResult.Failed.reason]/[SearchSourceResult.Failed.reason] must never carry the
|
||||
* API key — shared by [GoogleBooksClient.redact] and [GoogleBooksSearchClient.redact]
|
||||
* so there is one scrub implementation, not two that could drift apart.
|
||||
*/
|
||||
internal fun redactApiKey(reason: String, apiKey: String): String =
|
||||
if (apiKey.isNotBlank() && reason.contains(apiKey)) reason.replace(apiKey, "[REDACTED]") else reason
|
||||
@@ -0,0 +1,88 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
/**
|
||||
* Google Books title/author search — `volumes?q=`, distinct from [GoogleBooksClient]'s
|
||||
* by-ISBN lookup but sharing its DTOs ([GoogleBooksResponseDto]/[GoogleBooksVolumeInfoDto]):
|
||||
* a search result and an ISBN lookup result are both a list of volumes, just a different
|
||||
* query. Same key handling, same redaction, same never-throws contract — see
|
||||
* [GoogleBooksClient]'s KDoc.
|
||||
*/
|
||||
class GoogleBooksSearchClient(
|
||||
private val httpClient: OkHttpClient,
|
||||
json: Json,
|
||||
private val apiKey: String = "",
|
||||
) {
|
||||
private val json = Json(from = json) { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun search(query: String): SearchSourceResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
withSearchRetry(retryRateLimitedOnce = apiKey.isNotBlank()) { fetch(query) }
|
||||
}
|
||||
|
||||
/** Single un-retried attempt, for tests that need to count calls. */
|
||||
internal suspend fun searchOnce(query: String): SearchSourceResult =
|
||||
withContext(Dispatchers.IO) { fetch(query) }
|
||||
|
||||
private fun fetch(query: String): SearchSourceResult = redact(
|
||||
try {
|
||||
val request = Request.Builder().url(requestUrl(query)).build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
classify(response.code, response.body.string(), response.header("Retry-After"))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
SearchSourceResult.fromException(e)
|
||||
} catch (e: Throwable) {
|
||||
SearchSourceResult.Failed("unexpected: ${e.javaClass.simpleName}", FailureKind.UNEXPECTED)
|
||||
},
|
||||
)
|
||||
|
||||
/** Package-visible pure function — no socket — the key is appended only when non-blank, same as the ISBN lookup. */
|
||||
internal fun requestUrl(query: String): String {
|
||||
val builder = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("www.googleapis.com")
|
||||
.addPathSegments("books/v1/volumes")
|
||||
.addQueryParameter("q", query)
|
||||
.addQueryParameter("maxResults", "20")
|
||||
.addQueryParameter("printType", "books")
|
||||
if (apiKey.isNotBlank()) {
|
||||
builder.addQueryParameter("key", apiKey)
|
||||
}
|
||||
return builder.build().toString()
|
||||
}
|
||||
|
||||
/** Package-visible pure function — no socket — so it's exhaustively unit-testable offline. */
|
||||
internal fun classify(httpCode: Int, body: String?, retryAfterHeader: String? = null): SearchSourceResult {
|
||||
if (httpCode !in 200..299) return SearchSourceResult.fromHttpCode(httpCode, retryAfterHeader)
|
||||
if (body.isNullOrBlank()) return SearchSourceResult.Failed("empty body", FailureKind.MALFORMED)
|
||||
return try {
|
||||
val dto = json.decodeFromString(GoogleBooksResponseDto.serializer(), body)
|
||||
val hits = dto.items.mapNotNull { it.volumeInfo?.toSearchHit() }
|
||||
if (hits.isEmpty()) SearchSourceResult.NotFound else SearchSourceResult.Found(hits)
|
||||
} catch (e: SerializationException) {
|
||||
SearchSourceResult.Failed("malformed json", FailureKind.MALFORMED)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
SearchSourceResult.Failed("malformed json", FailureKind.MALFORMED)
|
||||
}
|
||||
}
|
||||
|
||||
/** Package-visible for offline fixture tests — parses a raw response body with no network involved. */
|
||||
internal fun parseResponse(body: String): List<SearchHit> =
|
||||
(classify(200, body) as? SearchSourceResult.Found)?.hits.orEmpty()
|
||||
|
||||
/** Same reasoning as [GoogleBooksClient.redact] — the key must never survive into a shown/logged reason. */
|
||||
internal fun redact(result: SearchSourceResult): SearchSourceResult =
|
||||
if (result is SearchSourceResult.Failed) result.copy(reason = redactApiKey(result.reason, apiKey)) else result
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
/** Which source(s) contributed to a merged [OnlineBook] — surfaced so the UI/enrichment can tell. */
|
||||
enum class SearchSource { OPEN_LIBRARY, GOOGLE_BOOKS }
|
||||
|
||||
/**
|
||||
* One deduplicated online search result, after [SearchResultMerger] has grouped
|
||||
* together every [SearchHit] (and any matching local [org.modg.bookshelf.data.local.BookEntity])
|
||||
* that identify the same book. [isbn13] is set only when [SearchResultMerger]'s ISBN
|
||||
* rule could resolve a single unambiguous edition — see its KDoc; otherwise null,
|
||||
* never guessed.
|
||||
*/
|
||||
data class OnlineBook(
|
||||
val title: String?,
|
||||
val subtitle: String? = null,
|
||||
val authors: List<String> = emptyList(),
|
||||
val year: Int? = null,
|
||||
val publisher: String? = null,
|
||||
val pageCount: Int? = null,
|
||||
val coverUrl: String? = null,
|
||||
val isbn13: String? = null,
|
||||
val sources: Set<SearchSource> = emptySet(),
|
||||
)
|
||||
@@ -1,23 +1,36 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
/**
|
||||
* Open Library lookup — SPEC.md "Book metadata lookup" primary source.
|
||||
* Never throws: every outcome, including transport failure, comes back as a
|
||||
* [SourceResult] rather than a swallowed null.
|
||||
*
|
||||
* Uses the **Editions API** (`/isbn/{isbn}.json`). The previous endpoint,
|
||||
* `/api/books?bibkeys=...&jscmd=data`, began answering 404 to everything; see
|
||||
* [OpenLibraryEditionDto]'s KDoc and docs/METADATA-SOURCES.md for the evidence that
|
||||
* this was a retirement rather than an outage.
|
||||
*
|
||||
* The move changed one thing that matters beyond the URL: **404 now means the book
|
||||
* genuinely is not here.** The legacy endpoint reported a miss as 200 with an empty
|
||||
* object, so every non-2xx was rightly a [SourceResult.Failed]. This endpoint has no
|
||||
* such body to return and 404s instead, so a 404 is an authoritative
|
||||
* [SourceResult.NotFound] — see [classifyEdition], which is the single place that
|
||||
* distinction lives.
|
||||
*
|
||||
* Genuinely never throws: every outcome, including transport failure, comes back as
|
||||
* a [SourceResult] rather than a swallowed null or an exception reaching the caller.
|
||||
*/
|
||||
class OpenLibraryClient(
|
||||
private val httpClient: OkHttpClient,
|
||||
json: Json,
|
||||
private val authorNames: AuthorNameCache = AuthorNameCache(),
|
||||
) {
|
||||
// Real responses carry fields this DTO doesn't model; never let an unknown key throw.
|
||||
private val json = Json(from = json) { ignoreUnknownKeys = true }
|
||||
@@ -26,6 +39,11 @@ class OpenLibraryClient(
|
||||
* Retries transient failures per [RetryPolicy]. Measured against the live API,
|
||||
* 13% of requests fail at the TLS stage in well under a second while successful
|
||||
* ones take seconds — so a retry is nearly free and removes most of that 13%.
|
||||
*
|
||||
* Only the **edition** request is retried. Author and work requests happen after
|
||||
* it has already succeeded and are best-effort by design (see [enrich]), so a
|
||||
* retry can never re-run them: a failure there degrades the record, it does not
|
||||
* fail the lookup.
|
||||
*/
|
||||
suspend fun lookup(isbn13: String): SourceResult =
|
||||
withContext(Dispatchers.IO) { withRetry { fetch(isbn13) } }
|
||||
@@ -35,37 +53,136 @@ class OpenLibraryClient(
|
||||
withContext(Dispatchers.IO) { fetch(isbn13) }
|
||||
|
||||
private fun fetch(isbn13: String): SourceResult = try {
|
||||
val request = Request.Builder()
|
||||
.url("https://openlibrary.org/api/books?bibkeys=ISBN:$isbn13&format=json&jscmd=data")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
classify(response.code, response.body.string(), isbn13)
|
||||
val (code, body) = get("https://openlibrary.org/isbn/$isbn13.json")
|
||||
when (val outcome = classifyEdition(code, body)) {
|
||||
is EditionOutcome.Terminal -> outcome.result
|
||||
is EditionOutcome.Parsed -> SourceResult.Found(enrich(outcome.dto, isbn13))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// See GoogleBooksClient.fetch's identical arm: cancellation is normal
|
||||
// control flow and must propagate, not become a Failed result.
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
SourceResult.fromException(e)
|
||||
} catch (e: Throwable) {
|
||||
// See GoogleBooksClient.fetch and the class KDoc — the 2026-09-12
|
||||
// on-device crash this arm exists for. No key to leak here, but the
|
||||
// message is still omitted for consistency with that reasoning.
|
||||
SourceResult.Failed("unexpected: ${e.javaClass.simpleName}", FailureKind.UNEXPECTED)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Turns a parsed edition into a complete [BookMetadata], resolving author
|
||||
* references to names.
|
||||
*
|
||||
* Best-effort throughout: the edition request already succeeded, so a failure
|
||||
* here must degrade the record rather than turn a found book into a failed
|
||||
* lookup. An author we cannot name is simply omitted. The one thing that is NOT
|
||||
* best-effort is cancellation, which propagates — the user dismissing the scan
|
||||
* sheet mid-lookup is normal control flow.
|
||||
*/
|
||||
internal fun classify(httpCode: Int, body: String?, isbn13: String): SourceResult {
|
||||
if (httpCode !in 200..299) return SourceResult.fromHttpCode(httpCode)
|
||||
if (body.isNullOrBlank()) return SourceResult.Failed("empty body", FailureKind.MALFORMED)
|
||||
private fun enrich(dto: OpenLibraryEditionDto, isbn13: String): BookMetadata {
|
||||
val workKey = dto.works.firstOrNull()?.key?.takeIf { it.isNotBlank() }
|
||||
// Fetched only when the edition itself names no authors, so the common case
|
||||
// costs nothing; when we do fetch it, its description is free to take.
|
||||
val work = if (dto.authors.isEmpty() && workKey != null) fetchWork(workKey) else null
|
||||
val authorKeys = authorKeysFor(dto, work)
|
||||
return dto.toBookMetadata(
|
||||
lookupIsbn13 = isbn13,
|
||||
authors = authorKeys.mapNotNull { authorName(it) },
|
||||
description = work?.description.text(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Author references for an edition: its own if it has any, otherwise the work's.
|
||||
*
|
||||
* The fallback is not hypothetical. ISBN 9780898707168 — *The Harp and Laurel
|
||||
* Wreath*, on the user's shelf — has no `authors` on its edition record at all,
|
||||
* only on its work. The legacy endpoint resolved that for us and returned
|
||||
* "Laura M. Berquist"; without this fallback the move to the Editions API would
|
||||
* have silently dropped the author for books like it.
|
||||
*/
|
||||
internal fun authorKeysFor(dto: OpenLibraryEditionDto, work: OpenLibraryWorkDto?): List<String> {
|
||||
val editionKeys = dto.authors.mapNotNull { it.key?.takeIf(String::isNotBlank) }
|
||||
if (editionKeys.isNotEmpty()) return editionKeys
|
||||
return work?.authors?.mapNotNull { it.author?.key?.takeIf(String::isNotBlank) }.orEmpty()
|
||||
}
|
||||
|
||||
/** Resolves one author reference to a name, via [AuthorNameCache]. Null if it can't. */
|
||||
private fun authorName(authorKey: String): String? {
|
||||
authorNames[authorKey]?.let { return it }
|
||||
val dto = getAndDecode<OpenLibraryAuthorDto>("https://openlibrary.org$authorKey.json")
|
||||
val name = dto?.name?.takeIf { it.isNotBlank() } ?: return null
|
||||
authorNames.put(authorKey, name)
|
||||
return name
|
||||
}
|
||||
|
||||
private fun fetchWork(workKey: String): OpenLibraryWorkDto? =
|
||||
getAndDecode("https://openlibrary.org$workKey.json")
|
||||
|
||||
/**
|
||||
* A supporting GET whose failure is not the lookup's failure. Swallows everything
|
||||
* except cancellation, which is normal control flow and must propagate.
|
||||
*/
|
||||
private inline fun <reified T> getAndDecode(url: String): T? = try {
|
||||
val (code, body) = get(url)
|
||||
if (code in 200..299 && !body.isNullOrBlank()) json.decodeFromString<T>(body) else null
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun get(url: String): Pair<Int, String?> =
|
||||
httpClient.newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
response.code to response.body.string()
|
||||
}
|
||||
|
||||
/**
|
||||
* Package-visible pure function — no socket involved — so it's exhaustively
|
||||
* unit-testable offline (2xx-with-record, 404, 429, 500, malformed body).
|
||||
*
|
||||
* **404 is [SourceResult.NotFound], not a failure.** On this endpoint a 404 is
|
||||
* Open Library answering authoritatively that it holds no edition with that
|
||||
* ISBN, which is exactly what SPEC.md's NotFound means; its body is an HTML
|
||||
* error page, so nothing tries to parse it. Every other non-2xx remains a
|
||||
* failure, because a source we could not reach must never be reported to the
|
||||
* user as a book that does not exist.
|
||||
*/
|
||||
internal fun classifyEdition(httpCode: Int, body: String?): EditionOutcome {
|
||||
if (httpCode == 404) return EditionOutcome.Terminal(SourceResult.NotFound)
|
||||
if (httpCode !in 200..299) return EditionOutcome.Terminal(SourceResult.fromHttpCode(httpCode))
|
||||
if (body.isNullOrBlank()) {
|
||||
return EditionOutcome.Terminal(SourceResult.Failed("empty body", FailureKind.MALFORMED))
|
||||
}
|
||||
return try {
|
||||
val root = json.parseToJsonElement(body).jsonObject
|
||||
val entry = root["ISBN:$isbn13"]?.jsonObject ?: return SourceResult.NotFound
|
||||
SourceResult.Found(json.decodeFromJsonElement<OpenLibraryBookDto>(entry).toBookMetadata(isbn13))
|
||||
EditionOutcome.Parsed(json.decodeFromString<OpenLibraryEditionDto>(body))
|
||||
} catch (e: SerializationException) {
|
||||
SourceResult.Failed("malformed json", FailureKind.MALFORMED)
|
||||
EditionOutcome.Terminal(SourceResult.Failed("malformed json", FailureKind.MALFORMED))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
SourceResult.Failed("malformed json", FailureKind.MALFORMED)
|
||||
EditionOutcome.Terminal(SourceResult.Failed("malformed json", FailureKind.MALFORMED))
|
||||
}
|
||||
}
|
||||
|
||||
/** Package-visible for offline fixture tests — parses a raw response body with no network involved. */
|
||||
/**
|
||||
* Status + body -> a [SourceResult], for tests covering the status mapping alone.
|
||||
* Authors stay unresolved here because resolving them needs the network; the full
|
||||
* path including author resolution is covered by the fixture-serving tests.
|
||||
*/
|
||||
internal fun classify(httpCode: Int, body: String?, isbn13: String): SourceResult =
|
||||
when (val outcome = classifyEdition(httpCode, body)) {
|
||||
is EditionOutcome.Terminal -> outcome.result
|
||||
is EditionOutcome.Parsed -> SourceResult.Found(outcome.dto.toBookMetadata(isbn13))
|
||||
}
|
||||
|
||||
/** Package-visible for offline fixture tests — parses a raw edition body, no network. */
|
||||
internal fun parseResponse(body: String, isbn13: String): BookMetadata? =
|
||||
(classify(200, body, isbn13) as? SourceResult.Found)?.metadata
|
||||
|
||||
/** What a status + body amounted to: a record to enrich, or a final answer. */
|
||||
internal sealed interface EditionOutcome {
|
||||
data class Parsed(val dto: OpenLibraryEditionDto) : EditionOutcome
|
||||
data class Terminal(val result: SourceResult) : EditionOutcome
|
||||
}
|
||||
}
|
||||
@@ -2,67 +2,133 @@ package org.modg.bookshelf.data.metadata
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* https://openlibrary.org/api/books?bibkeys=ISBN:{isbn}&format=json&jscmd=data
|
||||
* Response is a JSON object keyed by "ISBN:{isbn}"; this DTO models one value.
|
||||
* Real responses carry many more fields (excerpts, ebooks, key, url, ...) that
|
||||
* we don't need — the caller's Json must have ignoreUnknownKeys = true.
|
||||
* https://openlibrary.org/isbn/{isbn}.json — Open Library's **Editions API**
|
||||
* (documented under "Work & Edition APIs"), which answers 302 -> 200 with a single
|
||||
* edition record.
|
||||
*
|
||||
* Replaced `/api/books?bibkeys=...&jscmd=data` on 2026-09-20. That endpoint had
|
||||
* started answering **404 to every bibkey form, for every ISBN**, including books
|
||||
* Open Library demonstrably still holds, while every other OL endpoint served
|
||||
* normally. Its own documentation calls it the "Legacy Books API" and says "this is
|
||||
* a legacy endpoint and may be phased out in the future", and it is absent from
|
||||
* OL's API index — so this reads as a retirement, not an outage, and waiting for it
|
||||
* to come back was not a plan. See docs/METADATA-SOURCES.md § "The Open Library
|
||||
* endpoint move".
|
||||
*
|
||||
* Real responses carry far more fields than this (classifications, source_records,
|
||||
* lc_classifications, ...); the caller's Json must have ignoreUnknownKeys = true.
|
||||
*/
|
||||
@Serializable
|
||||
data class OpenLibraryBookDto(
|
||||
data class OpenLibraryEditionDto(
|
||||
val title: String? = null,
|
||||
val subtitle: String? = null,
|
||||
val authors: List<OpenLibraryAuthorDto> = emptyList(),
|
||||
val publishers: List<OpenLibraryPublisherDto> = emptyList(),
|
||||
/**
|
||||
* Edition authors are **references, not names** — `[{"key": "/authors/OL26320A"}]`.
|
||||
* Resolving them costs a request each, which is what [AuthorNameCache] exists to
|
||||
* amortize. This list is also legitimately absent on real records (verified: ISBN
|
||||
* 9780898707168, a book on the user's own shelf), in which case the authors live
|
||||
* on the work — see [OpenLibraryWorkDto] and [OpenLibraryClient.authorKeysFor].
|
||||
*/
|
||||
val authors: List<OpenLibraryKeyRefDto> = emptyList(),
|
||||
/** Plain strings on this endpoint, unlike the legacy API's `[{"name": ...}]` objects. */
|
||||
val publishers: List<String> = emptyList(),
|
||||
val works: List<OpenLibraryKeyRefDto> = emptyList(),
|
||||
@SerialName("publish_date") val publishDate: String? = null,
|
||||
@SerialName("number_of_pages") val numberOfPages: Int? = null,
|
||||
val identifiers: OpenLibraryIdentifiersDto? = null,
|
||||
val cover: OpenLibraryCoverDto? = null,
|
||||
@SerialName("isbn_10") val isbn10: List<String> = emptyList(),
|
||||
@SerialName("isbn_13") val isbn13: List<String> = emptyList(),
|
||||
/**
|
||||
* Cover IDs for the Covers API. **May contain -1**, Open Library's "no cover here"
|
||||
* sentinel (verified: 9780898707168 -> `[698345, -1]`, 9780140328721 ->
|
||||
* `[15152634, 8739161, -1]`). Taking `.first()` blindly would eventually build
|
||||
* `covers.openlibrary.org/b/id/-1-L.jpg`; [coverUrl] filters to positive ids.
|
||||
*/
|
||||
val covers: List<Int> = emptyList(),
|
||||
/** String on some records, `{"type": ..., "value": ...}` on others — see [text]. */
|
||||
val description: JsonElement? = null,
|
||||
)
|
||||
|
||||
/** `{"key": "/authors/OL26320A"}` / `{"key": "/works/OL8997784W"}`. */
|
||||
@Serializable
|
||||
data class OpenLibraryKeyRefDto(val key: String? = null)
|
||||
|
||||
/**
|
||||
* https://openlibrary.org/authors/{id}.json — fetched only to turn an author
|
||||
* reference into a name. Tiny record; [AuthorNameCache] keeps each one for the
|
||||
* process lifetime.
|
||||
*/
|
||||
@Serializable
|
||||
data class OpenLibraryAuthorDto(val name: String? = null)
|
||||
|
||||
@Serializable
|
||||
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.
|
||||
* https://openlibrary.org/works/{id}.json — fetched only when the edition record
|
||||
* carries no authors of its own. Work authors nest one level deeper than edition
|
||||
* authors: `[{"author": {"key": "/authors/OL335577A"}}]`.
|
||||
*/
|
||||
@Serializable
|
||||
data class OpenLibraryCoverDto(
|
||||
val small: String? = null,
|
||||
val medium: String? = null,
|
||||
val large: String? = null,
|
||||
data class OpenLibraryWorkDto(
|
||||
val authors: List<OpenLibraryWorkAuthorDto> = emptyList(),
|
||||
val description: JsonElement? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OpenLibraryIdentifiersDto(
|
||||
@SerialName("isbn_10") val isbn10: List<String> = emptyList(),
|
||||
@SerialName("isbn_13") val isbn13: List<String> = emptyList(),
|
||||
)
|
||||
data class OpenLibraryWorkAuthorDto(val author: OpenLibraryKeyRefDto? = null)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Open Library writes a description either as a bare string or as a typed object
|
||||
* `{"type": "/type/text", "value": "..."}`, and both shapes occur in the wild — of
|
||||
* 15 real editions sampled on 2026-09-20, 1 was a string, 5 were objects and 9 had
|
||||
* no description at all. Modelling it as [JsonElement] and unwrapping here is
|
||||
* cheaper than a custom serializer and cannot throw on a third shape.
|
||||
*/
|
||||
fun OpenLibraryBookDto.toBookMetadata(lookupIsbn13: String): BookMetadata = BookMetadata(
|
||||
isbn13 = identifiers?.isbn13?.firstOrNull() ?: lookupIsbn13,
|
||||
isbn10 = identifiers?.isbn10?.firstOrNull(),
|
||||
internal fun JsonElement?.text(): String? = when (this) {
|
||||
null -> null
|
||||
is JsonPrimitive -> contentOrNullIfNotString()
|
||||
is JsonObject -> this["value"]?.jsonPrimitive?.contentOrNullIfNotString()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonPrimitive.contentOrNullIfNotString(): String? =
|
||||
if (isString) content.takeIf { it.isNotBlank() } else null
|
||||
|
||||
/**
|
||||
* The cover URL, or null when Open Library reports no art for this edition.
|
||||
*
|
||||
* Built from a cover **ID**, which is real evidence that art exists. The by-ISBN
|
||||
* form (`/b/isbn/{isbn}-L.jpg`) is NOT evidence: for an edition with no art it
|
||||
* answers 200 with a 43-byte 1x1 transparent GIF (verified 2026-09-09) that any
|
||||
* image loader reports as a successful load and then paints as nothing at all.
|
||||
* SPEC.md's cover chain keeps that URL only as a last resort, with `?default=false`
|
||||
* so a miss becomes a 404 — see [MetadataRepository.byIsbnCoverUrl].
|
||||
*/
|
||||
internal fun OpenLibraryEditionDto.coverUrl(): String? =
|
||||
covers.firstOrNull { it > 0 }?.let { "https://covers.openlibrary.org/b/id/$it-L.jpg" }
|
||||
|
||||
/**
|
||||
* Maps the edition record to the source-agnostic [BookMetadata]. [authors] is passed
|
||||
* in already resolved because resolving it needs the network and this function must
|
||||
* stay pure; [description] likewise may come from the work when the edition has none
|
||||
* and [OpenLibraryClient] fetched the work anyway.
|
||||
*/
|
||||
fun OpenLibraryEditionDto.toBookMetadata(
|
||||
lookupIsbn13: String,
|
||||
authors: List<String> = emptyList(),
|
||||
description: String? = null,
|
||||
): BookMetadata = BookMetadata(
|
||||
isbn13 = isbn13.firstOrNull() ?: lookupIsbn13,
|
||||
isbn10 = isbn10.firstOrNull(),
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
authors = authors.mapNotNull { it.name },
|
||||
publisher = publishers.firstOrNull()?.name,
|
||||
authors = authors,
|
||||
publisher = publishers.firstOrNull()?.takeIf { it.isNotBlank() },
|
||||
publishedDate = publishDate,
|
||||
pageCount = numberOfPages,
|
||||
description = null,
|
||||
coverUrl = cover?.large?.takeIf { it.isNotBlank() } ?: cover?.medium?.takeIf { it.isNotBlank() },
|
||||
description = this.description.text() ?: description,
|
||||
coverUrl = coverUrl(),
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
/**
|
||||
* Open Library title/author search — `search.json`, distinct from [OpenLibraryClient]'s
|
||||
* by-ISBN `api/books` lookup. Same never-throws contract as [OpenLibraryClient] (see its
|
||||
* KDoc): every outcome is a [SearchSourceResult], [CancellationException] propagates,
|
||||
* anything else becomes [FailureKind.UNEXPECTED].
|
||||
*/
|
||||
class OpenLibrarySearchClient(
|
||||
private val httpClient: OkHttpClient,
|
||||
json: Json,
|
||||
) {
|
||||
private val json = Json(from = json) { ignoreUnknownKeys = true }
|
||||
|
||||
/** Retries transient failures per [RetryPolicy] — same 13% TLS-reset story as the ISBN lookup. */
|
||||
suspend fun search(query: String): SearchSourceResult =
|
||||
withContext(Dispatchers.IO) { withSearchRetry { fetch(query) } }
|
||||
|
||||
/** Single un-retried attempt, for tests that need to count calls. */
|
||||
internal suspend fun searchOnce(query: String): SearchSourceResult =
|
||||
withContext(Dispatchers.IO) { fetch(query) }
|
||||
|
||||
private fun fetch(query: String): SearchSourceResult = try {
|
||||
val request = Request.Builder().url(requestUrl(query)).build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
classify(response.code, response.body.string())
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
SearchSourceResult.fromException(e)
|
||||
} catch (e: Throwable) {
|
||||
SearchSourceResult.Failed("unexpected: ${e.javaClass.simpleName}", FailureKind.UNEXPECTED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Package-visible pure function — no socket — for offline URL tests. `fields=`
|
||||
* is load-bearing: without it `isbn` is absent from every result (verified live,
|
||||
* see docs/METADATA-SOURCES.md). Built with [HttpUrl.Builder] so [query] is
|
||||
* correctly percent-encoded regardless of what punctuation it contains.
|
||||
*/
|
||||
internal fun requestUrl(query: String): String =
|
||||
HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("openlibrary.org")
|
||||
.addPathSegment("search.json")
|
||||
.addQueryParameter("q", query)
|
||||
.addQueryParameter("limit", "20")
|
||||
.addQueryParameter(
|
||||
"fields",
|
||||
"key,title,subtitle,author_name,first_publish_year,edition_count,cover_i,isbn,publisher,number_of_pages_median",
|
||||
)
|
||||
.build()
|
||||
.toString()
|
||||
|
||||
/**
|
||||
* Package-visible pure function — no socket — so it's exhaustively unit-testable
|
||||
* offline. An empty `docs` list is [SearchSourceResult.NotFound] (OL answered
|
||||
* authoritatively), never conflated with a transport [SearchSourceResult.Failed].
|
||||
*/
|
||||
internal fun classify(httpCode: Int, body: String?): SearchSourceResult {
|
||||
if (httpCode !in 200..299) return SearchSourceResult.fromHttpCode(httpCode)
|
||||
if (body.isNullOrBlank()) return SearchSourceResult.Failed("empty body", FailureKind.MALFORMED)
|
||||
return try {
|
||||
val dto = json.decodeFromString(OpenLibrarySearchResponseDto.serializer(), body)
|
||||
if (dto.docs.isEmpty()) SearchSourceResult.NotFound else SearchSourceResult.Found(dto.docs.map { it.toSearchHit() })
|
||||
} catch (e: SerializationException) {
|
||||
SearchSourceResult.Failed("malformed json", FailureKind.MALFORMED)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
SearchSourceResult.Failed("malformed json", FailureKind.MALFORMED)
|
||||
}
|
||||
}
|
||||
|
||||
/** Package-visible for offline fixture tests — parses a raw response body with no network involved. */
|
||||
internal fun parseResponse(body: String): List<SearchHit> =
|
||||
(classify(200, body) as? SearchSourceResult.Found)?.hits.orEmpty()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* https://openlibrary.org/search.json?q=<q>&limit=20&fields=... — a WORKS index
|
||||
* search, distinct from [OpenLibraryBookDto]'s by-ISBN edition lookup. `isbn` is
|
||||
* NOT in OL's default response fields; the caller must request it explicitly (see
|
||||
* [OpenLibrarySearchClient.requestUrl]) or every hit comes back with no ISBNs at all.
|
||||
*/
|
||||
@Serializable
|
||||
data class OpenLibrarySearchResponseDto(
|
||||
val docs: List<OpenLibrarySearchDocDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OpenLibrarySearchDocDto(
|
||||
val key: String? = null,
|
||||
val title: String? = null,
|
||||
val subtitle: String? = null,
|
||||
@SerialName("author_name") val authorName: List<String> = emptyList(),
|
||||
@SerialName("first_publish_year") val firstPublishYear: Int? = null,
|
||||
@SerialName("cover_i") val coverI: Int? = null,
|
||||
/** Flat list mixing ISBN-10 and ISBN-13 of ALL editions of this work — may be absent, may be dozens. */
|
||||
val isbn: List<String> = emptyList(),
|
||||
val publisher: List<String> = emptyList(),
|
||||
@SerialName("number_of_pages_median") val numberOfPagesMedian: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Maps one work to a [SearchHit]. [isbn13s] normalizes every reported ISBN to
|
||||
* ISBN-13 (ISBN-10 converted) and drops anything that doesn't pass a checksum —
|
||||
* [SearchResultMerger]'s ISBN rule counts DISTINCT values here to decide whether
|
||||
* this work resolves to a single unambiguous edition.
|
||||
*/
|
||||
fun OpenLibrarySearchDocDto.toSearchHit(): SearchHit = SearchHit(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
authors = authorName,
|
||||
year = firstPublishYear,
|
||||
publisher = publisher.firstOrNull(),
|
||||
pageCount = numberOfPagesMedian,
|
||||
coverUrl = coverI?.let { "https://covers.openlibrary.org/b/id/$it-M.jpg" },
|
||||
isbn13s = isbn.mapNotNull(IsbnUtils::toIsbn13).distinct(),
|
||||
)
|
||||
@@ -63,6 +63,8 @@ object RetryPolicy {
|
||||
* changing this blanket answer.
|
||||
* - CLIENT_ERROR — an identical request gets an identical answer.
|
||||
* - MALFORMED — same bytes, same parse failure.
|
||||
* - UNEXPECTED — we don't know what broke, so we don't know it's safe
|
||||
* to repeat; see its KDoc.
|
||||
*/
|
||||
fun isRetryable(kind: FailureKind): Boolean =
|
||||
kind == FailureKind.TRANSPORT || kind == FailureKind.SERVER_ERROR
|
||||
@@ -128,22 +130,82 @@ suspend fun withRetry(
|
||||
nowMillis: () -> Long = { System.currentTimeMillis() },
|
||||
sleep: suspend (Long) -> Unit = { delay(it) },
|
||||
attempt: suspend () -> SourceResult,
|
||||
): SourceResult {
|
||||
): SourceResult = withRetryLoop(
|
||||
maxAttempts = maxAttempts,
|
||||
budgetMillis = budgetMillis,
|
||||
retryRateLimitedOnce = retryRateLimitedOnce,
|
||||
random = random,
|
||||
nowMillis = nowMillis,
|
||||
sleep = sleep,
|
||||
failureOf = { result -> (result as? SourceResult.Failed)?.let { it.kind to it.retryAfterMillis } },
|
||||
appendAttempts = { result, attempts ->
|
||||
(result as SourceResult.Failed).copy(reason = "${result.reason}, $attempts attempts")
|
||||
},
|
||||
attempt = attempt,
|
||||
)
|
||||
|
||||
/**
|
||||
* [OpenLibrarySearchClient]/[GoogleBooksSearchClient]'s counterpart to [withRetry] —
|
||||
* same policy, same loop, applied to [SearchSourceResult] instead of [SourceResult]
|
||||
* (search returns a list of hits, not one [BookMetadata], so it needs its own Found
|
||||
* shape, but the retry decision is identical). Both delegate to [withRetryLoop] so
|
||||
* there is exactly one loop implementation to keep correct.
|
||||
*/
|
||||
suspend fun withSearchRetry(
|
||||
maxAttempts: Int = RetryPolicy.MAX_ATTEMPTS,
|
||||
budgetMillis: Long = RetryPolicy.TOTAL_BUDGET_MILLIS,
|
||||
retryRateLimitedOnce: Boolean = false,
|
||||
random: Random = Random.Default,
|
||||
nowMillis: () -> Long = { System.currentTimeMillis() },
|
||||
sleep: suspend (Long) -> Unit = { delay(it) },
|
||||
attempt: suspend () -> SearchSourceResult,
|
||||
): SearchSourceResult = withRetryLoop(
|
||||
maxAttempts = maxAttempts,
|
||||
budgetMillis = budgetMillis,
|
||||
retryRateLimitedOnce = retryRateLimitedOnce,
|
||||
random = random,
|
||||
nowMillis = nowMillis,
|
||||
sleep = sleep,
|
||||
failureOf = { result -> (result as? SearchSourceResult.Failed)?.let { it.kind to it.retryAfterMillis } },
|
||||
appendAttempts = { result, attempts ->
|
||||
(result as SearchSourceResult.Failed).copy(reason = "${result.reason}, $attempts attempts")
|
||||
},
|
||||
attempt = attempt,
|
||||
)
|
||||
|
||||
/**
|
||||
* The actual retry loop, shared by [withRetry] and [withSearchRetry] so the policy
|
||||
* (attempt/budget caps, keyed-429 one-shot retry, backoff) can never drift between
|
||||
* ISBN lookup and title/author search. [failureOf] extracts (kind, retryAfterMillis)
|
||||
* from a failed [T], or null if [T] isn't a failure; [appendAttempts] stamps the final
|
||||
* attempt count into a surviving failure's reason, same diagnostic value as before.
|
||||
*/
|
||||
private suspend fun <T> withRetryLoop(
|
||||
maxAttempts: Int,
|
||||
budgetMillis: Long,
|
||||
retryRateLimitedOnce: Boolean,
|
||||
random: Random,
|
||||
nowMillis: () -> Long,
|
||||
sleep: suspend (Long) -> Unit,
|
||||
failureOf: (T) -> Pair<FailureKind, Long?>?,
|
||||
appendAttempts: (T, Int) -> T,
|
||||
attempt: suspend () -> T,
|
||||
): T {
|
||||
val started = nowMillis()
|
||||
var last: SourceResult = attempt()
|
||||
var last: T = attempt()
|
||||
var attemptsMade = 1
|
||||
var rateLimitedRetryUsed = false
|
||||
|
||||
while (attemptsMade < maxAttempts) {
|
||||
val failure = last as? SourceResult.Failed ?: return last
|
||||
val rateLimitedRetry = failure.kind == FailureKind.RATE_LIMITED &&
|
||||
retryRateLimitedOnce && !rateLimitedRetryUsed
|
||||
if (!RetryPolicy.isRetryable(failure.kind) && !rateLimitedRetry) break
|
||||
val failure = failureOf(last) ?: return last
|
||||
val (kind, retryAfterMillis) = failure
|
||||
val rateLimitedRetry = kind == FailureKind.RATE_LIMITED && retryRateLimitedOnce && !rateLimitedRetryUsed
|
||||
if (!RetryPolicy.isRetryable(kind) && !rateLimitedRetry) break
|
||||
if (nowMillis() - started >= budgetMillis) break
|
||||
|
||||
if (rateLimitedRetry) {
|
||||
rateLimitedRetryUsed = true
|
||||
sleep(failure.retryAfterMillis ?: RetryPolicy.RATE_LIMIT_RETRY_CAP_MILLIS)
|
||||
sleep(retryAfterMillis ?: RetryPolicy.RATE_LIMIT_RETRY_CAP_MILLIS)
|
||||
} else {
|
||||
sleep(RetryPolicy.backoffMillis(attemptsMade + 1, random))
|
||||
}
|
||||
@@ -151,6 +213,6 @@ suspend fun withRetry(
|
||||
attemptsMade++
|
||||
}
|
||||
|
||||
val failure = last as? SourceResult.Failed ?: return last
|
||||
return if (attemptsMade > 1) failure.copy(reason = "${failure.reason}, $attemptsMade attempts") else failure
|
||||
if (failureOf(last) == null) return last
|
||||
return if (attemptsMade > 1) appendAttempts(last, attemptsMade) else last
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
/**
|
||||
* One hit from a title/author search against Open Library or Google Books, before
|
||||
* [SearchResultMerger] groups and dedupes across sources. [isbn13s] carries EVERY
|
||||
* ISBN-13 this hit reports, already normalized (via [IsbnUtils.toIsbn13], ISBN-10
|
||||
* converted): for an Open Library work that can be dozens across all its editions;
|
||||
* for a Google Books volume it's the 0 or 1 ISBN-13 that volume itself carries. The
|
||||
* merger's ISBN rule (SPEC-adjacent, see its KDoc) reads this list directly rather
|
||||
* than a single [isbn13], so it can tell "one edition" from "many" per source.
|
||||
*/
|
||||
data class SearchHit(
|
||||
val title: String?,
|
||||
val subtitle: String? = null,
|
||||
val authors: List<String> = emptyList(),
|
||||
val year: Int? = null,
|
||||
val publisher: String? = null,
|
||||
val pageCount: Int? = null,
|
||||
val coverUrl: String? = null,
|
||||
val isbn13s: List<String> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.repo.decodeAuthors
|
||||
|
||||
/**
|
||||
* Combines local books with the two search sources' hits into one deduplicated result
|
||||
* (task: library search screen, "Results are DEDUPLICATED across the three sources").
|
||||
* Pure, no Android/coroutines — a plain function over already-fetched data so it's
|
||||
* exhaustively unit-testable.
|
||||
*
|
||||
* [local] is whatever candidate set of owned books the caller wants checked for a tie
|
||||
* to an online hit — the library ViewModel passes the locally text-matched books plus
|
||||
* any owned book sharing an ISBN with one of the online hits, so a book reached ONLY
|
||||
* via an online hit (its own text didn't match the query, but an OL/GB record it
|
||||
* shares an ISBN or title+author with did) still surfaces "In your library" rather
|
||||
* than being duplicated as a new online result. See the class-level dedupe writeup in
|
||||
* the wave report for where a caller that passes too narrow a [local] set would miss
|
||||
* that case, and where too broad a one would falsely merge unrelated namesakes.
|
||||
*/
|
||||
object SearchResultMerger {
|
||||
|
||||
data class SearchResults(val inLibrary: List<BookEntity>, val online: List<OnlineBook>)
|
||||
|
||||
/**
|
||||
* Identity: two records are the same book if they share any ISBN-13 (after
|
||||
* normalizing everything, ISBN-10 included) OR their normalized title AND first
|
||||
* author's surname match — grouped transitively via union-find, not pairwise, so
|
||||
* A~B by ISBN and B~C by title puts all three in one group. Any group containing
|
||||
* a local book becomes an "In your library" entry (shown as the local book(s) in
|
||||
* it, since two owned copies/editions of the same work both belong there); every
|
||||
* other group becomes one [OnlineBook], ordered by the best (lowest) position
|
||||
* either source returned any of its members at, so relevance from both sources
|
||||
* is preserved and neither is buried under the other.
|
||||
*/
|
||||
fun merge(local: List<BookEntity>, openLibrary: List<SearchHit>, googleBooks: List<SearchHit>): SearchResults {
|
||||
val localCount = local.size
|
||||
val olCount = openLibrary.size
|
||||
val total = localCount + olCount + googleBooks.size
|
||||
if (total == 0) return SearchResults(emptyList(), emptyList())
|
||||
|
||||
val keys = buildList {
|
||||
local.forEach { book -> add(identityKeysOf(book)) }
|
||||
openLibrary.forEach { hit -> add(identityKeysOf(hit)) }
|
||||
googleBooks.forEach { hit -> add(identityKeysOf(hit)) }
|
||||
}
|
||||
|
||||
val unionFind = UnionFind(total)
|
||||
unionByIsbn(unionFind, keys)
|
||||
unionByTitleAuthor(unionFind, keys)
|
||||
|
||||
val groups = LinkedHashMap<Int, MutableList<Int>>()
|
||||
for (index in 0 until total) groups.getOrPut(unionFind.find(index)) { mutableListOf() }.add(index)
|
||||
|
||||
val inLibrary = mutableListOf<BookEntity>()
|
||||
val rankedOnline = mutableListOf<Pair<Int, OnlineBook>>()
|
||||
|
||||
for (members in groups.values) {
|
||||
val localMembers = members.filter { it < localCount }
|
||||
if (localMembers.isNotEmpty()) {
|
||||
inLibrary += localMembers.map { local[it] }
|
||||
continue
|
||||
}
|
||||
val olMembers = members.filter { it in localCount until localCount + olCount }.map { it - localCount }
|
||||
val gbMembers = members.filter { it >= localCount + olCount }.map { it - localCount - olCount }
|
||||
if (olMembers.isEmpty() && gbMembers.isEmpty()) continue
|
||||
|
||||
val olHits = olMembers.map { openLibrary[it] }
|
||||
val gbHits = gbMembers.map { googleBooks[it] }
|
||||
val bestRank = minOf(
|
||||
olMembers.minOrNull() ?: Int.MAX_VALUE,
|
||||
gbMembers.minOrNull() ?: Int.MAX_VALUE,
|
||||
)
|
||||
rankedOnline += bestRank to buildOnlineBook(olHits, gbHits)
|
||||
}
|
||||
|
||||
return SearchResults(inLibrary, rankedOnline.sortedBy { it.first }.map { it.second })
|
||||
}
|
||||
|
||||
private data class IdentityKeys(val isbns: List<String>, val titleKey: String, val surname: String)
|
||||
|
||||
private fun identityKeysOf(book: BookEntity): IdentityKeys {
|
||||
val isbns = listOfNotNull(
|
||||
book.isbn13?.let(IsbnUtils::toIsbn13),
|
||||
book.isbn10?.let(IsbnUtils::toIsbn13),
|
||||
).distinct()
|
||||
return IdentityKeys(isbns, TextNormalization.normalizedTitleKey(book.title), TextNormalization.authorSurnameKey(decodeAuthors(book.authorsJson)))
|
||||
}
|
||||
|
||||
private fun identityKeysOf(hit: SearchHit): IdentityKeys =
|
||||
IdentityKeys(hit.isbn13s, TextNormalization.normalizedTitleKey(hit.title), TextNormalization.authorSurnameKey(hit.authors))
|
||||
|
||||
private fun unionByIsbn(unionFind: UnionFind, keys: List<IdentityKeys>) {
|
||||
val firstSeenAt = HashMap<String, Int>()
|
||||
keys.forEachIndexed { index, key ->
|
||||
for (isbn in key.isbns) {
|
||||
val existing = firstSeenAt.putIfAbsent(isbn, index)
|
||||
if (existing != null) unionFind.union(existing, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unionByTitleAuthor(unionFind: UnionFind, keys: List<IdentityKeys>) {
|
||||
val firstSeenAt = HashMap<String, Int>()
|
||||
keys.forEachIndexed { index, key ->
|
||||
if (key.titleKey.isBlank() || key.surname.isBlank()) return@forEachIndexed
|
||||
val compositeKey = "${key.titleKey}|${key.surname}"
|
||||
val existing = firstSeenAt.putIfAbsent(compositeKey, index)
|
||||
if (existing != null) unionFind.union(existing, index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SPEC-adjacent field fill, mirroring [MetadataMerger]'s "prefer whichever source
|
||||
* has a title, fill blanks from the other" — here per-field per the task's rule:
|
||||
* title/subtitle/authors from the OL hit with a title if there is one (work-level
|
||||
* data, cleaner than a single edition's), else the first GB hit with one; year,
|
||||
* publisher, page count and cover each independently fall through OL -> GB.
|
||||
*/
|
||||
private fun buildOnlineBook(olHits: List<SearchHit>, gbHits: List<SearchHit>): OnlineBook {
|
||||
val olPrimary = olHits.firstOrNull { !it.title.isNullOrBlank() }
|
||||
val gbPrimary = gbHits.firstOrNull { !it.title.isNullOrBlank() }
|
||||
val primary = olPrimary ?: gbPrimary
|
||||
val secondary = if (primary === olPrimary) gbPrimary else olPrimary
|
||||
|
||||
val sources = buildSet {
|
||||
if (olHits.isNotEmpty()) add(SearchSource.OPEN_LIBRARY)
|
||||
if (gbHits.isNotEmpty()) add(SearchSource.GOOGLE_BOOKS)
|
||||
}
|
||||
return OnlineBook(
|
||||
title = primary?.title,
|
||||
subtitle = primary?.subtitle ?: secondary?.subtitle,
|
||||
authors = primary?.authors?.ifEmpty { null } ?: secondary?.authors ?: emptyList(),
|
||||
year = primary?.year ?: secondary?.year,
|
||||
publisher = primary?.publisher ?: secondary?.publisher,
|
||||
pageCount = primary?.pageCount ?: secondary?.pageCount,
|
||||
coverUrl = primary?.coverUrl ?: secondary?.coverUrl,
|
||||
isbn13 = resolveIsbn(olHits, gbHits),
|
||||
sources = sources,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ISBN rule (decided by the user, implemented exactly): an OL work whose
|
||||
* ISBNs normalize to exactly one distinct ISBN-13 wins; else a GB volume set that
|
||||
* carries exactly one distinct ISBN-13 wins; otherwise no ISBN is recorded at all
|
||||
* rather than guessing among several editions.
|
||||
*/
|
||||
private fun resolveIsbn(olHits: List<SearchHit>, gbHits: List<SearchHit>): String? {
|
||||
for (hit in olHits) {
|
||||
val distinct = hit.isbn13s.distinct()
|
||||
if (distinct.size == 1) return distinct.single()
|
||||
}
|
||||
return gbHits.flatMap { it.isbn13s }.distinct().singleOrNull()
|
||||
}
|
||||
|
||||
/** Plain union-find over node indices `0 until n` — path-halved finds, union by attaching root to root. */
|
||||
private class UnionFind(n: Int) {
|
||||
private val parent = IntArray(n) { it }
|
||||
|
||||
fun find(x: Int): Int {
|
||||
var current = x
|
||||
while (parent[current] != current) {
|
||||
parent[current] = parent[parent[current]]
|
||||
current = parent[current]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
fun union(a: Int, b: Int) {
|
||||
val rootA = find(a)
|
||||
val rootB = find(b)
|
||||
if (rootA != rootB) parent[rootA] = rootB
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Per-source title/author search outcome — [SearchSourceResult]'s counterpart to
|
||||
* [SourceResult] for the library screen's online search (task: "Extend it to also
|
||||
* search Open Library and Google Books"). Kept as its own sealed type rather than
|
||||
* reusing [SourceResult] because a search's [Found] carries a list of [SearchHit],
|
||||
* not one [BookMetadata] — but it follows the exact same three-way rule (SPEC.md
|
||||
* "Book metadata lookup": a source that could not be reached must never be reported
|
||||
* as "no results") and the exact same [FailureKind] classification, via
|
||||
* [fromException]/[fromHttpCode] delegating straight to [SourceResult]'s so the two
|
||||
* can never disagree about what a given exception or HTTP code means.
|
||||
*/
|
||||
sealed interface SearchSourceResult {
|
||||
data class Found(val hits: List<SearchHit>) : SearchSourceResult
|
||||
|
||||
/** The source answered (2xx) and, in good faith, found nothing for this query. */
|
||||
data object NotFound : SearchSourceResult
|
||||
|
||||
data class Failed(
|
||||
val reason: String,
|
||||
val kind: FailureKind = FailureKind.TRANSPORT,
|
||||
val retryAfterMillis: Long? = null,
|
||||
) : SearchSourceResult
|
||||
|
||||
companion object {
|
||||
fun fromException(e: IOException): Failed =
|
||||
SourceResult.fromException(e).let { Failed(it.reason, it.kind, it.retryAfterMillis) }
|
||||
|
||||
fun fromHttpCode(code: Int, retryAfterHeader: String? = null): Failed =
|
||||
SourceResult.fromHttpCode(code, retryAfterHeader).let { Failed(it.reason, it.kind, it.retryAfterMillis) }
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,17 @@ enum class FailureKind {
|
||||
|
||||
/** 2xx whose body we could not parse. Deterministic: the same bytes will fail again. */
|
||||
MALFORMED,
|
||||
|
||||
/**
|
||||
* Something neither client's `fetch` was written to expect — not an
|
||||
* [IOException], not an HTTP status, not a parse failure. Added 2026-09-12
|
||||
* after the first barcode scanned on a real phone (right after the Google
|
||||
* Books key landed, commit 486f6eb) crashed the process; the JVM here has
|
||||
* never reproduced it, so what actually threw is still unknown. NOT
|
||||
* retryable: an unknown failure is as likely to repeat whatever damage it
|
||||
* did as to clear up, so the safer move is to surface it once and stop.
|
||||
*/
|
||||
UNEXPECTED,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import java.text.Normalizer
|
||||
|
||||
/**
|
||||
* Shared text normalization for the two places that need to match books by loose
|
||||
* text rather than exact fields: [org.modg.bookshelf.ui.library.LocalBookMatcher]
|
||||
* (query tokens against an owned book) and [SearchResultMerger] (title+author
|
||||
* identity across sources). One implementation so "hobbit" ~ "Hobbit" and
|
||||
* "Brontë" ~ "bronte" mean the same thing in both places.
|
||||
*/
|
||||
object TextNormalization {
|
||||
private val leadingArticles = setOf("the", "a", "an")
|
||||
|
||||
// Compiled once: the library search normalizes every owned book's fields, so an inline
|
||||
// Regex(...) here was being recompiled thousands of times per keystroke.
|
||||
private val combiningMarks = Regex("\\p{Mn}+")
|
||||
private val whitespaceRun = Regex("\\s+")
|
||||
|
||||
/**
|
||||
* Lowercase, diacritics stripped (NFD decompose + drop combining marks), every
|
||||
* non-alphanumeric character turned into a space, whitespace collapsed. "J.R.R."
|
||||
* becomes "j r r" — a space-separated token run, not a single run-together word.
|
||||
*/
|
||||
fun normalizeForMatching(raw: String): String {
|
||||
val decomposed = Normalizer.normalize(raw, Normalizer.Form.NFD)
|
||||
val noDiacritics = decomposed.replace(combiningMarks, "")
|
||||
val spaced = noDiacritics.map { c -> if (c.isLetterOrDigit()) c else ' ' }.joinToString("")
|
||||
return spaced.lowercase().trim().replace(whitespaceRun, " ")
|
||||
}
|
||||
|
||||
fun tokens(raw: String): List<String> {
|
||||
val normalized = normalizeForMatching(raw)
|
||||
return if (normalized.isBlank()) emptyList() else normalized.split(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity title key for [SearchResultMerger]: subtitle (everything from the
|
||||
* first ':' on) dropped BEFORE normalizing (the colon itself would otherwise
|
||||
* become a space and the split point would be lost), then a leading "the/a/an"
|
||||
* dropped so "The Hobbit" and "Hobbit" key the same.
|
||||
*/
|
||||
fun normalizedTitleKey(title: String?): String {
|
||||
if (title.isNullOrBlank()) return ""
|
||||
val words = tokens(title.substringBefore(':'))
|
||||
val withoutArticle = if (words.firstOrNull() in leadingArticles) words.drop(1) else words
|
||||
return withoutArticle.joinToString(" ")
|
||||
}
|
||||
|
||||
/** The last alphabetic token of the first author — "Joanne S. Williamson" -> "williamson". */
|
||||
fun authorSurnameKey(authors: List<String>): String =
|
||||
authors.firstOrNull()?.let { tokens(it).lastOrNull() }.orEmpty()
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.modg.bookshelf.data.prefs
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
@@ -27,6 +28,10 @@ class SettingsStore(private val context: Context) {
|
||||
val USER_EMAIL = stringPreferencesKey("user_email")
|
||||
val LAST_SYNC_TIME = longPreferencesKey("last_sync_time")
|
||||
val LAST_SHELF_ID = stringPreferencesKey("last_shelf_id")
|
||||
// Named for the incident, not the feature: a generic "did the full repull
|
||||
// run" flag would invite reuse for some future, unrelated repull with
|
||||
// different safety requirements. See SyncEngine.sync().
|
||||
val FULL_REPULL_2026_09_17_DONE = booleanPreferencesKey("full_repull_2026_09_17_done")
|
||||
fun cursor(collection: String) = stringPreferencesKey("cursor_$collection")
|
||||
}
|
||||
|
||||
@@ -36,6 +41,9 @@ class SettingsStore(private val context: Context) {
|
||||
val userEmail: Flow<String?> = context.dataStore.data.map { it[Keys.USER_EMAIL] }
|
||||
val lastSyncTime: Flow<Long?> = context.dataStore.data.map { it[Keys.LAST_SYNC_TIME] }
|
||||
|
||||
/** See [Keys.FULL_REPULL_2026_09_17_DONE] and [setFullRepullDone]. */
|
||||
val fullRepullDone: Flow<Boolean> = context.dataStore.data.map { it[Keys.FULL_REPULL_2026_09_17_DONE] ?: false }
|
||||
|
||||
/**
|
||||
* The shelf most recently assigned to a book — SPEC "remember the most recently
|
||||
* used shelf" (shelving a box of books usually means one shelf, over and over).
|
||||
@@ -66,10 +74,30 @@ class SettingsStore(private val context: Context) {
|
||||
context.dataStore.edit { it[Keys.cursor(collection)] = cursor }
|
||||
}
|
||||
|
||||
/** Forces the next sync's pull to fetch that collection from scratch. */
|
||||
suspend fun clearCursor(collection: String) {
|
||||
context.dataStore.edit { it.remove(Keys.cursor(collection)) }
|
||||
}
|
||||
|
||||
suspend fun setLastSyncTime(epochMillis: Long) {
|
||||
context.dataStore.edit { it[Keys.LAST_SYNC_TIME] = epochMillis }
|
||||
}
|
||||
|
||||
/** One-way: production code never needs to un-set this once a full re-pull has landed. */
|
||||
suspend fun setFullRepullDone() {
|
||||
context.dataStore.edit { it[Keys.FULL_REPULL_2026_09_17_DONE] = true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only seam (internal, not private) — DataStore's on-disk state
|
||||
* otherwise leaks across Robolectric test methods within a class; see
|
||||
* [org.modg.bookshelf.data.prefs.SettingsStoreTest]'s [clearLastShelfId]
|
||||
* call in its own setUp for the same reason.
|
||||
*/
|
||||
internal suspend fun resetFullRepullDoneForTesting() {
|
||||
context.dataStore.edit { it.remove(Keys.FULL_REPULL_2026_09_17_DONE) }
|
||||
}
|
||||
|
||||
suspend fun setLastShelfId(shelfId: String) {
|
||||
context.dataStore.edit { it[Keys.LAST_SHELF_ID] = shelfId }
|
||||
}
|
||||
@@ -92,6 +120,18 @@ class SettingsStore(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth EXPIRY, not sign-out: the 2026-09-17 incident (server logged
|
||||
* `auth: ""` on every request — a 5-day-old token PocketBase silently
|
||||
* treated as anonymous rather than rejecting) means the token can go bad
|
||||
* on its own. Unlike [clearAuth], this keeps server URL, user id AND
|
||||
* email so the setup screen can pre-fill everything but the password —
|
||||
* re-signing-in after an expiry should be one field, not three.
|
||||
*/
|
||||
suspend fun clearAuthToken() {
|
||||
context.dataStore.edit { it.remove(Keys.AUTH_TOKEN) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val COLLECTION_BOOKS = "books"
|
||||
const val COLLECTION_SHELVES = "shelves"
|
||||
|
||||
@@ -20,6 +20,19 @@ interface PocketBaseApi {
|
||||
@POST("api/collections/users/auth-with-password")
|
||||
suspend fun authWithPassword(@Body body: AuthWithPasswordRequest): AuthResponse
|
||||
|
||||
/**
|
||||
* Re-issues a fresh token for the currently-attached one (see
|
||||
* [PbAuthInterceptor]) — same response shape as [authWithPassword]. Called
|
||||
* at the start of every [org.modg.bookshelf.data.repo.SyncEngine.sync]: a
|
||||
* PocketBase user token that has quietly expired is NOT rejected by the
|
||||
* server, it's just treated as anonymous, and an anonymous request 404s
|
||||
* on records the rules hide instead of 401ing — see
|
||||
* [org.modg.bookshelf.data.repo.SyncEngine]'s 404 hard-delete helpers for
|
||||
* the 2026-09-17 incident that caused.
|
||||
*/
|
||||
@POST("api/collections/users/auth-refresh")
|
||||
suspend fun authRefresh(): AuthResponse
|
||||
|
||||
// ---- books ----
|
||||
|
||||
@GET("api/collections/books/records")
|
||||
|
||||
@@ -18,6 +18,9 @@ class AuthRepository(
|
||||
val isLoggedIn: Flow<Boolean> = settingsStore.authToken.map { !it.isNullOrBlank() }
|
||||
val serverUrl: Flow<String?> = settingsStore.serverUrl
|
||||
|
||||
/** Read by the setup screen to pre-fill re-sign-in after an auth expiry (SettingsStore.clearAuthToken keeps this). */
|
||||
val userEmail: Flow<String?> = settingsStore.userEmail
|
||||
|
||||
/** Normalizes per SPEC: requires https, strips a trailing slash. */
|
||||
suspend fun setServerUrl(rawUrl: String): Result<Unit> {
|
||||
val trimmed = rawUrl.trim().trimEnd('/')
|
||||
@@ -36,6 +39,12 @@ class AuthRepository(
|
||||
settingsStore.setAuthToken(response.token)
|
||||
settingsStore.setUserId(response.record.id)
|
||||
settingsStore.setUserEmail(email)
|
||||
// Every fresh sign-in reconciles fully, not just the one after the
|
||||
// 2026-09-17 incident's stuck cursors — the same backstop for any
|
||||
// future cause we haven't thought of yet.
|
||||
settingsStore.clearCursor(SettingsStore.COLLECTION_BOOKCASES)
|
||||
settingsStore.clearCursor(SettingsStore.COLLECTION_SHELVES)
|
||||
settingsStore.clearCursor(SettingsStore.COLLECTION_BOOKS)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
|
||||
@@ -7,14 +7,11 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.modg.bookshelf.data.local.BookDao
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.local.IdGenerator
|
||||
import org.modg.bookshelf.data.local.SyncState
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
private val authorsJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@@ -31,7 +28,6 @@ fun decodeAuthors(json: String): List<String> =
|
||||
class BookRepository(
|
||||
private val bookDao: BookDao,
|
||||
private val context: Context,
|
||||
private val downloadClient: OkHttpClient = OkHttpClient(),
|
||||
) {
|
||||
fun observeAll(): Flow<List<BookEntity>> = bookDao.observeAll()
|
||||
fun observeAllByRecentlyAdded(): Flow<List<BookEntity>> = bookDao.observeAllByRecentlyAdded()
|
||||
@@ -44,10 +40,13 @@ class BookRepository(
|
||||
suspend fun countByShelf(shelfId: String): Int = bookDao.countByShelf(shelfId)
|
||||
|
||||
/**
|
||||
* Creates a new book. If [coverSourceUrl] is set, attempts to download the
|
||||
* cover to app-private storage now (best-effort; failures are swallowed —
|
||||
* the SyncEngine will simply have nothing to upload, and the UI still has
|
||||
* [coverSourceUrl] to fall back on for display).
|
||||
* Creates a new book. [coverFile] is a cover the caller already downloaded (see
|
||||
* [CoverPreload]); it is COPIED into app-private storage as [BookEntity.localCoverPath]
|
||||
* for SyncEngine to upload. This used to download [coverSourceUrl] itself before
|
||||
* writing the row, which made every save wait on the image host and silently saved
|
||||
* the book with no cover file when that download failed. The caller keeps ownership
|
||||
* of [coverFile] (a copy, not a move, so a failed write leaves the preload intact for
|
||||
* a retry). [coverSourceUrl] is still recorded — the UI falls back to it for display.
|
||||
*/
|
||||
suspend fun createBook(
|
||||
title: String,
|
||||
@@ -60,35 +59,42 @@ class BookRepository(
|
||||
pageCount: Int? = null,
|
||||
description: String? = null,
|
||||
coverSourceUrl: String? = null,
|
||||
coverFile: File? = null,
|
||||
shelfId: String? = null,
|
||||
notes: String? = null,
|
||||
addedBy: String? = null,
|
||||
): String {
|
||||
val id = IdGenerator.newId()
|
||||
val now = System.currentTimeMillis()
|
||||
val localCoverPath = coverSourceUrl?.let { downloadCoverBestEffort(id, it) }
|
||||
bookDao.upsert(
|
||||
BookEntity(
|
||||
id = id,
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
authorsJson = encodeAuthors(authors),
|
||||
isbn13 = isbn13,
|
||||
isbn10 = isbn10,
|
||||
publisher = publisher,
|
||||
publishedDate = publishedDate,
|
||||
pageCount = pageCount,
|
||||
description = description,
|
||||
coverSourceUrl = coverSourceUrl,
|
||||
shelfId = shelfId,
|
||||
notes = notes,
|
||||
addedBy = addedBy,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
syncState = SyncState.PENDING_CREATE,
|
||||
localCoverPath = localCoverPath,
|
||||
),
|
||||
)
|
||||
val localCover = coverFile?.let { copyCover(id, it) }
|
||||
try {
|
||||
bookDao.upsert(
|
||||
BookEntity(
|
||||
id = id,
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
authorsJson = encodeAuthors(authors),
|
||||
isbn13 = isbn13,
|
||||
isbn10 = isbn10,
|
||||
publisher = publisher,
|
||||
publishedDate = publishedDate,
|
||||
pageCount = pageCount,
|
||||
description = description,
|
||||
coverSourceUrl = coverSourceUrl,
|
||||
shelfId = shelfId,
|
||||
notes = notes,
|
||||
addedBy = addedBy,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
syncState = SyncState.PENDING_CREATE,
|
||||
localCoverPath = localCover?.absolutePath,
|
||||
),
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
// No row points at the copy, so nothing would ever upload or delete it.
|
||||
localCover?.delete()
|
||||
throw e
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -131,19 +137,8 @@ class BookRepository(
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun downloadCoverBestEffort(id: String, url: String): String? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder().url(url).build()
|
||||
downloadClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
val bytes = response.body.bytes()
|
||||
val dir = File(context.filesDir, "covers").apply { mkdirs() }
|
||||
val file = File(dir, "$id.jpg")
|
||||
file.writeBytes(bytes)
|
||||
file.absolutePath
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
null
|
||||
}
|
||||
private suspend fun copyCover(id: String, source: File): File = withContext(Dispatchers.IO) {
|
||||
val dir = File(context.filesDir, "covers").apply { mkdirs() }
|
||||
source.copyTo(File(dir, "$id.jpg"), overwrite = true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package org.modg.bookshelf.data.repo
|
||||
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.modg.bookshelf.data.metadata.SourceResult
|
||||
|
||||
/** Outcome of one cover download. [Failed.reason] is safe to show the user. */
|
||||
sealed interface CoverFetch {
|
||||
data class Ready(val file: File) : CoverFetch
|
||||
data class Failed(val reason: String) : CoverFetch
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a cover into [preloadDir] so it can be handed to [BookRepository.createBook]
|
||||
* at save time. Save used to download the cover itself, BEFORE writing the row, so every
|
||||
* save sat waiting on the image host (covers.openlibrary.org redirects to archive.org and
|
||||
* is often slow) even though the sheet was already showing that very cover. Downloading
|
||||
* as soon as the cover URL is known means the file is usually there by the time Save is
|
||||
* tapped, and a failure can be shown while the user is still on the sheet.
|
||||
*
|
||||
* Not Coil's disk cache: that's Coil's private performance detail (eviction, cache keys,
|
||||
* format can all change), and we need a file we own to upload to the server.
|
||||
*/
|
||||
class CoverDownloader(
|
||||
private val httpClient: OkHttpClient,
|
||||
private val preloadDir: File,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
/** Never throws except [CancellationException]; no file is left behind on any non-success path. */
|
||||
suspend fun download(url: String): CoverFetch {
|
||||
preloadDir.mkdirs()
|
||||
val file = File(preloadDir, UUID.randomUUID().toString())
|
||||
return try {
|
||||
withContext(ioDispatcher) { fetchInto(file, url) }
|
||||
} catch (e: CancellationException) {
|
||||
// Also covers a cancel that lands AFTER the bytes were written: withContext then
|
||||
// throws on its way out, past any catch inside the block.
|
||||
file.delete()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchInto(file: File, url: String): CoverFetch = try {
|
||||
val request = Request.Builder().url(url).build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
val bytes = if (response.isSuccessful) response.body.bytes() else null
|
||||
when {
|
||||
bytes == null -> CoverFetch.Failed(SourceResult.fromHttpCode(response.code).reason)
|
||||
bytes.isEmpty() -> CoverFetch.Failed("empty image")
|
||||
else -> {
|
||||
file.writeBytes(bytes)
|
||||
CoverFetch.Ready(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
file.delete()
|
||||
CoverFetch.Failed(SourceResult.fromException(e).reason)
|
||||
} catch (e: Throwable) {
|
||||
file.delete()
|
||||
CoverFetch.Failed("unexpected: ${e.javaClass.simpleName}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes preloads left behind by a previous process (killed or crashed with a sheet
|
||||
* open). Only files older than [olderThanMillis] — the current process's start — so a
|
||||
* preload started by this process can never be swept out from under its sheet.
|
||||
*/
|
||||
fun sweepStale(olderThanMillis: Long) {
|
||||
preloadDir.listFiles()?.forEach { if (it.lastModified() < olderThanMillis) it.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
/** What a sheet/screen shows about its cover download. Every non-[None] state carries the URL it is for. */
|
||||
sealed interface CoverPreloadState {
|
||||
data object None : CoverPreloadState
|
||||
data class Loading(val url: String) : CoverPreloadState
|
||||
data class Ready(val url: String, val file: File) : CoverPreloadState
|
||||
data class Failed(val url: String, val reason: String) : CoverPreloadState
|
||||
}
|
||||
|
||||
/**
|
||||
* At most ONE cover download for one owner (a sheet or screen) — it starts when a result is
|
||||
* opened, never for every row in a result list. Owns the downloaded file until [discard]:
|
||||
* call it on Skip/dismiss/leaving the screen AND after a successful save (createBook copies
|
||||
* the file), so nothing is left behind for books that weren't saved.
|
||||
*/
|
||||
class CoverPreload(
|
||||
private val downloader: CoverDownloader,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val _state = MutableStateFlow<CoverPreloadState>(CoverPreloadState.None)
|
||||
val state: StateFlow<CoverPreloadState> = _state.asStateFlow()
|
||||
private var job: Job? = null
|
||||
|
||||
/** Starts downloading [url]; a no-op if that URL is already loading or ready. Null/blank clears. */
|
||||
fun start(url: String?) {
|
||||
if (url.isNullOrBlank()) {
|
||||
discard()
|
||||
return
|
||||
}
|
||||
when (val current = _state.value) {
|
||||
is CoverPreloadState.Loading -> if (current.url == url) return
|
||||
is CoverPreloadState.Ready -> if (current.url == url) return
|
||||
else -> Unit
|
||||
}
|
||||
discard()
|
||||
val loading = CoverPreloadState.Loading(url)
|
||||
_state.value = loading
|
||||
job = scope.launch {
|
||||
val result = downloader.download(url)
|
||||
if (_state.value != loading) {
|
||||
// Superseded or discarded while downloading — the file is nobody's.
|
||||
(result as? CoverFetch.Ready)?.file?.delete()
|
||||
return@launch
|
||||
}
|
||||
_state.value = when (result) {
|
||||
is CoverFetch.Ready -> CoverPreloadState.Ready(url, result.file)
|
||||
is CoverFetch.Failed -> CoverPreloadState.Failed(url, result.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
val failed = _state.value as? CoverPreloadState.Failed ?: return
|
||||
discard()
|
||||
start(failed.url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for any in-flight download to finish. A Ready file that has since vanished
|
||||
* (the preload lives in the cache dir, which Android may clear) is downloaded again.
|
||||
*/
|
||||
suspend fun awaitSettled(): CoverPreloadState {
|
||||
val settled = _state.first { it !is CoverPreloadState.Loading }
|
||||
if (settled is CoverPreloadState.Ready && !settled.file.exists()) {
|
||||
discard()
|
||||
start(settled.url)
|
||||
return _state.first { it !is CoverPreloadState.Loading }
|
||||
}
|
||||
return settled
|
||||
}
|
||||
|
||||
fun discard() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
(_state.value as? CoverPreloadState.Ready)?.file?.delete()
|
||||
_state.value = CoverPreloadState.None
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.modg.bookshelf.data.repo
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.first
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
@@ -26,6 +27,13 @@ sealed class SyncResult {
|
||||
object Success : SyncResult()
|
||||
object Skipped : SyncResult()
|
||||
data class Failure(val error: Throwable) : SyncResult()
|
||||
|
||||
/**
|
||||
* The session is gone (auth-refresh, or any push/pull mid-sync, came back
|
||||
* 401/403). Distinct from [Failure]: the fix isn't "retry later", it's
|
||||
* "sign in again" — see the 2026-09-17 incident in SyncEngine's KDoc.
|
||||
*/
|
||||
object AuthExpired : SyncResult()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,6 +43,18 @@ sealed class SyncResult {
|
||||
* is bookcases -> shelves -> books both ways, so relations resolve cleanly
|
||||
* even though Room does not enforce foreign keys between them (sync would
|
||||
* otherwise have to special-case out-of-order pages).
|
||||
*
|
||||
* 2026-09-17 incident: user auth tokens lasted PocketBase's default 5 days,
|
||||
* the app logged in once on the setup screen and never refreshed, and
|
||||
* PocketBase does NOT reject an expired/invalid `Authorization` header — it
|
||||
* silently treats the request as anonymous. The `@request.auth.id != ""`
|
||||
* collection rules then turned every write anonymous-404/400, and this
|
||||
* class's own 404-means-deleted handling (see the update* helpers below)
|
||||
* hard-deleted five books that were still intact on the server. Server
|
||||
* tokens now last 180 days (commit ab1d294) as a backstop, but the real fix
|
||||
* is here: refresh the token at the top of every sync, before any push/pull,
|
||||
* so a stale token surfaces as [SyncResult.AuthExpired] instead of silently
|
||||
* degrading into anonymous requests that 404 on records the rules are hiding.
|
||||
*/
|
||||
class SyncEngine(
|
||||
private val apiProvider: ApiProvider,
|
||||
@@ -46,6 +66,25 @@ class SyncEngine(
|
||||
suspend fun sync(): SyncResult {
|
||||
val api = apiProvider.api() ?: return SyncResult.Skipped
|
||||
return try {
|
||||
val refreshed = api.authRefresh()
|
||||
settingsStore.setAuthToken(refreshed.token)
|
||||
settingsStore.setUserId(refreshed.record.id)
|
||||
|
||||
// One-time recovery for the 2026-09-17 incident: rows hard-deleted
|
||||
// locally by an anonymous-404 during the outage are still on the
|
||||
// server, but the pull cursor has already moved past their
|
||||
// `updated`, so an ordinary incremental pull will never see them
|
||||
// again. Re-pulling everything once is safe — see
|
||||
// applyIncomingBook/shouldApplyRemote below: a row missing
|
||||
// locally is always inserted, and a locally-dirty row only ever
|
||||
// loses to a strictly newer remote copy.
|
||||
val recovering = !settingsStore.fullRepullDone.first()
|
||||
if (recovering) {
|
||||
settingsStore.clearCursor(SettingsStore.COLLECTION_BOOKCASES)
|
||||
settingsStore.clearCursor(SettingsStore.COLLECTION_SHELVES)
|
||||
settingsStore.clearCursor(SettingsStore.COLLECTION_BOOKS)
|
||||
}
|
||||
|
||||
pushBookcases(api)
|
||||
pushShelves(api)
|
||||
pushBooks(api)
|
||||
@@ -53,7 +92,24 @@ class SyncEngine(
|
||||
pullShelves(api)
|
||||
pullBooks(api)
|
||||
settingsStore.setLastSyncTime(System.currentTimeMillis())
|
||||
// Only after the whole sync succeeds — a sync that fails partway
|
||||
// through (e.g. pull throws) must retry the full re-pull next time.
|
||||
if (recovering) settingsStore.setFullRepullDone()
|
||||
SyncResult.Success
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: HttpException) {
|
||||
if (e.code() == 401 || e.code() == 403) {
|
||||
// Belt and braces: this catches both authRefresh's own 401/403
|
||||
// and one from any push/pull call later in this same sync
|
||||
// (e.g. the token was revoked mid-sync). The 404 handling in
|
||||
// the update* helpers below only matches code 404, so it can
|
||||
// never swallow this.
|
||||
settingsStore.clearAuthToken()
|
||||
SyncResult.AuthExpired
|
||||
} else {
|
||||
SyncResult.Failure(e)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
SyncResult.Failure(e)
|
||||
} catch (e: Exception) {
|
||||
@@ -85,6 +141,15 @@ class SyncEngine(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A 404 here is trusted as "deleted on the server" ONLY because [sync]
|
||||
* proved the attached token valid, moments earlier, by refreshing it. On
|
||||
* 2026-09-17 a request that carried a quietly-expired token 404'd on a
|
||||
* bookcase that still existed — PocketBase's create/update rules hide a
|
||||
* record from an unauthenticated caller rather than 401ing it, so an
|
||||
* anonymous PATCH to an existing id looks identical to one to a deleted
|
||||
* id. Do not call this without a fresh refresh in the same [sync] run.
|
||||
*/
|
||||
private suspend fun updateBookcase(api: PocketBaseApi, entity: BookcaseEntity) {
|
||||
try {
|
||||
val response = api.updateBookcase(entity.id, entity.toDto())
|
||||
@@ -115,6 +180,7 @@ class SyncEngine(
|
||||
}
|
||||
}
|
||||
|
||||
/** Same trust argument as [updateBookcase]'s KDoc — see it for the 2026-09-17 incident. */
|
||||
private suspend fun updateShelf(api: PocketBaseApi, entity: ShelfEntity) {
|
||||
try {
|
||||
val response = api.updateShelf(entity.id, entity.toDto())
|
||||
@@ -145,6 +211,13 @@ class SyncEngine(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same trust argument as [updateBookcase]'s KDoc — see it for the
|
||||
* 2026-09-17 incident. This is the helper that actually lost the user's
|
||||
* books that day: five records hard-deleted locally while fully intact
|
||||
* on the server, because the request that 404'd on them carried no valid
|
||||
* auth and PocketBase's rules hid rather than rejected them.
|
||||
*/
|
||||
private suspend fun updateBook(api: PocketBaseApi, entity: BookEntity) {
|
||||
try {
|
||||
val response = api.updateBook(entity.id, entity.toDto())
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package org.modg.bookshelf.diagnostics
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/** One crash report read back off disk, ready for the settings screen. */
|
||||
data class CrashReport(
|
||||
val file: File,
|
||||
/** ISO-8601, as written at crash time. */
|
||||
val timestamp: String,
|
||||
/** The stack trace's first line, e.g. "java.lang.RuntimeException: boom". */
|
||||
val headline: String,
|
||||
val fullText: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Installs a process-wide uncaught-exception handler that writes a crash
|
||||
* report to [Context.filesDir] before the process dies, so a crash on a
|
||||
* phone this box cannot reach leaves evidence instead of nothing.
|
||||
*
|
||||
* Born 2026-09-12: the first barcode scanned after the Google Books key
|
||||
* landed (commit 486f6eb) crashed the app on the user's phone. It has never
|
||||
* reproduced since, on-device or off, and static review found nothing —
|
||||
* see [org.modg.bookshelf.data.metadata.GoogleBooksClient]'s KDoc and
|
||||
* docs/METADATA-SOURCES.md. That crash is why this class exists: it can't
|
||||
* tell us what threw this time, but the next one won't vanish the same way.
|
||||
*/
|
||||
class CrashReporter(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val filesDir get() = appContext.filesDir
|
||||
|
||||
/**
|
||||
* Installs [this], chaining to whatever handler was previously registered
|
||||
* (the platform default, normally) so Android's own crash dialog and
|
||||
* process teardown still happen — without chaining, the process would
|
||||
* hang instead of dying. Call once, from
|
||||
* [org.modg.bookshelf.BookshelfApplication.onCreate]. Never throws: an
|
||||
* exception while installing a crash handler would itself take the app
|
||||
* down before it ever got a chance to protect anything.
|
||||
*/
|
||||
fun install() {
|
||||
runCatching {
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
// The whole point of this handler is to not be the thing
|
||||
// that goes wrong next — a failure while writing the
|
||||
// report must never stop the previous handler running.
|
||||
runCatching { recordCrash(thread, throwable) }
|
||||
} finally {
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one crash report file, then trims to [MAX_REPORTS]. Internal
|
||||
* (not private) so tests can drive it directly without actually crashing
|
||||
* a thread.
|
||||
*/
|
||||
internal fun recordCrash(thread: Thread, throwable: Throwable) {
|
||||
val stackTrace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString()
|
||||
val text = buildString {
|
||||
appendLine("Bookshelf crash report")
|
||||
appendLine("timestamp: ${isoNow()}")
|
||||
appendLine("version: ${versionName()} (${versionCode()})")
|
||||
appendLine("device: ${Build.MODEL}, sdk ${Build.VERSION.SDK_INT}")
|
||||
appendLine("thread: ${thread.name}")
|
||||
appendLine(SEPARATOR)
|
||||
append(stackTrace)
|
||||
}
|
||||
File(filesDir, fileName()).writeText(text)
|
||||
trimToNewest(MAX_REPORTS)
|
||||
}
|
||||
|
||||
/** Every saved crash report, most recent first. Empty (never throws) if there are none or [filesDir] can't be read. */
|
||||
fun list(): List<CrashReport> = runCatching {
|
||||
reportFiles().sortedByDescending { it.name }.mapNotNull(::parse)
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/** The most recently written crash report, or null if none. This is the only I/O this class does outside a crash. */
|
||||
fun latest(): CrashReport? = list().firstOrNull()
|
||||
|
||||
private fun reportFiles(): List<File> =
|
||||
filesDir.listFiles { f -> f.isFile && f.name.startsWith(FILE_PREFIX) && f.name.endsWith(FILE_SUFFIX) }
|
||||
?.toList()
|
||||
?: emptyList()
|
||||
|
||||
/** Deletes everything past the newest [keep] — a phone must never accumulate these without bound. */
|
||||
private fun trimToNewest(keep: Int) {
|
||||
reportFiles().sortedByDescending { it.name }.drop(keep).forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun parse(file: File): CrashReport? = runCatching {
|
||||
val text = file.readText()
|
||||
val headerEnd = text.indexOf("\n$SEPARATOR\n")
|
||||
val header = if (headerEnd >= 0) text.substring(0, headerEnd) else ""
|
||||
val trace = if (headerEnd >= 0) text.substring(headerEnd + SEPARATOR.length + 2) else text
|
||||
val timestamp = header.lineSequence().firstOrNull { it.startsWith("timestamp: ") }
|
||||
?.removePrefix("timestamp: ")
|
||||
?: "unknown time"
|
||||
val headline = trace.lineSequence().firstOrNull { it.isNotBlank() } ?: "unknown error"
|
||||
CrashReport(file = file, timestamp = timestamp, headline = headline, fullText = text)
|
||||
}.getOrNull()
|
||||
|
||||
private fun versionName(): String = runCatching {
|
||||
appContext.packageManager.getPackageInfo(appContext.packageName, 0).versionName
|
||||
}.getOrNull() ?: "unknown"
|
||||
|
||||
private fun versionCode(): Long = runCatching {
|
||||
val info = appContext.packageManager.getPackageInfo(appContext.packageName, 0)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
info.longVersionCode
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
info.versionCode.toLong()
|
||||
}
|
||||
}.getOrDefault(-1L)
|
||||
|
||||
private fun isoNow(): String =
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
.format(java.util.Date())
|
||||
|
||||
/**
|
||||
* Millis for rough chronological sorting plus a monotonic sequence number
|
||||
* so two crashes in the same millisecond (readily possible in tests, and
|
||||
* not provably impossible on-device) never collide and silently overwrite
|
||||
* each other.
|
||||
*/
|
||||
private fun fileName(): String =
|
||||
String.format(Locale.US, "$FILE_PREFIX%013d-%06d$FILE_SUFFIX", System.currentTimeMillis(), sequence.incrementAndGet())
|
||||
|
||||
private companion object {
|
||||
const val MAX_REPORTS = 5
|
||||
const val FILE_PREFIX = "crash-"
|
||||
const val FILE_SUFFIX = ".txt"
|
||||
const val SEPARATOR = "---"
|
||||
val sequence = AtomicLong(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.modg.bookshelf.ui.add
|
||||
|
||||
import org.modg.bookshelf.data.metadata.IsbnUtils
|
||||
|
||||
/**
|
||||
* The add-by-hand form's fields, independent of any particular entry point.
|
||||
* [org.modg.bookshelf.ui.nav.Routes.addBook] pre-fills whatever the entry point
|
||||
* knows — the scan screen's manual-ISBN dialog and library FAB pass just
|
||||
* title/isbn; wave 10's online-search save sheet's "Edit details" passes every
|
||||
* field. [fromRouteArgs] is the one place the route-args-to-draft mapping lives.
|
||||
* [coverSourceUrl] isn't shown anywhere in the form (no cover picker in this
|
||||
* screen) but rides along so a cover carried in from a search hit still gets
|
||||
* saved — see [AddBookViewModel.performSave].
|
||||
*/
|
||||
data class BookDraft(
|
||||
val title: String = "",
|
||||
val subtitle: String = "",
|
||||
val authors: String = "",
|
||||
val publisher: String = "",
|
||||
val publishedDate: String = "",
|
||||
val pages: String = "",
|
||||
val isbn: String = "",
|
||||
val description: String = "",
|
||||
val coverSourceUrl: String = "",
|
||||
) {
|
||||
companion object {
|
||||
fun fromRouteArgs(
|
||||
title: String? = null,
|
||||
isbn: String? = null,
|
||||
subtitle: String? = null,
|
||||
authors: String? = null,
|
||||
publisher: String? = null,
|
||||
publishedDate: String? = null,
|
||||
pages: String? = null,
|
||||
description: String? = null,
|
||||
coverSourceUrl: String? = null,
|
||||
): BookDraft = BookDraft(
|
||||
title = title.orEmpty(),
|
||||
subtitle = subtitle.orEmpty(),
|
||||
authors = authors.orEmpty(),
|
||||
publisher = publisher.orEmpty(),
|
||||
publishedDate = publishedDate.orEmpty(),
|
||||
pages = pages.orEmpty(),
|
||||
isbn = isbn.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
coverSourceUrl = coverSourceUrl.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [BookDraft.isbn] parsed per SPEC's "ISBN-10 or ISBN-13, with or without
|
||||
* hyphens" (same rule [IsbnUtils.toIsbn13] already enforces for scanning).
|
||||
* Blank is valid — no ISBN is a legal book. A non-blank value that fails to
|
||||
* parse is [Invalid], never silently dropped: wave 5 fixed exactly that
|
||||
* silent-discard bug in the scan screen's manual-ISBN dialog, and this form
|
||||
* must not reintroduce it.
|
||||
*/
|
||||
sealed interface IsbnFieldState {
|
||||
data object Blank : IsbnFieldState
|
||||
data class Valid(val isbn13: String, val isbn10: String?) : IsbnFieldState
|
||||
data object Invalid : IsbnFieldState
|
||||
}
|
||||
|
||||
fun validateIsbn(raw: String): IsbnFieldState {
|
||||
if (raw.isBlank()) return IsbnFieldState.Blank
|
||||
val isbn13 = IsbnUtils.toIsbn13(raw) ?: return IsbnFieldState.Invalid
|
||||
val normalized = IsbnUtils.normalize(raw)
|
||||
// Keep the ISBN-10 alongside the normalized ISBN-13 only when the user actually
|
||||
// typed a valid ISBN-10 — a 13-digit entry has no ISBN-10 form to record.
|
||||
val isbn10 = normalized.takeIf { it.length == 10 && IsbnUtils.isValidIsbn10(it) }
|
||||
return IsbnFieldState.Valid(isbn13, isbn10)
|
||||
}
|
||||
|
||||
/** [BookDraft.pages] — SPEC "digits only". Blank means unknown, not zero. */
|
||||
sealed interface PagesFieldState {
|
||||
data object Blank : PagesFieldState
|
||||
data class Valid(val pages: Int) : PagesFieldState
|
||||
data object Invalid : PagesFieldState
|
||||
}
|
||||
|
||||
fun validatePages(raw: String): PagesFieldState {
|
||||
if (raw.isBlank()) return PagesFieldState.Blank
|
||||
if (!raw.all(Char::isDigit)) return PagesFieldState.Invalid
|
||||
val pages = raw.toIntOrNull() ?: return PagesFieldState.Invalid
|
||||
return PagesFieldState.Valid(pages)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package org.modg.bookshelf.ui.add
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import org.modg.bookshelf.AppContainer
|
||||
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
||||
import org.modg.bookshelf.ui.components.CoverPreloadNotice
|
||||
import org.modg.bookshelf.ui.components.PaperSurface
|
||||
import org.modg.bookshelf.ui.components.PrimaryButton
|
||||
import org.modg.bookshelf.ui.components.SecondaryButton
|
||||
import org.modg.bookshelf.ui.components.saveButtonLabel
|
||||
import org.modg.bookshelf.ui.scan.DuplicateStatus
|
||||
import org.modg.bookshelf.ui.scan.ShelfPicker
|
||||
|
||||
/**
|
||||
* SPEC gap: there was no way to add a book with no barcode — every save path
|
||||
* started from a scanned/typed ISBN. A full screen, not a sheet: the library
|
||||
* has no camera context to anchor a bottom sheet to (unlike [org.modg.bookshelf.ui.scan.ManualEntrySheet]),
|
||||
* and this form is longer than that sheet's two fields.
|
||||
*/
|
||||
@Composable
|
||||
fun AddBookScreen(
|
||||
initialTitle: String?,
|
||||
initialIsbn: String?,
|
||||
onBack: () -> Unit,
|
||||
onSaved: (bookId: String) -> Unit,
|
||||
container: AppContainer,
|
||||
initialSubtitle: String? = null,
|
||||
initialAuthors: String? = null,
|
||||
initialPublisher: String? = null,
|
||||
initialPublishedDate: String? = null,
|
||||
initialPages: String? = null,
|
||||
initialDescription: String? = null,
|
||||
initialCoverSourceUrl: String? = null,
|
||||
) {
|
||||
val viewModel: AddBookViewModel = viewModel(
|
||||
factory = viewModelFactory {
|
||||
initializer {
|
||||
AddBookViewModel(
|
||||
bookRepository = container.bookRepository,
|
||||
locationRepository = container.locationRepository,
|
||||
settingsStore = container.settingsStore,
|
||||
coverDownloader = container.coverDownloader,
|
||||
initialDraft = BookDraft.fromRouteArgs(
|
||||
title = initialTitle,
|
||||
isbn = initialIsbn,
|
||||
subtitle = initialSubtitle,
|
||||
authors = initialAuthors,
|
||||
publisher = initialPublisher,
|
||||
publishedDate = initialPublishedDate,
|
||||
pages = initialPages,
|
||||
description = initialDescription,
|
||||
coverSourceUrl = initialCoverSourceUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
AddBookContent(
|
||||
state = state,
|
||||
callbacks = AddBookCallbacks(
|
||||
onTitleChanged = viewModel::onTitleChanged,
|
||||
onSubtitleChanged = viewModel::onSubtitleChanged,
|
||||
onAuthorsChanged = viewModel::onAuthorsChanged,
|
||||
onPublisherChanged = viewModel::onPublisherChanged,
|
||||
onPublishedDateChanged = viewModel::onPublishedDateChanged,
|
||||
onPagesChanged = viewModel::onPagesChanged,
|
||||
onIsbnChanged = viewModel::onIsbnChanged,
|
||||
onDescriptionChanged = viewModel::onDescriptionChanged,
|
||||
onShelfSelected = viewModel::onShelfSelected,
|
||||
onSave = { viewModel.save(onSaved) },
|
||||
onSaveAndAddAnother = viewModel::saveAndAddAnother,
|
||||
onBack = onBack,
|
||||
onRetryCover = viewModel::retryCover,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every callback [AddBookContent] needs, grouped into one object per the task's
|
||||
* `AddBookContent(state, callbacks)` shape — so the Paparazzi test can render
|
||||
* this REAL composable fed with static data, instead of a hand-rolled lookalike
|
||||
* (wave 6 shipped exactly that for Locations; HANDOFF.md records it as a known
|
||||
* soft spot this wave was told not to repeat).
|
||||
*/
|
||||
data class AddBookCallbacks(
|
||||
val onTitleChanged: (String) -> Unit,
|
||||
val onSubtitleChanged: (String) -> Unit,
|
||||
val onAuthorsChanged: (String) -> Unit,
|
||||
val onPublisherChanged: (String) -> Unit,
|
||||
val onPublishedDateChanged: (String) -> Unit,
|
||||
val onPagesChanged: (String) -> Unit,
|
||||
val onIsbnChanged: (String) -> Unit,
|
||||
val onDescriptionChanged: (String) -> Unit,
|
||||
val onShelfSelected: (String?) -> Unit,
|
||||
val onSave: () -> Unit,
|
||||
val onSaveAndAddAnother: () -> Unit,
|
||||
val onBack: () -> Unit,
|
||||
val onRetryCover: () -> Unit = {},
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AddBookContent(state: AddBookUiState, callbacks: AddBookCallbacks, modifier: Modifier = Modifier) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val titleFocusRequester = remember { FocusRequester() }
|
||||
|
||||
// "Save & add another" clears the form and SPEC wants focus back in Title.
|
||||
// savedCount only increments on a successful save (not on every keystroke), and
|
||||
// the empty-title check keeps this from re-stealing focus if the user has
|
||||
// already started typing the next book by the time recomposition catches up.
|
||||
LaunchedEffect(state.savedCount) {
|
||||
if (state.savedCount > 0 && state.draft.title.isEmpty()) {
|
||||
runCatching { titleFocusRequester.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
BookshelfScaffold(
|
||||
title = "Add a book",
|
||||
navigationIcon = {
|
||||
IconButton(onClick = callbacks.onBack) {
|
||||
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
) { innerPadding ->
|
||||
PaperSurface(modifier = Modifier.fillMaxSize()) {
|
||||
// Same fix as the setup screen (see its KDoc): imePadding OUTSIDE
|
||||
// verticalScroll shrinks the scrollable viewport instead of letting the
|
||||
// keyboard cover it, so a field low in this (longer) form isn't
|
||||
// stranded behind the IME. BookshelfScaffold's own innerPadding only
|
||||
// accounts for the top/bottom bars, not the IME, so this screen still
|
||||
// needs to handle it itself.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
if (state.lastSavedTitle != null) {
|
||||
Text(
|
||||
text = "Saved “${state.lastSavedTitle}”. ${state.savedCount} added this session.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.draft.title,
|
||||
onValueChange = callbacks.onTitleChanged,
|
||||
label = { Text("Title") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Words,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(titleFocusRequester),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.draft.subtitle,
|
||||
onValueChange = callbacks.onSubtitleChanged,
|
||||
label = { Text("Subtitle") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Words,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.draft.authors,
|
||||
onValueChange = callbacks.onAuthorsChanged,
|
||||
label = { Text("Authors (comma-separated)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Words,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.draft.publisher,
|
||||
onValueChange = callbacks.onPublisherChanged,
|
||||
label = { Text("Publisher") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Words,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.draft.publishedDate,
|
||||
onValueChange = callbacks.onPublishedDateChanged,
|
||||
label = { Text("Published") },
|
||||
placeholder = { Text("1960, or 1999-04") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.draft.pages,
|
||||
onValueChange = callbacks.onPagesChanged,
|
||||
label = { Text("Pages") },
|
||||
singleLine = true,
|
||||
isError = state.pagesError != null,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
if (state.pagesError != null) {
|
||||
Text(
|
||||
text = state.pagesError!!,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = state.draft.isbn,
|
||||
onValueChange = callbacks.onIsbnChanged,
|
||||
label = { Text("ISBN") },
|
||||
placeholder = { Text("Optional — ISBN-10 or ISBN-13") },
|
||||
singleLine = true,
|
||||
isError = state.isbnError != null,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
if (state.isbnError != null) {
|
||||
Text(
|
||||
text = state.isbnError!!,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
val duplicate = state.duplicate
|
||||
if (duplicate is DuplicateStatus.AlreadyOwned) {
|
||||
// A warning, not a block — SPEC: two copies of a book is legal. Same
|
||||
// copy the scan sheet uses, for consistency (see FoundBookSheet).
|
||||
Text(
|
||||
text = "You already own this book (\"${duplicate.title}\").",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = state.draft.description,
|
||||
onValueChange = callbacks.onDescriptionChanged,
|
||||
label = { Text("Description") },
|
||||
minLines = 3,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
|
||||
ShelfPicker(
|
||||
bookcases = state.bookcases,
|
||||
shelves = state.shelves,
|
||||
selectedShelfId = state.selectedShelfId,
|
||||
recentShelfId = state.recentShelfId,
|
||||
onShelfSelected = callbacks.onShelfSelected,
|
||||
)
|
||||
|
||||
CoverPreloadNotice(state = state.cover, onRetry = callbacks.onRetryCover)
|
||||
|
||||
if (state.saveError != null) {
|
||||
Text(
|
||||
text = "Couldn't save: ${state.saveError}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
SecondaryButton(
|
||||
text = "Save & add another",
|
||||
enabled = state.canSave,
|
||||
onClick = callbacks.onSaveAndAddAnother,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
PrimaryButton(
|
||||
text = saveButtonLabel(state.isSaving, state.cover),
|
||||
enabled = state.canSave,
|
||||
onClick = callbacks.onSave,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package org.modg.bookshelf.ui.add
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.modg.bookshelf.data.local.BookcaseEntity
|
||||
import org.modg.bookshelf.data.local.ShelfEntity
|
||||
import org.modg.bookshelf.data.prefs.SettingsStore
|
||||
import org.modg.bookshelf.data.repo.BookRepository
|
||||
import org.modg.bookshelf.data.repo.CoverDownloader
|
||||
import org.modg.bookshelf.data.repo.CoverPreload
|
||||
import org.modg.bookshelf.data.repo.CoverPreloadState
|
||||
import org.modg.bookshelf.data.repo.LocationRepository
|
||||
import org.modg.bookshelf.ui.scan.DuplicateCheck
|
||||
import org.modg.bookshelf.ui.scan.DuplicateStatus
|
||||
|
||||
/** Everything [AddBookContent] needs to render the form. */
|
||||
data class AddBookUiState(
|
||||
val draft: BookDraft = BookDraft(),
|
||||
val bookcases: List<BookcaseEntity> = emptyList(),
|
||||
val shelves: List<ShelfEntity> = emptyList(),
|
||||
val selectedShelfId: String? = null,
|
||||
val recentShelfId: String? = null,
|
||||
val duplicate: DuplicateStatus = DuplicateStatus.New,
|
||||
val isSaving: Boolean = false,
|
||||
val saveError: String? = null,
|
||||
val savedCount: Int = 0,
|
||||
val lastSavedTitle: String? = null,
|
||||
val cover: CoverPreloadState = CoverPreloadState.None,
|
||||
) {
|
||||
val isbnError: String?
|
||||
get() = if (validateIsbn(draft.isbn) is IsbnFieldState.Invalid) {
|
||||
"That isn't a valid ISBN — check the digits."
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val pagesError: String?
|
||||
get() = if (validatePages(draft.pages) is PagesFieldState.Invalid) "Pages must be a number." else null
|
||||
|
||||
/** Title is the only required field; a field in error or a save already in flight blocks both Save actions. */
|
||||
val canSave: Boolean
|
||||
get() = draft.title.isNotBlank() && isbnError == null && pagesError == null && !isSaving
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the add-by-hand form (SPEC gap: no way to add a book with no barcode).
|
||||
* Offline-first like every other screen: [bookRepository]/[locationRepository]
|
||||
* are Room only, nothing here touches the network.
|
||||
*/
|
||||
class AddBookViewModel(
|
||||
private val bookRepository: BookRepository,
|
||||
locationRepository: LocationRepository,
|
||||
private val settingsStore: SettingsStore,
|
||||
coverDownloader: CoverDownloader,
|
||||
initialDraft: BookDraft = BookDraft(),
|
||||
) : ViewModel() {
|
||||
|
||||
private data class FormState(
|
||||
val draft: BookDraft,
|
||||
val selectedShelfId: String? = null,
|
||||
val duplicate: DuplicateStatus = DuplicateStatus.New,
|
||||
val isSaving: Boolean = false,
|
||||
val saveError: String? = null,
|
||||
val savedCount: Int = 0,
|
||||
val lastSavedTitle: String? = null,
|
||||
)
|
||||
|
||||
private val _formState = MutableStateFlow(FormState(draft = initialDraft))
|
||||
|
||||
/**
|
||||
* Only a draft carried in from an online search hit ("Edit details") has a cover URL —
|
||||
* there's no cover field on this form — so this is usually [CoverPreloadState.None].
|
||||
*/
|
||||
private val coverPreload = CoverPreload(coverDownloader, viewModelScope)
|
||||
private var duplicateCheckJob: Job? = null
|
||||
|
||||
val bookcases: StateFlow<List<BookcaseEntity>> = locationRepository.observeBookcases()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
val shelves: StateFlow<List<ShelfEntity>> = locationRepository.observeShelves()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
/** Surfaced as the shelf picker's "Recent" shortcut, same as the scan screen's. */
|
||||
val recentShelfId: StateFlow<String?> = settingsStore.lastShelfId
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
// WhileSubscribed, matching Library/LocationsViewModel's uiState — but note this
|
||||
// means .value only reflects post-action state to a caller that is actively
|
||||
// collecting (e.g. Compose's collectAsState, or a test using .first()); a bare
|
||||
// .value read with no live collector sees only the seed passed to stateIn.
|
||||
val uiState: StateFlow<AddBookUiState> = combine(
|
||||
_formState, bookcases, shelves, recentShelfId, coverPreload.state,
|
||||
) { form, cases, shelfList, recent, cover ->
|
||||
AddBookUiState(
|
||||
draft = form.draft,
|
||||
bookcases = cases,
|
||||
shelves = shelfList,
|
||||
selectedShelfId = form.selectedShelfId,
|
||||
recentShelfId = recent,
|
||||
duplicate = form.duplicate,
|
||||
isSaving = form.isSaving,
|
||||
saveError = form.saveError,
|
||||
savedCount = form.savedCount,
|
||||
lastSavedTitle = form.lastSavedTitle,
|
||||
cover = cover,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AddBookUiState(draft = initialDraft))
|
||||
|
||||
init {
|
||||
// SPEC: pre-select the remembered last shelf. The scan sheet only offers this
|
||||
// as a "Recent" shortcut because it also has a per-scan shelf that sticks
|
||||
// across a continuous session; a hand-entry form has no such session anchor,
|
||||
// so here the remembered shelf IS the default.
|
||||
viewModelScope.launch {
|
||||
val remembered = settingsStore.lastShelfId.first()
|
||||
if (remembered != null) _formState.update { it.copy(selectedShelfId = remembered) }
|
||||
}
|
||||
if (initialDraft.isbn.isNotBlank()) scheduleDuplicateCheck(initialDraft.isbn)
|
||||
coverPreload.start(initialDraft.coverSourceUrl)
|
||||
}
|
||||
|
||||
fun retryCover() = coverPreload.retry()
|
||||
|
||||
fun onTitleChanged(value: String) = updateDraft { it.copy(title = value) }
|
||||
fun onSubtitleChanged(value: String) = updateDraft { it.copy(subtitle = value) }
|
||||
fun onAuthorsChanged(value: String) = updateDraft { it.copy(authors = value) }
|
||||
fun onPublisherChanged(value: String) = updateDraft { it.copy(publisher = value) }
|
||||
fun onPublishedDateChanged(value: String) = updateDraft { it.copy(publishedDate = value) }
|
||||
fun onPagesChanged(value: String) = updateDraft { it.copy(pages = value) }
|
||||
fun onDescriptionChanged(value: String) = updateDraft { it.copy(description = value) }
|
||||
|
||||
fun onIsbnChanged(value: String) {
|
||||
updateDraft { it.copy(isbn = value) }
|
||||
scheduleDuplicateCheck(value)
|
||||
}
|
||||
|
||||
fun onShelfSelected(shelfId: String?) {
|
||||
_formState.update { it.copy(selectedShelfId = shelfId) }
|
||||
}
|
||||
|
||||
private inline fun updateDraft(transform: (BookDraft) -> BookDraft) {
|
||||
_formState.update { it.copy(draft = transform(it.draft)) }
|
||||
}
|
||||
|
||||
/** SPEC scan-sheet parity: a warning, not a block — two copies of a book is legal. */
|
||||
private fun scheduleDuplicateCheck(rawIsbn: String) {
|
||||
duplicateCheckJob?.cancel()
|
||||
val isbn13 = (validateIsbn(rawIsbn) as? IsbnFieldState.Valid)?.isbn13
|
||||
if (isbn13 == null) {
|
||||
_formState.update { it.copy(duplicate = DuplicateStatus.New) }
|
||||
return
|
||||
}
|
||||
duplicateCheckJob = viewModelScope.launch {
|
||||
// A Room failure here would otherwise crash the app from a keystroke. The check
|
||||
// is only a warning, so on failure there is simply no warning.
|
||||
val duplicate = try {
|
||||
DuplicateCheck.check(bookRepository.findByIsbn13(isbn13))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
DuplicateStatus.New
|
||||
}
|
||||
_formState.update { it.copy(duplicate = duplicate) }
|
||||
}
|
||||
}
|
||||
|
||||
/** "Save" — navigates to the new book's detail screen on success (see [AddBookScreen]). */
|
||||
fun save(onSaved: (bookId: String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val id = performSave(clearAfterSave = false)
|
||||
if (id != null) onSaved(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Save & add another" — SPEC's real use case, shelving a box of books at once. */
|
||||
fun saveAndAddAnother() {
|
||||
viewModelScope.launch { performSave(clearAfterSave = true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual save, split out so tests can await it directly — same reasoning
|
||||
* as [org.modg.bookshelf.ui.scan.ScanViewModel.performSave]. HANDOFF.md flagged
|
||||
* that ViewModel's save paths as still making unguarded Room calls after wave 8
|
||||
* guarded only the lookup path; this one is guarded from the start. Catches
|
||||
* [Throwable] — [CancellationException] is rethrown FIRST, ahead of that catch,
|
||||
* because a cancelled save (screen left mid-save) is normal control flow, not a
|
||||
* failure, and must propagate rather than being reported as one. Re-entrancy is
|
||||
* guarded here rather than trusted to the caller's disabled-button state, so a
|
||||
* genuine double-tap (two taps landing before recomposition disables the
|
||||
* button) still can't create two books.
|
||||
*/
|
||||
internal suspend fun performSave(clearAfterSave: Boolean): String? {
|
||||
if (_formState.value.isSaving) return null
|
||||
val coverAtTap = coverPreload.state.value
|
||||
_formState.update { it.copy(isSaving = true, saveError = null) }
|
||||
val form = _formState.value
|
||||
val draft = form.draft
|
||||
val savedTitle = draft.title.trim()
|
||||
val bookId = try {
|
||||
// Same cover rule as the scan sheet (see ScanViewModel.performSave): a download
|
||||
// that fails while Save waits stops the save; one that had already failed when
|
||||
// Save (labelled "Save without cover") was tapped does not.
|
||||
val cover = coverPreload.awaitSettled()
|
||||
if (cover is CoverPreloadState.Failed && coverAtTap !is CoverPreloadState.Failed) {
|
||||
_formState.update { it.copy(isSaving = false) }
|
||||
return null
|
||||
}
|
||||
val isbnValidation = validateIsbn(draft.isbn)
|
||||
val isbn13 = (isbnValidation as? IsbnFieldState.Valid)?.isbn13
|
||||
val isbn10 = (isbnValidation as? IsbnFieldState.Valid)?.isbn10
|
||||
val pageCount = (validatePages(draft.pages) as? PagesFieldState.Valid)?.pages
|
||||
val authors = draft.authors.split(",").map(String::trim).filter(String::isNotEmpty)
|
||||
bookRepository.createBook(
|
||||
title = savedTitle,
|
||||
subtitle = draft.subtitle.trim().ifEmpty { null },
|
||||
authors = authors,
|
||||
isbn13 = isbn13,
|
||||
isbn10 = isbn10,
|
||||
publisher = draft.publisher.trim().ifEmpty { null },
|
||||
publishedDate = draft.publishedDate.trim().ifEmpty { null },
|
||||
pageCount = pageCount,
|
||||
description = draft.description.trim().ifEmpty { null },
|
||||
coverSourceUrl = draft.coverSourceUrl.trim().ifEmpty { null },
|
||||
coverFile = (cover as? CoverPreloadState.Ready)?.file,
|
||||
shelfId = form.selectedShelfId,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
_formState.update { it.copy(isSaving = false) }
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
_formState.update { it.copy(isSaving = false, saveError = e.javaClass.simpleName) }
|
||||
return null
|
||||
}
|
||||
// Everything below runs only once the book is written. None of it may report a
|
||||
// failure: "couldn't save" for a saved book invites a retry that creates a duplicate.
|
||||
coverPreload.discard() // createBook copied it; "add another" starts a draft with no cover
|
||||
rememberShelf(form.selectedShelfId)
|
||||
_formState.update {
|
||||
it.copy(
|
||||
draft = if (clearAfterSave) BookDraft() else it.draft,
|
||||
isSaving = false,
|
||||
saveError = null,
|
||||
savedCount = it.savedCount + 1,
|
||||
lastSavedTitle = savedTitle,
|
||||
)
|
||||
}
|
||||
if (clearAfterSave) scheduleDuplicateCheck("") // the ISBN field just cleared; the warning must too
|
||||
return bookId
|
||||
}
|
||||
|
||||
/** A remembered-shelf write that fails is swallowed — the book is already saved (see [performSave]). */
|
||||
private suspend fun rememberShelf(shelfId: String?) {
|
||||
if (shelfId == null) return // "Not shelved" must never overwrite the memory — it isn't a shelf
|
||||
try {
|
||||
settingsStore.setLastShelfId(shelfId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
// Losing the "recent shelf" shortcut is not worth an error on a saved book.
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
coverPreload.discard()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.modg.bookshelf.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.modg.bookshelf.data.repo.CoverPreloadState
|
||||
|
||||
/**
|
||||
* The one visible trace of a cover preload: nothing while it loads or once it's ready (the
|
||||
* sheet already shows the cover via Coil), and a failure line with Retry when it failed —
|
||||
* so the user finds out while still on the sheet, not after the book is saved without one.
|
||||
*/
|
||||
@Composable
|
||||
fun CoverPreloadNotice(state: CoverPreloadState, onRetry: () -> Unit, modifier: Modifier = Modifier) {
|
||||
if (state !is CoverPreloadState.Failed) return
|
||||
Row(modifier = modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Couldn't download the cover — ${state.reason}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onRetry) { Text("Retry") }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save's label. After a failed cover download, saving goes ahead without the cover file
|
||||
* (the user decided: retry OR save without it, never stuck mid-box), so the button says so.
|
||||
*/
|
||||
fun saveButtonLabel(isSaving: Boolean, cover: CoverPreloadState, idle: String = "Save"): String = when {
|
||||
isSaving -> "Saving…"
|
||||
cover is CoverPreloadState.Failed -> "$idle without cover"
|
||||
else -> idle
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -36,6 +37,9 @@ enum class SyncStatus {
|
||||
Syncing,
|
||||
Offline,
|
||||
Error,
|
||||
|
||||
/** The session expired (see SyncEngine's 2026-09-17 incident KDoc) — distinct from [Error] so the bar is tappable to re-sign-in. */
|
||||
AuthExpired,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,11 +56,16 @@ fun SyncStatusBar(
|
||||
status: SyncStatus,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
// Only [SyncStatus.AuthExpired] wires this up today (SPEC: sync failure is
|
||||
// a quiet status line, never a dialog — but "sign in again" needs SOME
|
||||
// affordance, and this bar is what the user is already looking at).
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
.let { if (onClick != null) it.clickable(onClick = onClick) else it }
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 28.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -77,7 +86,7 @@ private fun StatusDot(status: SyncStatus) {
|
||||
SyncStatus.Synced -> MaterialTheme.colorScheme.secondary
|
||||
SyncStatus.Syncing -> MaterialTheme.colorScheme.secondary
|
||||
SyncStatus.Offline -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
SyncStatus.Error -> MaterialTheme.colorScheme.error
|
||||
SyncStatus.Error, SyncStatus.AuthExpired -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
if (status == SyncStatus.Syncing) {
|
||||
val transition = rememberInfiniteTransition(label = "sync-pulse")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.modg.bookshelf.ui.library
|
||||
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.metadata.BookMetadata
|
||||
import org.modg.bookshelf.data.metadata.OnlineBook
|
||||
import org.modg.bookshelf.data.repo.decodeAuthors
|
||||
|
||||
/** SPEC.md library screen: "sort title/author/added". */
|
||||
@@ -52,3 +54,91 @@ object LibraryFilterLogic {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Online search state (task: library search "Extend it to also search Open Library
|
||||
* and Google Books"). Lives in [LibraryViewModel] so it survives rotation. Search
|
||||
* runs ONLY on submit (IME action / the explicit row), never per keystroke — decided
|
||||
* by the user, see the task brief on Google Books' 1,000/day quota.
|
||||
*/
|
||||
sealed interface OnlineSearchState {
|
||||
/** No search has run for the current query yet — the prompt row is what's shown. */
|
||||
data object NotSearched : OnlineSearchState
|
||||
|
||||
data class Searching(val query: String) : OnlineSearchState
|
||||
|
||||
/**
|
||||
* [inLibrary] is what the "In your library" section shows once a search has run — a
|
||||
* superset of [LocalBookMatcher]'s plain text match, since [org.modg.bookshelf.data.metadata.SearchResultMerger]
|
||||
* also pulls in an owned book reached only via one of the online hits (task: "an
|
||||
* online hit that is a different edition of an owned book belongs in the library
|
||||
* section"). The shelf/bookcase filter and sort are applied to it; owned matches the
|
||||
* filter hides are counted in [inLibraryOutsideFilter] instead of silently vanishing —
|
||||
* the merge still (correctly) keeps them out of the online section, so without the count
|
||||
* a book you own on another shelf would appear in neither.
|
||||
* [openLibraryFailure]/[googleBooksFailure] are that source's failure reason, null when
|
||||
* that source answered (whether or not it had anything) — the UI keys off these to
|
||||
* show a per-source failure line with Retry while still showing the other source's
|
||||
* [online] results, and to tell "both failed" (retry affordance, never "no results")
|
||||
* from an authoritative miss (both answered, nothing found anywhere).
|
||||
*/
|
||||
data class Done(
|
||||
val query: String,
|
||||
val inLibrary: List<BookEntity>,
|
||||
val online: List<OnlineBook>,
|
||||
val openLibraryFailure: String?,
|
||||
val googleBooksFailure: String?,
|
||||
val inLibraryOutsideFilter: Int = 0,
|
||||
) : OnlineSearchState
|
||||
}
|
||||
|
||||
/** The bottom sheet shown when an online result is tapped (task: "same idea as the scan screen's Found sheet"). */
|
||||
sealed interface OnlineSaveSheetState {
|
||||
data object Hidden : OnlineSaveSheetState
|
||||
|
||||
/**
|
||||
* [enrichment] fills in once the background by-ISBN lookup (started the moment
|
||||
* the sheet opens, only when [book] has an isbn13) completes — Save reads
|
||||
* whatever's here at the moment it's tapped and never waits on it, per the task's
|
||||
* "Save must NEVER wait for this".
|
||||
*/
|
||||
data class Shown(
|
||||
val book: OnlineBook,
|
||||
val selectedShelfId: String?,
|
||||
val isSaving: Boolean = false,
|
||||
val saveError: String? = null,
|
||||
val enrichment: BookMetadata? = null,
|
||||
) : OnlineSaveSheetState
|
||||
}
|
||||
|
||||
/**
|
||||
* The same OL-then-GB-then-enrichment field-fill precedence, used by BOTH
|
||||
* [LibraryViewModel.performSaveOnlineResult] (what gets persisted) and
|
||||
* [org.modg.bookshelf.ui.nav.BookshelfNavHost]'s "Edit details" route builder
|
||||
* (what pre-fills the add-by-hand form) — one place so the two can't drift apart.
|
||||
*/
|
||||
data class OnlineBookDraftFields(
|
||||
val title: String,
|
||||
val subtitle: String?,
|
||||
val authors: List<String>,
|
||||
val publisher: String?,
|
||||
val publishedDate: String?,
|
||||
val pageCount: Int?,
|
||||
val isbn13: String?,
|
||||
val isbn10: String?,
|
||||
val description: String?,
|
||||
val coverUrl: String?,
|
||||
)
|
||||
|
||||
fun resolveOnlineBookFields(book: OnlineBook, enrichment: BookMetadata?): OnlineBookDraftFields = OnlineBookDraftFields(
|
||||
title = book.title?.ifBlank { null } ?: enrichment?.title?.ifBlank { null } ?: "Untitled",
|
||||
subtitle = book.subtitle ?: enrichment?.subtitle,
|
||||
authors = book.authors.ifEmpty { enrichment?.authors.orEmpty() },
|
||||
publisher = book.publisher ?: enrichment?.publisher,
|
||||
publishedDate = enrichment?.publishedDate ?: book.year?.toString(),
|
||||
pageCount = book.pageCount ?: enrichment?.pageCount,
|
||||
isbn13 = book.isbn13 ?: enrichment?.isbn13,
|
||||
isbn10 = enrichment?.isbn10,
|
||||
description = enrichment?.description,
|
||||
coverUrl = book.coverUrl ?: enrichment?.coverUrl,
|
||||
)
|
||||
@@ -1,23 +1,35 @@
|
||||
package org.modg.bookshelf.ui.library
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Clear
|
||||
import androidx.compose.material.icons.outlined.EditNote
|
||||
import androidx.compose.material.icons.outlined.FilterList
|
||||
import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Sort
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -25,9 +37,13 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -37,6 +53,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -48,19 +65,33 @@ import org.modg.bookshelf.R
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.local.BookcaseEntity
|
||||
import org.modg.bookshelf.data.local.ShelfEntity
|
||||
import org.modg.bookshelf.data.metadata.BookMetadata
|
||||
import org.modg.bookshelf.data.metadata.OnlineBook
|
||||
import org.modg.bookshelf.data.repo.decodeAuthors
|
||||
import org.modg.bookshelf.data.repo.CoverPreloadState
|
||||
import org.modg.bookshelf.ui.components.BookCover
|
||||
import org.modg.bookshelf.ui.components.CoverPreloadNotice
|
||||
import org.modg.bookshelf.ui.components.EmptyState
|
||||
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
||||
import org.modg.bookshelf.ui.components.GoldDivider
|
||||
import org.modg.bookshelf.ui.components.PaperSurface
|
||||
import org.modg.bookshelf.ui.components.PrimaryButton
|
||||
import org.modg.bookshelf.ui.components.SecondaryButton
|
||||
import org.modg.bookshelf.ui.components.saveButtonLabel
|
||||
import org.modg.bookshelf.ui.components.SyncStatus
|
||||
import org.modg.bookshelf.ui.components.SyncStatusBar
|
||||
import org.modg.bookshelf.ui.scan.ShelfPicker
|
||||
|
||||
/**
|
||||
* SPEC.md "library" screen: adaptive cover grid, search, filter, sort, empty
|
||||
* state, FAB to scan, sync status line. Covers carry the color; card chrome
|
||||
* stays minimal (cover + two lines of text) on purpose.
|
||||
*
|
||||
* Wave 10 (online search) extends the search bar: a blank query keeps the
|
||||
* plain cover grid, but any query switches this screen into a sectioned
|
||||
* search-results view — "In your library" (instant, local, as-you-type) above
|
||||
* "Online" (Open Library + Google Books, submit-only) — see
|
||||
* [LibrarySearchResultsContent].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -68,9 +99,16 @@ fun LibraryScreen(
|
||||
shelfIdFilter: String?,
|
||||
onBookClick: (String) -> Unit,
|
||||
onScanClick: () -> Unit,
|
||||
onAddByHandClick: () -> Unit,
|
||||
onLocationsClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
container: AppContainer,
|
||||
onEnterByHandWithQuery: (String) -> Unit = { onAddByHandClick() },
|
||||
onEditOnlineResult: (OnlineBook, BookMetadata?) -> Unit = { _, _ -> },
|
||||
// 2026-09-17 incident: the user is looking at THIS screen when the
|
||||
// session expires, not the setup screen — MainActivity's start-destination
|
||||
// check only runs on a fresh launch. Defaults to a no-op for previews/tests.
|
||||
onAuthExpired: () -> Unit = {},
|
||||
) {
|
||||
val viewModel: LibraryViewModel = viewModel(
|
||||
// A fresh VM per distinct shelf filter, so tapping a different shelf
|
||||
@@ -83,6 +121,9 @@ fun LibraryScreen(
|
||||
locationRepository = container.locationRepository,
|
||||
syncEngine = container.syncEngine,
|
||||
settingsStore = container.settingsStore,
|
||||
bookSearchRepository = container.bookSearchRepository,
|
||||
metadataRepository = container.metadataRepository,
|
||||
coverDownloader = container.coverDownloader,
|
||||
initialShelfId = shelfIdFilter,
|
||||
)
|
||||
}
|
||||
@@ -93,6 +134,10 @@ fun LibraryScreen(
|
||||
val query by viewModel.query.collectAsState()
|
||||
val sortOption by viewModel.sortOption.collectAsState()
|
||||
val filter by viewModel.filter.collectAsState()
|
||||
val onlineSearch by viewModel.onlineSearch.collectAsState()
|
||||
val saveSheet by viewModel.saveSheet.collectAsState()
|
||||
val recentShelfId by viewModel.recentShelfId.collectAsState()
|
||||
val cover by viewModel.cover.collectAsState()
|
||||
|
||||
BookshelfScaffold(
|
||||
title = "Bookshelf",
|
||||
@@ -105,12 +150,26 @@ fun LibraryScreen(
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = onScanClick) {
|
||||
Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book")
|
||||
// Scan stays the one primary action (it's the SPEC use case — a box of
|
||||
// books, one after another); "Add by hand" is a small satellite FAB
|
||||
// stacked above it rather than a second full-size FAB competing for
|
||||
// attention, or a toolbar icon that would bury it below Locations/Settings.
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
SmallFloatingActionButton(onClick = onAddByHandClick) {
|
||||
Icon(Icons.Outlined.EditNote, contentDescription = "Add a book by hand")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
FloatingActionButton(onClick = onScanClick) {
|
||||
Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book")
|
||||
}
|
||||
}
|
||||
},
|
||||
syncStatusBar = {
|
||||
SyncStatusBar(status = uiState.syncBar.status, label = uiState.syncBar.label)
|
||||
SyncStatusBar(
|
||||
status = uiState.syncBar.status,
|
||||
label = uiState.syncBar.label,
|
||||
onClick = if (uiState.syncBar.status == SyncStatus.AuthExpired) onAuthExpired else null,
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
PullToRefreshBox(
|
||||
@@ -125,6 +184,7 @@ fun LibraryScreen(
|
||||
LibraryToolbar(
|
||||
query = query,
|
||||
onQueryChange = viewModel::onQueryChange,
|
||||
onSearchSubmit = viewModel::submitOnlineSearch,
|
||||
sortOption = sortOption,
|
||||
onSortOptionChange = viewModel::onSortOptionChange,
|
||||
filter = filter,
|
||||
@@ -134,10 +194,29 @@ fun LibraryScreen(
|
||||
)
|
||||
|
||||
when {
|
||||
query.isNotBlank() -> LibrarySearchResultsContent(
|
||||
query = query,
|
||||
// Once an online search has run, the merged set — it includes owned
|
||||
// editions reached only through an online hit, which the plain text
|
||||
// match can't find and the merge has already removed from "Online".
|
||||
localMatches = (onlineSearch as? OnlineSearchState.Done)?.inLibrary ?: uiState.books,
|
||||
inLibraryOutsideFilter = (onlineSearch as? OnlineSearchState.Done)?.inLibraryOutsideFilter ?: 0,
|
||||
onlineSearch = onlineSearch,
|
||||
onBookClick = onBookClick,
|
||||
onSubmitOnlineSearch = viewModel::submitOnlineSearch,
|
||||
onRetryOnlineSearch = viewModel::retryOnlineSearch,
|
||||
onOnlineResultClick = viewModel::onOnlineResultTapped,
|
||||
onEnterByHand = { onEnterByHandWithQuery(query.trim()) },
|
||||
)
|
||||
!uiState.hasAnyBooks -> EmptyState(
|
||||
title = "Your shelves are empty",
|
||||
message = "Scan a barcode to add your first book.",
|
||||
action = { PrimaryButton(text = "Scan a book", onClick = onScanClick) },
|
||||
action = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
PrimaryButton(text = "Scan a book", onClick = onScanClick)
|
||||
SecondaryButton(text = "Add by hand", onClick = onAddByHandClick)
|
||||
}
|
||||
},
|
||||
)
|
||||
uiState.books.isEmpty() -> EmptyState(
|
||||
title = "No books match",
|
||||
@@ -159,6 +238,27 @@ fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sheetState = saveSheet
|
||||
if (sheetState is OnlineSaveSheetState.Shown) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = viewModel::dismissSaveSheet,
|
||||
sheetState = rememberModalBottomSheetState(),
|
||||
) {
|
||||
OnlineResultSaveSheet(
|
||||
state = sheetState,
|
||||
bookcases = uiState.bookcases,
|
||||
shelves = uiState.shelves,
|
||||
recentShelfId = recentShelfId,
|
||||
onShelfSelected = viewModel::onSaveSheetShelfSelected,
|
||||
onSave = viewModel::saveOnlineResult,
|
||||
onEditDetails = { viewModel.takeForEditing()?.let { (book, enrichment) -> onEditOnlineResult(book, enrichment) } },
|
||||
onSkip = viewModel::dismissSaveSheet,
|
||||
cover = cover,
|
||||
onRetryCover = viewModel::retryCover,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -171,6 +271,7 @@ internal fun LibraryToolbar(
|
||||
onFilterChange: (LibraryFilter) -> Unit,
|
||||
bookcases: List<BookcaseEntity>,
|
||||
shelves: List<ShelfEntity>,
|
||||
onSearchSubmit: () -> Unit = {},
|
||||
) {
|
||||
var filterMenuExpanded by remember { mutableStateOf(false) }
|
||||
var sortMenuExpanded by remember { mutableStateOf(false) }
|
||||
@@ -196,6 +297,10 @@ internal fun LibraryToolbar(
|
||||
}
|
||||
}
|
||||
},
|
||||
// Wave 10: IME Search triggers the online search (task: "ONLY ON SUBMIT
|
||||
// (IME Search action, or an explicit button) — never per keystroke").
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { onSearchSubmit() }),
|
||||
)
|
||||
|
||||
Column {
|
||||
@@ -267,8 +372,8 @@ internal fun LibraryToolbar(
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LibraryBookCard(book: BookEntity, onClick: () -> Unit) {
|
||||
Column(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||
internal fun LibraryBookCard(book: BookEntity, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||
BookCover(
|
||||
coverUrl = book.coverUrl ?: book.coverSourceUrl,
|
||||
contentDescription = book.title,
|
||||
@@ -300,3 +405,321 @@ internal fun LibraryBookCard(book: BookEntity, onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wave 10: online search results ---
|
||||
|
||||
/**
|
||||
* The sectioned view shown whenever the search bar carries a non-blank query —
|
||||
* "In your library" (instant local matches, existing card style, horizontally
|
||||
* scrollable so it never fights the outer vertical scroll) above "Online"
|
||||
* (Open Library + Google Books, submit-only, as rows). Split out as its own
|
||||
* stateless composable — fed real state, not hand-rolled — so Paparazzi
|
||||
* renders the actual screen (see HANDOFF.md wave-6 "soft spots").
|
||||
*/
|
||||
@Composable
|
||||
internal fun LibrarySearchResultsContent(
|
||||
query: String,
|
||||
localMatches: List<BookEntity>,
|
||||
onlineSearch: OnlineSearchState,
|
||||
onBookClick: (String) -> Unit,
|
||||
onSubmitOnlineSearch: () -> Unit,
|
||||
onRetryOnlineSearch: () -> Unit,
|
||||
onOnlineResultClick: (OnlineBook) -> Unit,
|
||||
onEnterByHand: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
inLibraryOutsideFilter: Int = 0,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 24.dp),
|
||||
) {
|
||||
SearchSectionHeader(text = "In your library")
|
||||
if (localMatches.isEmpty()) {
|
||||
// The header stays up even with nothing to show — task: the user must be
|
||||
// able to tell "not owned" from "didn't look".
|
||||
Text(
|
||||
text = "No matches in your library",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
localMatches.forEach { book ->
|
||||
LibraryBookCard(book = book, onClick = { onBookClick(book.id) }, modifier = Modifier.width(112.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inLibraryOutsideFilter > 0) {
|
||||
Text(
|
||||
text = if (inLibraryOutsideFilter == 1) {
|
||||
"1 more in your library, outside the current filter"
|
||||
} else {
|
||||
"$inLibraryOutsideFilter more in your library, outside the current filter"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
// Extra top space: at 4dp it sat under the last card's author and read as its byline.
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
GoldDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
SearchSectionHeader(text = "Online")
|
||||
OnlineSearchSection(
|
||||
query = query,
|
||||
state = onlineSearch,
|
||||
onSubmit = onSubmitOnlineSearch,
|
||||
onRetry = onRetryOnlineSearch,
|
||||
onResultClick = onOnlineResultClick,
|
||||
)
|
||||
|
||||
TextButton(onClick = onEnterByHand, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Text("Can't find it? Enter it by hand")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchSectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Minimum characters before the online-search prompt/submit is offered at all. */
|
||||
internal const val OnlineSearchMinQueryLength = 2
|
||||
|
||||
@Composable
|
||||
private fun OnlineSearchSection(
|
||||
query: String,
|
||||
state: OnlineSearchState,
|
||||
onSubmit: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onResultClick: (OnlineBook) -> Unit,
|
||||
) {
|
||||
val trimmed = query.trim()
|
||||
when (state) {
|
||||
is OnlineSearchState.NotSearched -> {
|
||||
if (trimmed.length >= OnlineSearchMinQueryLength) {
|
||||
SearchPromptRow(query = trimmed, onClick = onSubmit)
|
||||
} else {
|
||||
Text(
|
||||
text = "Keep typing to search Open Library and Google Books.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
is OnlineSearchState.Searching -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text(
|
||||
text = "Searching Open Library and Google Books…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
is OnlineSearchState.Done -> OnlineSearchDoneSection(state = state, onRetry = onRetry, onResultClick = onResultClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnlineSearchDoneSection(state: OnlineSearchState.Done, onRetry: () -> Unit, onResultClick: (OnlineBook) -> Unit) {
|
||||
val bothFailed = state.openLibraryFailure != null && state.googleBooksFailure != null
|
||||
if (bothFailed) {
|
||||
// Task: "both failed -> a retry affordance that does NOT say 'no results'".
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
|
||||
Text(
|
||||
text = "Couldn't search online",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "This doesn't mean there are no results — neither source could be reached.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
PrimaryButton(text = "Retry", onClick = onRetry, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
state.openLibraryFailure?.let { FailureLine(message = "Open Library couldn't be reached — $it", onRetry = onRetry) }
|
||||
state.googleBooksFailure?.let { FailureLine(message = "Google Books couldn't be reached — $it", onRetry = onRetry) }
|
||||
|
||||
if (state.online.isEmpty()) {
|
||||
Text(
|
||||
text = "No online results for \"${state.query}\".",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
} else {
|
||||
Column {
|
||||
state.online.forEach { book -> OnlineResultRow(book = book, onClick = { onResultClick(book) }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchPromptRow(query: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Outlined.Search, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
Text(
|
||||
text = "Search Open Library & Google Books for \"$query\"",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FailureLine(message: String, onRetry: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onRetry) { Text("Retry") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun OnlineResultRow(book: OnlineBook, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
BookCover(coverUrl = book.coverUrl, contentDescription = book.title, modifier = Modifier.width(44.dp))
|
||||
Column(modifier = Modifier.padding(start = 12.dp).weight(1f)) {
|
||||
Text(
|
||||
text = book.title ?: "Untitled",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitleParts = listOfNotNull(
|
||||
book.authors.takeIf { it.isNotEmpty() }?.joinToString(", "),
|
||||
book.year?.toString(),
|
||||
)
|
||||
if (subtitleParts.isNotEmpty()) {
|
||||
Text(
|
||||
text = subtitleParts.joinToString(" • "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The save sheet for an online result — task: "same idea as the scan screen's
|
||||
* Found sheet: book summary + shelf picker + Save / Skip", plus "Edit details".
|
||||
*/
|
||||
@Composable
|
||||
internal fun OnlineResultSaveSheet(
|
||||
state: OnlineSaveSheetState.Shown,
|
||||
bookcases: List<BookcaseEntity>,
|
||||
shelves: List<ShelfEntity>,
|
||||
recentShelfId: String?,
|
||||
onShelfSelected: (String?) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onEditDetails: () -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
cover: CoverPreloadState = CoverPreloadState.None,
|
||||
onRetryCover: () -> Unit = {},
|
||||
) {
|
||||
val book = state.book
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.size(width = 100.dp, height = 150.dp),
|
||||
) {
|
||||
BookCover(coverUrl = book.coverUrl, contentDescription = book.title, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
Text(
|
||||
text = book.title ?: "Untitled",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
if (book.authors.isNotEmpty()) {
|
||||
Text(text = book.authors.joinToString(", "), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
val meta = listOfNotNull(book.year?.toString(), book.isbn13)
|
||||
if (meta.isNotEmpty()) {
|
||||
Text(
|
||||
text = meta.joinToString(" • "),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
if (state.saveError != null) {
|
||||
Text(
|
||||
text = "Couldn't save: ${state.saveError}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
ShelfPicker(
|
||||
bookcases = bookcases,
|
||||
shelves = shelves,
|
||||
selectedShelfId = state.selectedShelfId,
|
||||
recentShelfId = recentShelfId,
|
||||
onShelfSelected = onShelfSelected,
|
||||
)
|
||||
CoverPreloadNotice(state = cover, onRetry = onRetryCover)
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
SecondaryButton(text = "Skip", onClick = onSkip, modifier = Modifier.weight(1f))
|
||||
SecondaryButton(text = "Edit details", onClick = onEditDetails, modifier = Modifier.weight(1f))
|
||||
}
|
||||
PrimaryButton(
|
||||
text = saveButtonLabel(state.isSaving, cover),
|
||||
enabled = !state.isSaving,
|
||||
onClick = onSave,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ enum class SyncOutcome {
|
||||
SUCCESS,
|
||||
FAILURE,
|
||||
NO_SERVER,
|
||||
|
||||
/** See [org.modg.bookshelf.data.repo.SyncResult.AuthExpired] — the fix is "sign in again", not "retry". */
|
||||
AUTH_EXPIRED,
|
||||
}
|
||||
|
||||
data class SyncBarState(val status: SyncStatus, val label: String)
|
||||
@@ -29,6 +32,7 @@ object LibrarySyncPresenter {
|
||||
return when (lastOutcome) {
|
||||
SyncOutcome.FAILURE -> SyncBarState(SyncStatus.Error, "Sync failed — showing local library")
|
||||
SyncOutcome.NO_SERVER -> SyncBarState(SyncStatus.Offline, "No server configured")
|
||||
SyncOutcome.AUTH_EXPIRED -> SyncBarState(SyncStatus.AuthExpired, "Signed out — sign in again to sync")
|
||||
SyncOutcome.SUCCESS, SyncOutcome.NONE -> if (lastSyncMillis != null) {
|
||||
SyncBarState(SyncStatus.Synced, "Synced ${elapsedPhrase(nowMillis - lastSyncMillis)}")
|
||||
} else {
|
||||
|
||||
@@ -2,21 +2,38 @@ package org.modg.bookshelf.ui.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.local.BookcaseEntity
|
||||
import org.modg.bookshelf.data.local.ShelfEntity
|
||||
import org.modg.bookshelf.data.metadata.BookMetadata
|
||||
import org.modg.bookshelf.data.metadata.BookSearchRepository
|
||||
import org.modg.bookshelf.data.metadata.IsbnUtils
|
||||
import org.modg.bookshelf.data.metadata.LookupResult
|
||||
import org.modg.bookshelf.data.metadata.MetadataRepository
|
||||
import org.modg.bookshelf.data.metadata.OnlineBook
|
||||
import org.modg.bookshelf.data.metadata.SearchHit
|
||||
import org.modg.bookshelf.data.metadata.SearchResultMerger
|
||||
import org.modg.bookshelf.data.metadata.SearchSourceResult
|
||||
import org.modg.bookshelf.data.prefs.SettingsStore
|
||||
import org.modg.bookshelf.data.repo.BookRepository
|
||||
import org.modg.bookshelf.data.repo.CoverDownloader
|
||||
import org.modg.bookshelf.data.repo.CoverPreload
|
||||
import org.modg.bookshelf.data.repo.CoverPreloadState
|
||||
import org.modg.bookshelf.data.repo.LocationRepository
|
||||
import org.modg.bookshelf.data.repo.SyncEngine
|
||||
import org.modg.bookshelf.data.repo.SyncResult
|
||||
@@ -37,7 +54,12 @@ class LibraryViewModel(
|
||||
private val locationRepository: LocationRepository,
|
||||
private val syncEngine: SyncEngine,
|
||||
private val settingsStore: SettingsStore,
|
||||
private val bookSearchRepository: BookSearchRepository,
|
||||
private val metadataRepository: MetadataRepository,
|
||||
coverDownloader: CoverDownloader,
|
||||
initialShelfId: String?,
|
||||
/** Where the per-keystroke match and the search merge run — off the main thread. Tests pass their own. */
|
||||
computeDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _query = MutableStateFlow("")
|
||||
@@ -65,12 +87,32 @@ class LibraryViewModel(
|
||||
.map { shelves -> shelves.groupBy({ it.bookcaseId }, { it.id }).mapValues { it.value.toSet() } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||
|
||||
private val hasAnyBooks: StateFlow<Boolean> = bookRepository.observeAll()
|
||||
/** Every non-deleted owned book — the base [allBooks] both the grid and the search/merge logic read from. */
|
||||
private val allBooks: StateFlow<List<BookEntity>> = bookRepository.observeAll()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
/** [allBooks] normalized once per change, not once per keystroke — see [LocalBookMatcher.IndexedBook]. */
|
||||
private val allBooksIndex: StateFlow<List<LocalBookMatcher.IndexedBook>> = allBooks
|
||||
.map { LocalBookMatcher.index(it) }
|
||||
.flowOn(computeDispatcher)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val hasAnyBooks: StateFlow<Boolean> = allBooks
|
||||
.map { it.isNotEmpty() }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
|
||||
|
||||
/**
|
||||
* Instant, offline, as-you-type local match (task: "Own library filters AS THEY
|
||||
* TYPE"). [LocalBookMatcher] replaces [org.modg.bookshelf.data.local.BookDao.search]'s
|
||||
* single whole-string LIKE for this screen, which couldn't match "hobbit tolkien"
|
||||
* at all. A blank query means "browse everything", not "match nothing".
|
||||
*/
|
||||
private val localMatches: StateFlow<List<BookEntity>> = combine(_query, allBooksIndex) { queryValue, index ->
|
||||
if (queryValue.isBlank()) index.map { it.book } else LocalBookMatcher.matchIndexed(index, queryValue)
|
||||
}.flowOn(computeDispatcher).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val filteredSortedBooks: StateFlow<List<BookEntity>> = combine(
|
||||
_query.flatMapLatest { bookRepository.search(it) },
|
||||
localMatches,
|
||||
_filter,
|
||||
_sortOption,
|
||||
shelfIdsByBookcase,
|
||||
@@ -78,6 +120,92 @@ class LibraryViewModel(
|
||||
LibrarySort.sort(LibraryFilterLogic.apply(books, filterValue, shelfMap), sort)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
// --- online search (task: extend the search bar to also search Open Library/Google Books) ---
|
||||
|
||||
private sealed interface OnlineSearchPhase {
|
||||
data object NotSearched : OnlineSearchPhase
|
||||
data class Searching(val query: String) : OnlineSearchPhase
|
||||
data class Done(val hits: RawHits) : OnlineSearchPhase
|
||||
}
|
||||
|
||||
private data class RawHits(
|
||||
val query: String,
|
||||
val openLibrary: List<SearchHit>,
|
||||
val googleBooks: List<SearchHit>,
|
||||
val openLibraryFailure: String?,
|
||||
val googleBooksFailure: String?,
|
||||
)
|
||||
|
||||
private val _onlineSearchPhase = MutableStateFlow<OnlineSearchPhase>(OnlineSearchPhase.NotSearched)
|
||||
|
||||
/** Internal (not private) so tests can join it to deterministically wait out a cancelled search. */
|
||||
internal var onlineSearchJob: Job? = null
|
||||
|
||||
/**
|
||||
* Recomputed from [allBooks] on every change, not frozen at search time — so a
|
||||
* book saved from the online section's save sheet (or from anywhere else, e.g.
|
||||
* sync) immediately migrates itself from "Online" into "In your library" via the
|
||||
* normal Room flow, with no hand-written "move it over" step.
|
||||
*/
|
||||
val onlineSearch: StateFlow<OnlineSearchState> = combine(
|
||||
_onlineSearchPhase,
|
||||
allBooksIndex,
|
||||
_filter,
|
||||
_sortOption,
|
||||
shelfIdsByBookcase,
|
||||
) { phase, index, filterValue, sort, shelfMap ->
|
||||
when (phase) {
|
||||
is OnlineSearchPhase.NotSearched -> OnlineSearchState.NotSearched
|
||||
is OnlineSearchPhase.Searching -> OnlineSearchState.Searching(phase.query)
|
||||
is OnlineSearchPhase.Done -> buildDoneState(phase.hits, index, filterValue, sort, shelfMap)
|
||||
}
|
||||
}.flowOn(computeDispatcher).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), OnlineSearchState.NotSearched)
|
||||
|
||||
/**
|
||||
* The merge runs against the WHOLE library (ownership doesn't depend on the filter),
|
||||
* then the shelf/bookcase filter and sort are applied to what "In your library" shows.
|
||||
*/
|
||||
private fun buildDoneState(
|
||||
raw: RawHits,
|
||||
index: List<LocalBookMatcher.IndexedBook>,
|
||||
filterValue: LibraryFilter,
|
||||
sort: LibrarySortOption,
|
||||
shelfMap: Map<String, Set<String>>,
|
||||
): OnlineSearchState.Done {
|
||||
val onlineIsbns = (raw.openLibrary.asSequence() + raw.googleBooks.asSequence())
|
||||
.flatMap { it.isbn13s }
|
||||
.toSet()
|
||||
val isbnLinked = index.map { it.book }.filter { book -> bookIsbn13s(book).any { it in onlineIsbns } }
|
||||
val candidates = (LocalBookMatcher.matchIndexed(index, raw.query) + isbnLinked).distinctBy { it.id }
|
||||
val merged = SearchResultMerger.merge(candidates, raw.openLibrary, raw.googleBooks)
|
||||
val visible = LibraryFilterLogic.apply(merged.inLibrary, filterValue, shelfMap)
|
||||
return OnlineSearchState.Done(
|
||||
query = raw.query,
|
||||
inLibrary = LibrarySort.sort(visible, sort),
|
||||
online = merged.online,
|
||||
openLibraryFailure = raw.openLibraryFailure,
|
||||
googleBooksFailure = raw.googleBooksFailure,
|
||||
inLibraryOutsideFilter = merged.inLibrary.size - visible.size,
|
||||
)
|
||||
}
|
||||
|
||||
private fun bookIsbn13s(book: BookEntity): List<String> =
|
||||
listOfNotNull(book.isbn13?.let(IsbnUtils::toIsbn13), book.isbn10?.let(IsbnUtils::toIsbn13))
|
||||
|
||||
// --- online result save sheet ---
|
||||
|
||||
private val _saveSheet = MutableStateFlow<OnlineSaveSheetState>(OnlineSaveSheetState.Hidden)
|
||||
val saveSheet: StateFlow<OnlineSaveSheetState> = _saveSheet.asStateFlow()
|
||||
private var enrichmentJob: Job? = null
|
||||
|
||||
/** The save sheet's cover, downloading from the moment the sheet opens — one result at a time, never the whole list. */
|
||||
private val coverPreload = CoverPreload(coverDownloader, viewModelScope)
|
||||
val cover: StateFlow<CoverPreloadState> = coverPreload.state
|
||||
|
||||
/** Surfaced as the save sheet's shelf picker default, same as the scan/add-by-hand sheets. */
|
||||
val recentShelfId: StateFlow<String?> = settingsStore.lastShelfId
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
private val syncBar: StateFlow<SyncBarState> = combine(
|
||||
settingsStore.lastSyncTime,
|
||||
_isSyncing,
|
||||
@@ -104,8 +232,16 @@ class LibraryViewModel(
|
||||
refreshSync()
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing the query after a submit clears the online section and cancels any
|
||||
* in-flight search (task: stale results for a different query are misleading).
|
||||
*/
|
||||
fun onQueryChange(value: String) {
|
||||
_query.value = value
|
||||
if (_onlineSearchPhase.value !is OnlineSearchPhase.NotSearched) {
|
||||
onlineSearchJob?.cancel()
|
||||
_onlineSearchPhase.value = OnlineSearchPhase.NotSearched
|
||||
}
|
||||
}
|
||||
|
||||
fun onSortOptionChange(option: LibrarySortOption) {
|
||||
@@ -116,6 +252,172 @@ class LibraryViewModel(
|
||||
_filter.value = filter
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs on submit (IME Search action / the explicit prompt row) ONLY — never per
|
||||
* keystroke, per the task's decision on Google Books' 1,000/day quota and Open
|
||||
* Library's latency. [org.modg.bookshelf.ui.library.LibraryScreen]'s minimum
|
||||
* query length (2) is enforced here too, so a stray programmatic call can't
|
||||
* burn quota on a near-empty query either.
|
||||
*/
|
||||
fun submitOnlineSearch() {
|
||||
val trimmed = _query.value.trim()
|
||||
if (trimmed.length < 2) return
|
||||
onlineSearchJob?.cancel()
|
||||
onlineSearchJob = viewModelScope.launch { performOnlineSearch(trimmed) }
|
||||
}
|
||||
|
||||
/** Retries both sources — used by the "both failed" retry affordance and each per-source failure line. */
|
||||
fun retryOnlineSearch() = submitOnlineSearch()
|
||||
|
||||
/**
|
||||
* Internal (not private) so tests can await it directly, same reasoning as
|
||||
* [org.modg.bookshelf.ui.scan.ScanViewModel.runLookup]. [BookSearchRepository]'s
|
||||
* clients already never throw (see their KDoc), but this guard is the same
|
||||
* belt-and-braces backstop that class documents.
|
||||
*/
|
||||
internal suspend fun performOnlineSearch(query: String) {
|
||||
_onlineSearchPhase.value = OnlineSearchPhase.Searching(query)
|
||||
val raw = try {
|
||||
val results = bookSearchRepository.search(query)
|
||||
RawHits(
|
||||
query = query,
|
||||
openLibrary = (results.openLibrary as? SearchSourceResult.Found)?.hits.orEmpty(),
|
||||
googleBooks = (results.googleBooks as? SearchSourceResult.Found)?.hits.orEmpty(),
|
||||
openLibraryFailure = (results.openLibrary as? SearchSourceResult.Failed)?.reason,
|
||||
googleBooksFailure = (results.googleBooks as? SearchSourceResult.Failed)?.reason,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
val reason = "unexpected: ${e.javaClass.simpleName}"
|
||||
RawHits(query, emptyList(), emptyList(), reason, reason)
|
||||
}
|
||||
_onlineSearchPhase.value = OnlineSearchPhase.Done(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the save sheet and, when [book] carries an ISBN, kicks off the same
|
||||
* by-ISBN enrichment lookup the scan flow uses in the background — Save never
|
||||
* waits on it (see [performSaveOnlineResult]); it's cancelled when the sheet closes.
|
||||
*/
|
||||
fun onOnlineResultTapped(book: OnlineBook) {
|
||||
enrichmentJob?.cancel()
|
||||
_saveSheet.value = OnlineSaveSheetState.Shown(book = book, selectedShelfId = recentShelfId.value)
|
||||
coverPreload.start(resolveOnlineBookFields(book, enrichment = null).coverUrl)
|
||||
val isbn13 = book.isbn13 ?: return
|
||||
enrichmentJob = viewModelScope.launch {
|
||||
val result = metadataRepository.lookup(isbn13)
|
||||
if (result is LookupResult.Found) {
|
||||
_saveSheet.update { state ->
|
||||
if (state is OnlineSaveSheetState.Shown && state.book == book) state.copy(enrichment = result.metadata) else state
|
||||
}
|
||||
// A no-op unless enrichment supplied the only cover (the hit's own wins, see resolveOnlineBookFields).
|
||||
val shown = _saveSheet.value as? OnlineSaveSheetState.Shown
|
||||
if (shown?.book == book) coverPreload.start(resolveOnlineBookFields(book, shown.enrichment).coverUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun retryCover() = coverPreload.retry()
|
||||
|
||||
/**
|
||||
* "Edit details": closes the sheet and hands its book to the add-by-hand screen. It must
|
||||
* close here — left open, the sheet was still up (Save enabled) on returning to the
|
||||
* library after saving that same book from the add screen, one tap from a duplicate.
|
||||
*/
|
||||
fun takeForEditing(): Pair<OnlineBook, BookMetadata?>? {
|
||||
val shown = _saveSheet.value as? OnlineSaveSheetState.Shown ?: return null
|
||||
dismissSaveSheet()
|
||||
return shown.book to shown.enrichment
|
||||
}
|
||||
|
||||
fun onSaveSheetShelfSelected(shelfId: String?) {
|
||||
_saveSheet.update { if (it is OnlineSaveSheetState.Shown) it.copy(selectedShelfId = shelfId) else it }
|
||||
}
|
||||
|
||||
fun dismissSaveSheet() {
|
||||
enrichmentJob?.cancel()
|
||||
coverPreload.discard()
|
||||
_saveSheet.value = OnlineSaveSheetState.Hidden
|
||||
}
|
||||
|
||||
fun saveOnlineResult() {
|
||||
viewModelScope.launch { performSaveOnlineResult() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Split out so tests can await it directly (same pattern as
|
||||
* [org.modg.bookshelf.ui.add.AddBookViewModel.performSave]). Re-entrancy guarded
|
||||
* here so a genuine double-tap can't create two books. Reads whatever
|
||||
* [OnlineSaveSheetState.Shown.enrichment] happens to hold at this instant — it
|
||||
* never awaits the enrichment lookup, per the task's "Save must NEVER wait for this".
|
||||
*/
|
||||
internal suspend fun performSaveOnlineResult(): String? {
|
||||
val state = _saveSheet.value as? OnlineSaveSheetState.Shown ?: return null
|
||||
if (state.isSaving) return null
|
||||
val coverAtTap = coverPreload.state.value
|
||||
_saveSheet.update { if (it is OnlineSaveSheetState.Shown) it.copy(isSaving = true, saveError = null) else it }
|
||||
var shelfId = state.selectedShelfId
|
||||
|
||||
val id = try {
|
||||
// Same cover rule as the scan sheet (see ScanViewModel.performSave): a download
|
||||
// that fails while Save waits stops the save; one that had already failed when
|
||||
// "Save without cover" was tapped does not.
|
||||
val cover = coverPreload.awaitSettled()
|
||||
if (cover is CoverPreloadState.Failed && coverAtTap !is CoverPreloadState.Failed) {
|
||||
_saveSheet.update { if (it is OnlineSaveSheetState.Shown) it.copy(isSaving = false) else it }
|
||||
return null
|
||||
}
|
||||
// Read the sheet again: enrichment may have landed during the wait.
|
||||
val latest = _saveSheet.value as? OnlineSaveSheetState.Shown ?: state
|
||||
val fields = resolveOnlineBookFields(latest.book, latest.enrichment)
|
||||
shelfId = latest.selectedShelfId
|
||||
bookRepository.createBook(
|
||||
title = fields.title,
|
||||
subtitle = fields.subtitle,
|
||||
authors = fields.authors,
|
||||
isbn13 = fields.isbn13,
|
||||
isbn10 = fields.isbn10,
|
||||
publisher = fields.publisher,
|
||||
publishedDate = fields.publishedDate,
|
||||
pageCount = fields.pageCount,
|
||||
description = fields.description,
|
||||
coverSourceUrl = fields.coverUrl,
|
||||
coverFile = (cover as? CoverPreloadState.Ready)?.takeIf { it.url == fields.coverUrl }?.file,
|
||||
shelfId = shelfId,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
_saveSheet.update { if (it is OnlineSaveSheetState.Shown) it.copy(isSaving = false) else it }
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
_saveSheet.update { if (it is OnlineSaveSheetState.Shown) it.copy(isSaving = false, saveError = e.javaClass.simpleName) else it }
|
||||
return null
|
||||
}
|
||||
// The book is written: nothing below may report a failure (a retry would duplicate it).
|
||||
dismissSaveSheet() // also discards the preload — createBook copied it
|
||||
rememberShelf(shelfId)
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* "Not shelved" (null) must never overwrite the memory — it isn't a shelf. Runs after the
|
||||
* book is written, so a failure is swallowed rather than reported (see [performSaveOnlineResult]).
|
||||
*/
|
||||
private suspend fun rememberShelf(shelfId: String?) {
|
||||
if (shelfId == null) return
|
||||
try {
|
||||
settingsStore.setLastShelfId(shelfId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
// Losing the "recent shelf" shortcut is not worth an error on a saved book.
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
coverPreload.discard()
|
||||
}
|
||||
|
||||
/** Manual trigger — app start (see init) and pull-to-refresh, per SPEC "Sync design". */
|
||||
fun refreshSync() {
|
||||
if (_isSyncing.value) return
|
||||
@@ -125,6 +427,7 @@ class LibraryViewModel(
|
||||
is SyncResult.Success -> _lastSyncOutcome.value = SyncOutcome.SUCCESS
|
||||
is SyncResult.Failure -> _lastSyncOutcome.value = SyncOutcome.FAILURE
|
||||
is SyncResult.Skipped -> _lastSyncOutcome.value = SyncOutcome.NO_SERVER
|
||||
is SyncResult.AuthExpired -> _lastSyncOutcome.value = SyncOutcome.AUTH_EXPIRED
|
||||
}
|
||||
_isSyncing.value = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.modg.bookshelf.ui.library
|
||||
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.metadata.TextNormalization
|
||||
import org.modg.bookshelf.data.repo.decodeAuthors
|
||||
|
||||
/**
|
||||
* The library search bar's instant, offline, as-you-type local filter (task: "Own
|
||||
* library filters AS THEY TYPE"). Replaces [org.modg.bookshelf.data.local.BookDao.search]'s
|
||||
* single whole-string LIKE for this screen — that matches nothing for "hobbit tolkien"
|
||||
* because neither column contains that exact substring. A home library is hundreds to
|
||||
* low thousands of books, so a pure Kotlin scan is fine and far more testable than SQL.
|
||||
*/
|
||||
object LocalBookMatcher {
|
||||
|
||||
/**
|
||||
* One book's searchable text, normalized once. Normalizing every field of every book
|
||||
* on each keystroke (Unicode decomposition + regex, on the main thread) measured ~60ms
|
||||
* per keystroke for 2,000 books on the dev server, so a phone would visibly lag;
|
||||
* [index] is rebuilt only when the book list changes.
|
||||
*/
|
||||
class IndexedBook internal constructor(val book: BookEntity, private val haystacks: List<String>) {
|
||||
private val squashed = haystacks.map { it.replace(" ", "") }
|
||||
|
||||
internal fun matches(queryTokens: List<String>): Boolean =
|
||||
queryTokens.all { token -> haystacks.any { it.contains(token) } || squashed.any { it.contains(token) } }
|
||||
}
|
||||
|
||||
fun index(books: List<BookEntity>): List<IndexedBook> = books.map { book ->
|
||||
IndexedBook(
|
||||
book,
|
||||
buildList {
|
||||
add(TextNormalization.normalizeForMatching(book.title))
|
||||
book.subtitle?.let { add(TextNormalization.normalizeForMatching(it)) }
|
||||
decodeAuthors(book.authorsJson).forEach { add(TextNormalization.normalizeForMatching(it)) }
|
||||
book.isbn13?.let { add(TextNormalization.normalizeForMatching(it)) }
|
||||
book.isbn10?.let { add(TextNormalization.normalizeForMatching(it)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes [query], splits it on whitespace, and requires EVERY token to appear in at
|
||||
* least one of title/subtitle/authors/isbn13/isbn10 (different tokens may match different
|
||||
* fields). Matching also checks a space-stripped form of each field so an initials-heavy
|
||||
* name like "J.R.R. Tolkien" (normalized to "j r r tolkien", per [TextNormalization]) is
|
||||
* still found by the squashed query token "jrr", not just "j" or "r".
|
||||
*/
|
||||
fun matchIndexed(index: List<IndexedBook>, query: String): List<BookEntity> {
|
||||
val queryTokens = TextNormalization.tokens(query)
|
||||
if (queryTokens.isEmpty()) return emptyList()
|
||||
return index.filter { it.matches(queryTokens) }.map { it.book }
|
||||
}
|
||||
|
||||
/** Convenience for callers without an index (tests, one-off matches). */
|
||||
fun match(books: List<BookEntity>, query: String): List<BookEntity> = matchIndexed(index(books), query)
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import org.modg.bookshelf.AppContainer
|
||||
import org.modg.bookshelf.ui.add.AddBookScreen
|
||||
import org.modg.bookshelf.ui.detail.DetailScreen
|
||||
import org.modg.bookshelf.ui.library.LibraryScreen
|
||||
import org.modg.bookshelf.ui.library.resolveOnlineBookFields
|
||||
import org.modg.bookshelf.ui.locations.LocationsScreen
|
||||
import org.modg.bookshelf.ui.scan.ScanScreen
|
||||
import org.modg.bookshelf.ui.settings.SettingsScreen
|
||||
@@ -52,9 +54,37 @@ fun BookshelfNavHost(
|
||||
shelfIdFilter = backStackEntry.arguments?.getString(Routes.LIBRARY_SHELF_ARG),
|
||||
onBookClick = { bookId -> navController.navigate(Routes.detail(bookId)) },
|
||||
onScanClick = { navController.navigate(Routes.SCAN) },
|
||||
onAddByHandClick = { navController.navigate(Routes.addBook()) },
|
||||
onLocationsClick = { navController.navigate(Routes.LOCATIONS) },
|
||||
onSettingsClick = { navController.navigate(Routes.SETTINGS) },
|
||||
onAuthExpired = {
|
||||
navController.navigate(Routes.SETUP) {
|
||||
popUpTo(navController.graph.id) { inclusive = true }
|
||||
}
|
||||
},
|
||||
container = container,
|
||||
// Wave 10: "Can't find it? Enter it by hand" under the online results —
|
||||
// pre-fills the add-by-hand form's title with the search query.
|
||||
onEnterByHandWithQuery = { query -> navController.navigate(Routes.addBook(title = query)) },
|
||||
// Wave 10: the online-result save sheet's "Edit details" — carries the
|
||||
// whole resolved draft (see resolveOnlineBookFields) through to the
|
||||
// add-by-hand form via its per-field route args.
|
||||
onEditOnlineResult = { book, enrichment ->
|
||||
val fields = resolveOnlineBookFields(book, enrichment)
|
||||
navController.navigate(
|
||||
Routes.addBook(
|
||||
title = fields.title,
|
||||
subtitle = fields.subtitle,
|
||||
authors = fields.authors.joinToString(", "),
|
||||
publisher = fields.publisher,
|
||||
publishedDate = fields.publishedDate,
|
||||
pages = fields.pageCount?.toString(),
|
||||
isbn = fields.isbn13 ?: fields.isbn10,
|
||||
description = fields.description,
|
||||
coverSourceUrl = fields.coverUrl,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
@@ -70,6 +100,50 @@ fun BookshelfNavHost(
|
||||
composable(Routes.SCAN) {
|
||||
ScanScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onEnterByHand = { navController.navigate(Routes.addBook()) },
|
||||
container = container,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.ADD_BOOK,
|
||||
arguments = listOf(
|
||||
Routes.ADD_BOOK_TITLE_ARG,
|
||||
Routes.ADD_BOOK_ISBN_ARG,
|
||||
Routes.ADD_BOOK_SUBTITLE_ARG,
|
||||
Routes.ADD_BOOK_AUTHORS_ARG,
|
||||
Routes.ADD_BOOK_PUBLISHER_ARG,
|
||||
Routes.ADD_BOOK_PUBLISHED_DATE_ARG,
|
||||
Routes.ADD_BOOK_PAGES_ARG,
|
||||
Routes.ADD_BOOK_DESCRIPTION_ARG,
|
||||
Routes.ADD_BOOK_COVER_URL_ARG,
|
||||
).map { argName ->
|
||||
navArgument(argName) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
},
|
||||
) { backStackEntry ->
|
||||
fun arg(name: String) = backStackEntry.arguments?.getString(name)
|
||||
AddBookScreen(
|
||||
initialTitle = arg(Routes.ADD_BOOK_TITLE_ARG),
|
||||
initialIsbn = arg(Routes.ADD_BOOK_ISBN_ARG),
|
||||
initialSubtitle = arg(Routes.ADD_BOOK_SUBTITLE_ARG),
|
||||
initialAuthors = arg(Routes.ADD_BOOK_AUTHORS_ARG),
|
||||
initialPublisher = arg(Routes.ADD_BOOK_PUBLISHER_ARG),
|
||||
initialPublishedDate = arg(Routes.ADD_BOOK_PUBLISHED_DATE_ARG),
|
||||
initialPages = arg(Routes.ADD_BOOK_PAGES_ARG),
|
||||
initialDescription = arg(Routes.ADD_BOOK_DESCRIPTION_ARG),
|
||||
initialCoverSourceUrl = arg(Routes.ADD_BOOK_COVER_URL_ARG),
|
||||
onBack = { navController.popBackStack() },
|
||||
onSaved = { bookId ->
|
||||
// Removes the add screen from the back stack, per the task's "Save"
|
||||
// contract, so Back from detail returns to the library rather than
|
||||
// to a form that was just successfully submitted.
|
||||
navController.navigate(Routes.detail(bookId)) {
|
||||
popUpTo(Routes.ADD_BOOK) { inclusive = true }
|
||||
}
|
||||
},
|
||||
container = container,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.modg.bookshelf.ui.nav
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Route string constants for [BookshelfNavHost]. Fixed by the wave-3 screen
|
||||
* contract shared with Worker E2 — do not rename or restructure these; E2's
|
||||
@@ -16,6 +18,78 @@ object Routes {
|
||||
const val LOCATIONS = "locations"
|
||||
const val SETTINGS = "settings"
|
||||
|
||||
const val ADD_BOOK_TITLE_ARG = "title"
|
||||
const val ADD_BOOK_ISBN_ARG = "isbn"
|
||||
|
||||
/**
|
||||
* Wave 10 (online search): "Edit details" on an online search hit needs to carry
|
||||
* the whole draft, not just title/isbn, to the add-by-hand screen. Extends the
|
||||
* wave-9 route with the same per-field nullable-query-arg pattern rather than a
|
||||
* new serialized-blob argument, so every arg keeps the identical encode/decode
|
||||
* story [ADD_BOOK_TITLE_ARG]/[ADD_BOOK_ISBN_ARG] already have (and RoutesTest
|
||||
* already covers).
|
||||
*/
|
||||
const val ADD_BOOK_SUBTITLE_ARG = "subtitle"
|
||||
const val ADD_BOOK_AUTHORS_ARG = "authors"
|
||||
const val ADD_BOOK_PUBLISHER_ARG = "publisher"
|
||||
const val ADD_BOOK_PUBLISHED_DATE_ARG = "publishedDate"
|
||||
const val ADD_BOOK_PAGES_ARG = "pages"
|
||||
const val ADD_BOOK_DESCRIPTION_ARG = "description"
|
||||
const val ADD_BOOK_COVER_URL_ARG = "coverUrl"
|
||||
private const val ADD_BOOK_BASE = "add"
|
||||
|
||||
/**
|
||||
* Every arg is optional (wave 9: "add a book by hand" with nothing pre-filled)
|
||||
* — nullable/defaultValue-null args, same pattern [LIBRARY_WITH_SHELF] uses so
|
||||
* this one route also matches the bare "add" entry with none supplied.
|
||||
*/
|
||||
const val ADD_BOOK = "$ADD_BOOK_BASE?$ADD_BOOK_TITLE_ARG={$ADD_BOOK_TITLE_ARG}" +
|
||||
"&$ADD_BOOK_ISBN_ARG={$ADD_BOOK_ISBN_ARG}" +
|
||||
"&$ADD_BOOK_SUBTITLE_ARG={$ADD_BOOK_SUBTITLE_ARG}" +
|
||||
"&$ADD_BOOK_AUTHORS_ARG={$ADD_BOOK_AUTHORS_ARG}" +
|
||||
"&$ADD_BOOK_PUBLISHER_ARG={$ADD_BOOK_PUBLISHER_ARG}" +
|
||||
"&$ADD_BOOK_PUBLISHED_DATE_ARG={$ADD_BOOK_PUBLISHED_DATE_ARG}" +
|
||||
"&$ADD_BOOK_PAGES_ARG={$ADD_BOOK_PAGES_ARG}" +
|
||||
"&$ADD_BOOK_DESCRIPTION_ARG={$ADD_BOOK_DESCRIPTION_ARG}" +
|
||||
"&$ADD_BOOK_COVER_URL_ARG={$ADD_BOOK_COVER_URL_ARG}"
|
||||
|
||||
fun libraryFilteredByShelf(shelfId: String) = "library?shelfId=$shelfId"
|
||||
fun detail(bookId: String) = "detail/$bookId"
|
||||
|
||||
/**
|
||||
* Builds the add-by-hand route, percent-encoding every arg so values containing
|
||||
* `&`, `?`, `/`, `#` etc. (titles/descriptions routinely do) survive as a single
|
||||
* query value instead of corrupting the route string. [Uri.encode] rather than
|
||||
* [java.net.URLEncoder] because the latter encodes space as `+`, which
|
||||
* Navigation Compose's Uri-pattern argument matching does NOT decode back to
|
||||
* space (it does standard percent-decoding, not form-decoding) — see
|
||||
* RoutesTest for the round-trip this must survive. A null arg is omitted
|
||||
* entirely rather than encoded as the literal string "null", so a caller with
|
||||
* nothing to pre-fill (e.g. the plain library FAB) gets exactly the bare
|
||||
* "add" route wave 9's [org.modg.bookshelf.ui.add.AddBookScreen] expects.
|
||||
*/
|
||||
fun addBook(
|
||||
title: String? = null,
|
||||
isbn: String? = null,
|
||||
subtitle: String? = null,
|
||||
authors: String? = null,
|
||||
publisher: String? = null,
|
||||
publishedDate: String? = null,
|
||||
pages: String? = null,
|
||||
description: String? = null,
|
||||
coverSourceUrl: String? = null,
|
||||
): String {
|
||||
val query = buildList {
|
||||
title?.let { add("$ADD_BOOK_TITLE_ARG=${Uri.encode(it)}") }
|
||||
isbn?.let { add("$ADD_BOOK_ISBN_ARG=${Uri.encode(it)}") }
|
||||
subtitle?.let { add("$ADD_BOOK_SUBTITLE_ARG=${Uri.encode(it)}") }
|
||||
authors?.let { add("$ADD_BOOK_AUTHORS_ARG=${Uri.encode(it)}") }
|
||||
publisher?.let { add("$ADD_BOOK_PUBLISHER_ARG=${Uri.encode(it)}") }
|
||||
publishedDate?.let { add("$ADD_BOOK_PUBLISHED_DATE_ARG=${Uri.encode(it)}") }
|
||||
pages?.let { add("$ADD_BOOK_PAGES_ARG=${Uri.encode(it)}") }
|
||||
description?.let { add("$ADD_BOOK_DESCRIPTION_ARG=${Uri.encode(it)}") }
|
||||
coverSourceUrl?.let { add("$ADD_BOOK_COVER_URL_ARG=${Uri.encode(it)}") }
|
||||
}
|
||||
return if (query.isEmpty()) ADD_BOOK_BASE else "$ADD_BOOK_BASE?" + query.joinToString("&")
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,9 @@ object ScanMetadataOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
/** The Found sheet's Save button: [isSaving] disables it; [error] names the exception class of a failed save. */
|
||||
data class ScanSaveStatus(val isSaving: Boolean = false, val error: String? = null)
|
||||
|
||||
/** SPEC.md "Continuous mode: ... a running 'added this session' count." */
|
||||
data class ScanSessionState(val savedCount: Int = 0) {
|
||||
fun withSave(): ScanSessionState = copy(savedCount = savedCount + 1)
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -70,6 +71,9 @@ import org.modg.bookshelf.ui.components.EmptyState
|
||||
import org.modg.bookshelf.ui.components.PrimaryButton
|
||||
import org.modg.bookshelf.ui.components.SecondaryButton
|
||||
import org.modg.bookshelf.ui.components.ShelfPickerSheet
|
||||
import org.modg.bookshelf.ui.components.CoverPreloadNotice
|
||||
import org.modg.bookshelf.ui.components.saveButtonLabel
|
||||
import org.modg.bookshelf.data.repo.CoverPreloadState
|
||||
|
||||
/**
|
||||
* SPEC.md "scan" screen: camera + reticle, on-hit bottom sheet, continuous
|
||||
@@ -80,6 +84,7 @@ import org.modg.bookshelf.ui.components.ShelfPickerSheet
|
||||
@Composable
|
||||
fun ScanScreen(
|
||||
onBack: () -> Unit,
|
||||
onEnterByHand: () -> Unit,
|
||||
container: AppContainer,
|
||||
) {
|
||||
val viewModel: ScanViewModel = viewModel(
|
||||
@@ -90,12 +95,15 @@ fun ScanScreen(
|
||||
locationRepository = container.locationRepository,
|
||||
metadataRepository = container.metadataRepository,
|
||||
settingsStore = container.settingsStore,
|
||||
coverDownloader = container.coverDownloader,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val sheetState by viewModel.sheetState.collectAsState()
|
||||
val cover by viewModel.cover.collectAsState()
|
||||
val saveStatus by viewModel.saveStatus.collectAsState()
|
||||
val sessionState by viewModel.sessionState.collectAsState()
|
||||
val rejectedMessage by viewModel.rejectedMessage.collectAsState()
|
||||
val torchEnabled by viewModel.scannerController.torchEnabled.collectAsState()
|
||||
@@ -169,6 +177,10 @@ fun ScanScreen(
|
||||
onShelfSelected = viewModel::selectShelf,
|
||||
onSave = { viewModel.save(state.isbn13, state.metadata) },
|
||||
onSkip = { viewModel.skip() },
|
||||
cover = cover,
|
||||
onRetryCover = viewModel::retryCover,
|
||||
isSaving = saveStatus.isSaving,
|
||||
saveError = saveStatus.error,
|
||||
)
|
||||
}
|
||||
is ScanSheetState.NotFound -> ModalBottomSheet(
|
||||
@@ -205,6 +217,7 @@ fun ScanScreen(
|
||||
ManualIsbnDialog(
|
||||
onDismiss = { showManualEntry = false },
|
||||
onSubmit = { isbn -> showManualEntry = false; viewModel.manualIsbnEntered(isbn) },
|
||||
onEnterByHand = { showManualEntry = false; onEnterByHand() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -327,6 +340,10 @@ internal fun FoundBookSheet(
|
||||
onShelfSelected: (String?) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
cover: CoverPreloadState = CoverPreloadState.None,
|
||||
onRetryCover: () -> Unit = {},
|
||||
isSaving: Boolean = false,
|
||||
saveError: String? = null,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Box(
|
||||
@@ -359,9 +376,23 @@ internal fun FoundBookSheet(
|
||||
recentShelfId = recentShelfId,
|
||||
onShelfSelected = onShelfSelected,
|
||||
)
|
||||
CoverPreloadNotice(state = cover, onRetry = onRetryCover)
|
||||
if (saveError != null) {
|
||||
Text(
|
||||
text = "Couldn't save: $saveError",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
SecondaryButton(text = "Skip", onClick = onSkip, modifier = Modifier.weight(1f))
|
||||
PrimaryButton(text = "Save", onClick = onSave, modifier = Modifier.weight(1f))
|
||||
PrimaryButton(
|
||||
text = saveButtonLabel(isSaving, cover),
|
||||
enabled = !isSaving,
|
||||
onClick = onSave,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -523,7 +554,7 @@ internal fun ShelfPicker(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit) {
|
||||
private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit, onEnterByHand: () -> Unit) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
// Validate here rather than letting ScanViewModel.manualIsbnEntered drop an
|
||||
// unparseable ISBN on the floor. A typed-in check digit is easy to get wrong,
|
||||
@@ -561,6 +592,12 @@ private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit)
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
// Escape hatch for the books this dialog can't help with at all — SPEC
|
||||
// gap: older/self-published/foreign books often have no ISBN, and this
|
||||
// dialog only ever accepts one.
|
||||
TextButton(onClick = onEnterByHand, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Text("No ISBN? Enter the details by hand")
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.modg.bookshelf.ui.scan
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -17,6 +18,9 @@ import org.modg.bookshelf.data.metadata.IsbnUtils
|
||||
import org.modg.bookshelf.data.metadata.MetadataRepository
|
||||
import org.modg.bookshelf.data.prefs.SettingsStore
|
||||
import org.modg.bookshelf.data.repo.BookRepository
|
||||
import org.modg.bookshelf.data.repo.CoverDownloader
|
||||
import org.modg.bookshelf.data.repo.CoverPreload
|
||||
import org.modg.bookshelf.data.repo.CoverPreloadState
|
||||
import org.modg.bookshelf.data.repo.LocationRepository
|
||||
|
||||
/**
|
||||
@@ -30,12 +34,21 @@ class ScanViewModel(
|
||||
locationRepository: LocationRepository,
|
||||
private val metadataRepository: MetadataRepository,
|
||||
private val settingsStore: SettingsStore,
|
||||
coverDownloader: CoverDownloader,
|
||||
val scannerController: ScannerController = ScannerController(),
|
||||
) : ViewModel() {
|
||||
|
||||
private val _sheetState = MutableStateFlow<ScanSheetState>(ScanSheetState.Hidden)
|
||||
val sheetState: StateFlow<ScanSheetState> = _sheetState.asStateFlow()
|
||||
|
||||
/** The Found sheet's cover, downloading from the moment the lookup succeeds — see [CoverPreload]. */
|
||||
private val coverPreload = CoverPreload(coverDownloader, viewModelScope)
|
||||
val cover: StateFlow<CoverPreloadState> = coverPreload.state
|
||||
|
||||
private val _saveStatus = MutableStateFlow(ScanSaveStatus())
|
||||
/** The Found sheet's Save: in flight (disables the button) and the last failure, if any. */
|
||||
val saveStatus: StateFlow<ScanSaveStatus> = _saveStatus.asStateFlow()
|
||||
|
||||
private val _sessionState = MutableStateFlow(ScanSessionState())
|
||||
val sessionState: StateFlow<ScanSessionState> = _sessionState.asStateFlow()
|
||||
|
||||
@@ -71,11 +84,30 @@ class ScanViewModel(
|
||||
runLookup(isbn13)
|
||||
}
|
||||
|
||||
private suspend fun runLookup(isbn13: String) {
|
||||
/**
|
||||
* Internal (not private) so tests can await it directly, same reasoning as
|
||||
* [performSave]. Wrapped in try/catch because nothing below this point —
|
||||
* [bookRepository] (Room/disk) or [metadataRepository] (§1's clients) — is
|
||||
* proven not to throw on a real device; see [GoogleBooksClient]'s KDoc on
|
||||
* the 2026-09-12 crash. Belt-and-braces with that fix: this is the
|
||||
* backstop for anything that escapes anywhere else in this function, not
|
||||
* just the network.
|
||||
*/
|
||||
internal suspend fun runLookup(isbn13: String) {
|
||||
_sheetState.value = ScanSheetState.Loading(isbn13)
|
||||
val duplicate = DuplicateCheck.check(bookRepository.findByIsbn13(isbn13))
|
||||
val result = metadataRepository.lookup(isbn13)
|
||||
_sheetState.value = ScanMetadataOutcome.from(isbn13, result, duplicate)
|
||||
try {
|
||||
val duplicate = DuplicateCheck.check(bookRepository.findByIsbn13(isbn13))
|
||||
val result = metadataRepository.lookup(isbn13)
|
||||
val outcome = ScanMetadataOutcome.from(isbn13, result, duplicate)
|
||||
_sheetState.value = outcome
|
||||
if (outcome is ScanSheetState.Found) coverPreload.start(outcome.metadata.coverUrl)
|
||||
} catch (e: CancellationException) {
|
||||
// Normal control flow (sheet dismissed / screen left mid-lookup) —
|
||||
// must propagate, never be reported as a failed lookup.
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
_sheetState.value = ScanSheetState.LookupFailed(isbn13, "unexpected: ${e.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
/** [ScanSheetState.LookupFailed]'s Retry — re-enters loading and re-runs the same lookup. */
|
||||
@@ -113,27 +145,57 @@ class ScanViewModel(
|
||||
viewModelScope.launch { performSave(isbn13, metadata) }
|
||||
}
|
||||
|
||||
fun retryCover() = coverPreload.retry()
|
||||
|
||||
/**
|
||||
* The actual save-from-metadata logic, split out from [save] so tests can await it
|
||||
* directly (as a plain suspend call) instead of racing [viewModelScope]'s launch.
|
||||
* Returns whether the book was saved.
|
||||
*
|
||||
* Waits for the cover preload if it's still running, so the cover file is written
|
||||
* with the row. If the download fails DURING that wait the save stops and the sheet
|
||||
* shows the failure (Retry, or Save again to go ahead without it); if it had already
|
||||
* failed when Save was tapped, the user saw "Save without cover" and meant it.
|
||||
*
|
||||
* Guarded like wave 8's lookup path, which this save path wasn't: [Throwable] is
|
||||
* caught so a Room failure can't crash the app, [CancellationException] rethrown
|
||||
* first, and a save already in flight makes a second tap a no-op.
|
||||
*/
|
||||
internal suspend fun performSave(isbn13: String, metadata: BookMetadata) {
|
||||
internal suspend fun performSave(isbn13: String, metadata: BookMetadata): Boolean {
|
||||
if (_saveStatus.value.isSaving) return false
|
||||
val coverAtTap = coverPreload.state.value
|
||||
_saveStatus.value = ScanSaveStatus(isSaving = true)
|
||||
val shelfId = _selectedShelfId.value
|
||||
bookRepository.createBook(
|
||||
title = metadata.title ?: "Untitled",
|
||||
subtitle = metadata.subtitle,
|
||||
authors = metadata.authors,
|
||||
isbn13 = metadata.isbn13 ?: isbn13,
|
||||
isbn10 = metadata.isbn10,
|
||||
publisher = metadata.publisher,
|
||||
publishedDate = metadata.publishedDate,
|
||||
pageCount = metadata.pageCount,
|
||||
description = metadata.description,
|
||||
coverSourceUrl = metadata.coverUrl,
|
||||
shelfId = shelfId,
|
||||
)
|
||||
try {
|
||||
val cover = coverPreload.awaitSettled()
|
||||
if (cover is CoverPreloadState.Failed && coverAtTap !is CoverPreloadState.Failed) {
|
||||
_saveStatus.value = ScanSaveStatus()
|
||||
return false
|
||||
}
|
||||
bookRepository.createBook(
|
||||
title = metadata.title ?: "Untitled",
|
||||
subtitle = metadata.subtitle,
|
||||
authors = metadata.authors,
|
||||
isbn13 = metadata.isbn13 ?: isbn13,
|
||||
isbn10 = metadata.isbn10,
|
||||
publisher = metadata.publisher,
|
||||
publishedDate = metadata.publishedDate,
|
||||
pageCount = metadata.pageCount,
|
||||
description = metadata.description,
|
||||
coverSourceUrl = metadata.coverUrl,
|
||||
coverFile = (cover as? CoverPreloadState.Ready)?.file,
|
||||
shelfId = shelfId,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
_saveStatus.value = ScanSaveStatus()
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
_saveStatus.value = ScanSaveStatus(error = e.javaClass.simpleName)
|
||||
return false
|
||||
}
|
||||
rememberShelf(shelfId)
|
||||
recordSave()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Save from the manual-entry form shown when metadata lookup misses (SPEC: pre-filled with the scanned ISBN). */
|
||||
@@ -154,9 +216,20 @@ class ScanViewModel(
|
||||
recordSave()
|
||||
}
|
||||
|
||||
/** "Not shelved" (null) must never overwrite the memory — it isn't a shelf. */
|
||||
/**
|
||||
* "Not shelved" (null) must never overwrite the memory — it isn't a shelf. Called only
|
||||
* AFTER the book is written, and a failure here is swallowed: reporting "couldn't save"
|
||||
* for a book that WAS saved would invite a retry that creates a duplicate.
|
||||
*/
|
||||
private suspend fun rememberShelf(shelfId: String?) {
|
||||
if (shelfId != null) settingsStore.setLastShelfId(shelfId)
|
||||
if (shelfId == null) return
|
||||
try {
|
||||
settingsStore.setLastShelfId(shelfId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
// The book is saved; losing the "recent shelf" shortcut is not worth an error.
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordSave() {
|
||||
@@ -170,9 +243,15 @@ class ScanViewModel(
|
||||
|
||||
fun dismissSheet() {
|
||||
_sheetState.value = ScanSheetState.Hidden
|
||||
coverPreload.discard() // saved (createBook copied it) or skipped — either way, not needed
|
||||
_saveStatus.value = ScanSaveStatus()
|
||||
scannerController.resetDebounce()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
coverPreload.discard()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** How long a rejected-barcode message stays on screen before it auto-clears. */
|
||||
const val REJECTED_MESSAGE_MILLIS = 3000L
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package org.modg.bookshelf.ui.settings
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -18,13 +24,19 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import kotlinx.coroutines.launch
|
||||
import org.modg.bookshelf.AppContainer
|
||||
import org.modg.bookshelf.diagnostics.CrashReport
|
||||
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
||||
import org.modg.bookshelf.ui.components.GoldDivider
|
||||
import org.modg.bookshelf.ui.components.PaperSurface
|
||||
@@ -50,12 +62,17 @@ fun SettingsScreen(
|
||||
settingsStore = container.settingsStore,
|
||||
bookRepository = container.bookRepository,
|
||||
syncEngine = container.syncEngine,
|
||||
crashReporter = container.crashReporter,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
var confirmSignOut by remember { mutableStateOf(false) }
|
||||
var showFullTrace by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboard.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
BookshelfScaffold(
|
||||
title = "Settings",
|
||||
@@ -87,18 +104,45 @@ fun SettingsScreen(
|
||||
|
||||
SectionHeading("Sync")
|
||||
InfoRow(label = "Last synced", value = formatLastSync(state.lastSyncTime))
|
||||
PrimaryButton(
|
||||
text = if (state.isSyncing) "Syncing…" else "Sync now",
|
||||
onClick = viewModel::syncNow,
|
||||
enabled = !state.isSyncing,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
if (state.syncAuthExpired) {
|
||||
// Not a "Sync failed" dead end: the fix is signing in again, so the
|
||||
// button goes straight to setup instead of retrying the same sync.
|
||||
// Goes through onSignedOut directly, NOT viewModel.signOut(onSignedOut)
|
||||
// — that also clears the stored email/user id, and setup pre-fills those.
|
||||
Text(
|
||||
text = "Your session expired. Sign in again.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
PrimaryButton(
|
||||
text = "Sign in",
|
||||
onClick = onSignedOut,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
} else {
|
||||
PrimaryButton(
|
||||
text = if (state.isSyncing) "Syncing…" else "Sync now",
|
||||
onClick = viewModel::syncNow,
|
||||
enabled = !state.isSyncing,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
|
||||
SectionHeading("Library")
|
||||
InfoRow(label = "Books", value = state.bookCount.toString())
|
||||
InfoRow(label = "Covers", value = state.coverCount.toString())
|
||||
|
||||
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
|
||||
SectionHeading("Diagnostics")
|
||||
DiagnosticsSection(
|
||||
crash = state.latestCrash,
|
||||
onViewTrace = { showFullTrace = true },
|
||||
onShare = { state.latestCrash?.let { shareCrashReport(context, it.fullText) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +161,78 @@ fun SettingsScreen(
|
||||
dismissButton = { TextButton(onClick = { confirmSignOut = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
val crash = state.latestCrash
|
||||
if (showFullTrace && crash != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showFullTrace = false },
|
||||
title = { Text(text = "Crash report", style = MaterialTheme.typography.titleLarge) },
|
||||
text = {
|
||||
Text(
|
||||
text = crash.fullText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState()),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
coroutineScope.launch {
|
||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("crash report", crash.fullText)))
|
||||
}
|
||||
showFullTrace = false
|
||||
}) { Text("Copy") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { showFullTrace = false }) { Text("Close") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SPEC has no dedicated diagnostics entry; added 2026-09-12 alongside
|
||||
* [org.modg.bookshelf.diagnostics.CrashReporter] so a crash on a phone this
|
||||
* box cannot reach leaves evidence the user can actually get off the device
|
||||
* — a screenshot of a stack trace, from another country, is not that. Kept
|
||||
* as its own composable (rather than inlined into [SettingsScreen]) so
|
||||
* [org.modg.bookshelf.ui.screens.SettingsScreenPaparazziTest] can snapshot
|
||||
* both the empty and populated states directly, the same pattern as
|
||||
* [SectionHeading]/[InfoRow].
|
||||
*/
|
||||
@Composable
|
||||
internal fun DiagnosticsSection(
|
||||
crash: CrashReport?,
|
||||
onViewTrace: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
) {
|
||||
if (crash == null) {
|
||||
// An empty section with nothing in it reads as broken, not as good news — say so.
|
||||
Text(
|
||||
text = "No crashes recorded.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
return
|
||||
}
|
||||
InfoRow(label = "Last crash", value = crash.timestamp)
|
||||
Text(
|
||||
text = crash.headline,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Row(modifier = Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SecondaryButton(text = "View trace", onClick = onViewTrace)
|
||||
SecondaryButton(text = "Share", onClick = onShare)
|
||||
}
|
||||
}
|
||||
|
||||
/** Hands the full report text to any app that accepts plain text — the user is in another country from this machine. */
|
||||
private fun shareCrashReport(context: Context, text: String) {
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, "Share crash report"))
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -141,6 +257,7 @@ internal fun InfoRow(label: String, value: String) {
|
||||
|
||||
private fun syncLabel(state: SettingsUiState): String = when {
|
||||
state.isSyncing -> "Syncing…"
|
||||
state.syncAuthExpired -> "Signed out — sign in again to sync"
|
||||
state.syncError != null -> "Sync failed: ${state.syncError}"
|
||||
state.lastSyncTime != null -> "Synced • ${formatLastSync(state.lastSyncTime)}"
|
||||
else -> "Not synced yet"
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.modg.bookshelf.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -13,6 +14,8 @@ import org.modg.bookshelf.data.repo.AuthRepository
|
||||
import org.modg.bookshelf.data.repo.BookRepository
|
||||
import org.modg.bookshelf.data.repo.SyncEngine
|
||||
import org.modg.bookshelf.data.repo.SyncResult
|
||||
import org.modg.bookshelf.diagnostics.CrashReport
|
||||
import org.modg.bookshelf.diagnostics.CrashReporter
|
||||
import org.modg.bookshelf.ui.components.SyncStatus
|
||||
|
||||
data class SettingsUiState(
|
||||
@@ -23,10 +26,13 @@ data class SettingsUiState(
|
||||
val lastSyncTime: Long? = null,
|
||||
val isSyncing: Boolean = false,
|
||||
val syncError: String? = null,
|
||||
val syncAuthExpired: Boolean = false,
|
||||
val latestCrash: CrashReport? = null,
|
||||
) {
|
||||
val syncStatus: SyncStatus
|
||||
get() = when {
|
||||
isSyncing -> SyncStatus.Syncing
|
||||
syncAuthExpired -> SyncStatus.AuthExpired
|
||||
syncError != null -> SyncStatus.Error
|
||||
lastSyncTime != null -> SyncStatus.Synced
|
||||
else -> SyncStatus.Offline
|
||||
@@ -47,10 +53,22 @@ class SettingsViewModel(
|
||||
private val settingsStore: SettingsStore,
|
||||
private val bookRepository: BookRepository,
|
||||
private val syncEngine: SyncEngine,
|
||||
private val crashReporter: CrashReporter,
|
||||
) : ViewModel() {
|
||||
|
||||
private val isSyncing = MutableStateFlow(false)
|
||||
private val syncError = MutableStateFlow<String?>(null)
|
||||
private val syncAuthExpired = MutableStateFlow(false)
|
||||
|
||||
// Read only when this screen is opened (never at app startup) and off the
|
||||
// main thread — CrashReporter.list() reads files. See its KDoc.
|
||||
private val latestCrash = MutableStateFlow<CrashReport?>(null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
latestCrash.value = crashReporter.latest()
|
||||
}
|
||||
}
|
||||
|
||||
private val baseInfo = combine(
|
||||
authRepository.serverUrl,
|
||||
@@ -67,23 +85,27 @@ class SettingsViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
val uiState: StateFlow<SettingsUiState> = combine(baseInfo, isSyncing, syncError) { base, syncing, error ->
|
||||
SettingsUiState(
|
||||
serverUrl = base.serverUrl,
|
||||
userEmail = base.userEmail,
|
||||
bookCount = base.bookCount,
|
||||
coverCount = base.coverCount,
|
||||
lastSyncTime = base.lastSyncTime,
|
||||
isSyncing = syncing,
|
||||
syncError = error,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
|
||||
val uiState: StateFlow<SettingsUiState> =
|
||||
combine(baseInfo, isSyncing, syncError, syncAuthExpired, latestCrash) { base, syncing, error, authExpired, crash ->
|
||||
SettingsUiState(
|
||||
serverUrl = base.serverUrl,
|
||||
userEmail = base.userEmail,
|
||||
bookCount = base.bookCount,
|
||||
coverCount = base.coverCount,
|
||||
lastSyncTime = base.lastSyncTime,
|
||||
isSyncing = syncing,
|
||||
syncError = error,
|
||||
syncAuthExpired = authExpired,
|
||||
latestCrash = crash,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
|
||||
|
||||
fun syncNow() {
|
||||
if (isSyncing.value) return
|
||||
viewModelScope.launch {
|
||||
isSyncing.value = true
|
||||
syncError.value = null
|
||||
syncAuthExpired.value = false
|
||||
// SyncEngine never throws (SPEC: sync failure is a quiet status
|
||||
// line, never a crash or a blocking dialog) — surface its result
|
||||
// as that quiet line, nothing more.
|
||||
@@ -91,6 +113,9 @@ class SettingsViewModel(
|
||||
is SyncResult.Success -> Unit
|
||||
is SyncResult.Skipped -> syncError.value = "No server configured yet."
|
||||
is SyncResult.Failure -> syncError.value = result.error.message ?: "Sync failed."
|
||||
// Not a generic failure: "Sync failed: HTTP 401" would tell the user
|
||||
// to retry, when what actually fixes this is signing in again.
|
||||
is SyncResult.AuthExpired -> syncAuthExpired.value = true
|
||||
}
|
||||
isSyncing.value = false
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -46,6 +47,16 @@ class SetupViewModel(private val authRepository: AuthRepository) : ViewModel() {
|
||||
.readTimeout(6, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
init {
|
||||
// Re-signing-in after an auth expiry (SettingsStore.clearAuthToken keeps
|
||||
// server URL, user id and email) should be typing the password only.
|
||||
viewModelScope.launch {
|
||||
val url = authRepository.serverUrl.first()
|
||||
val email = authRepository.userEmail.first()
|
||||
_uiState.update { it.copy(serverUrl = url ?: it.serverUrl, email = email ?: it.email) }
|
||||
}
|
||||
}
|
||||
|
||||
fun onServerUrlChanged(value: String) {
|
||||
_uiState.update { it.copy(serverUrl = value, urlError = null) }
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
@@ -18,7 +22,7 @@ class GoogleBooksClientTest {
|
||||
private val client = GoogleBooksClient(OkHttpClient(), Json)
|
||||
|
||||
@Test
|
||||
fun `parses a successful response into BookMetadata and forces https zoom=2 on the cover`() {
|
||||
fun `parses a successful response into BookMetadata and normalizes the cover url`() {
|
||||
val body = fixture("googlebooks_success.json")
|
||||
val result = client.parseResponse(body)
|
||||
|
||||
@@ -31,11 +35,92 @@ class GoogleBooksClientTest {
|
||||
assertEquals("9780134685991", result.isbn13)
|
||||
assertEquals("0134685997", result.isbn10)
|
||||
assertEquals(
|
||||
"https://books.google.com/books/content?id=ABC123XYZ&printsec=frontcover&img=1&zoom=2",
|
||||
"https://books.google.com/books/content?id=ABC123XYZ&printsec=frontcover&img=1&zoom=1&w=400",
|
||||
result.coverUrl,
|
||||
)
|
||||
}
|
||||
|
||||
// --- normalizeCoverUrl: the 2026-09-20 "image not available" fix. ---
|
||||
|
||||
/**
|
||||
* The defect this rule exists to prevent. Google answers `zoom=2` for a volume
|
||||
* it has no full preview of with **200 and a grey "image not available" PNG**,
|
||||
* not a 404 — so Coil loads it happily, BookCover's own placeholder never fires,
|
||||
* and the cover pipeline uploads Google's placeholder to PocketBase as the
|
||||
* book's cover. Measured 2026-09-20: 11 of 18 real volumes did this at zoom=2,
|
||||
* 0 of 18 at zoom=1 with a width. See normalizeCoverUrl's KDoc.
|
||||
*/
|
||||
@Test
|
||||
fun `pins zoom to 1 so google cannot substitute its image-not-available placeholder`() {
|
||||
assertEquals(
|
||||
"https://books.google.com/books/content?id=X&printsec=frontcover&img=1&zoom=1&w=400",
|
||||
normalizeCoverUrl("http://books.google.com/books/content?id=X&printsec=frontcover&img=1&zoom=2"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rewrites every zoom level, not just the one google usually sends`() {
|
||||
listOf(0, 1, 2, 3, 5, 6).forEach { zoom ->
|
||||
val normalized = normalizeCoverUrl("https://books.google.com/books/content?id=X&zoom=$zoom")
|
||||
assertEquals(
|
||||
"zoom=$zoom should have been pinned to 1",
|
||||
"https://books.google.com/books/content?id=X&zoom=1&w=400",
|
||||
normalized,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adds a zoom and a width when the url carries neither`() {
|
||||
assertEquals(
|
||||
"https://books.google.com/books/content?id=X&zoom=1&w=400",
|
||||
normalizeCoverUrl("https://books.google.com/books/content?id=X"),
|
||||
)
|
||||
}
|
||||
|
||||
/** `edge=curl` paints a fake page-curl over the right edge of the cover. */
|
||||
@Test
|
||||
fun `strips the edge curl treatment`() {
|
||||
val normalized = normalizeCoverUrl(
|
||||
"http://books.google.com/books/content?id=X&img=1&zoom=1&edge=curl&source=gbs_api",
|
||||
)
|
||||
assertEquals(
|
||||
"https://books.google.com/books/content?id=X&img=1&zoom=1&source=gbs_api&w=400",
|
||||
normalized,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forces https so the cover is not fetched in the clear`() {
|
||||
val normalized = checkNotNull(normalizeCoverUrl("http://books.google.com/books/content?id=X"))
|
||||
assertTrue(normalized.startsWith("https://"))
|
||||
}
|
||||
|
||||
/** An explicit width already on the URL is the caller's business, not ours to override. */
|
||||
@Test
|
||||
fun `keeps a width the url already specifies`() {
|
||||
assertEquals(
|
||||
"https://books.google.com/books/content?id=X&zoom=1&w=800",
|
||||
normalizeCoverUrl("https://books.google.com/books/content?id=X&zoom=1&w=800"),
|
||||
)
|
||||
}
|
||||
|
||||
/** ...but "w=" buried in another parameter's value is not a width. */
|
||||
@Test
|
||||
fun `still adds a width when w= appears only inside another parameter value`() {
|
||||
val normalized = checkNotNull(
|
||||
normalizeCoverUrl("https://books.google.com/books/content?id=X&sig=aw=bc&zoom=1"),
|
||||
)
|
||||
assertTrue("expected a width to be appended, got $normalized", normalized.endsWith("&w=400"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null for a missing or blank cover url`() {
|
||||
assertNull(normalizeCoverUrl(null))
|
||||
assertNull(normalizeCoverUrl(""))
|
||||
assertNull(normalizeCoverUrl(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when there are no items`() {
|
||||
val body = fixture("googlebooks_no_items.json")
|
||||
@@ -157,6 +242,56 @@ class GoogleBooksClientTest {
|
||||
assertEquals(clean, client.redact(clean))
|
||||
}
|
||||
|
||||
// --- fetch(): §1 of the 2026-09-12 crash fix -- a non-IOException thrown by the real
|
||||
// call must become Failed(UNEXPECTED), never escape as a crash, and CancellationException
|
||||
// is the one exception to that: it must propagate. An Interceptor that throws is the
|
||||
// straightforward way to provoke this with the real client (no fake seam needed).
|
||||
|
||||
@Test
|
||||
fun `lookupOnce reports Failed UNEXPECTED, not a crash, when the http call throws a non-IOException`() = runTest {
|
||||
val client = clientThrowing(IllegalStateException("boom, and here's the key: test-key-123"))
|
||||
|
||||
val result = client.lookupOnce("9780134685991")
|
||||
|
||||
val failed = result as? SourceResult.Failed
|
||||
checkNotNull(failed) { "expected Failed, got $result" }
|
||||
assertEquals(FailureKind.UNEXPECTED, failed.kind)
|
||||
assertEquals("unexpected: IllegalStateException", failed.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the UNEXPECTED reason names the exception class but never carries its message`() = runTest {
|
||||
// The message is the one place a leaked key could hide (the same reasoning as
|
||||
// redact() below) -- it must never reach the reason string at all.
|
||||
val secretMessage = "connect to https://www.googleapis.com/books/v1/volumes?key=test-key-123 failed"
|
||||
val client = clientThrowing(IllegalStateException(secretMessage))
|
||||
|
||||
val failed = client.lookupOnce("9780134685991") as? SourceResult.Failed
|
||||
checkNotNull(failed)
|
||||
assertFalse(failed.reason.contains(secretMessage))
|
||||
assertFalse(failed.reason.contains("test-key-123"))
|
||||
assertTrue(failed.reason.contains("IllegalStateException"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancellationException propagates out of the client rather than becoming a Failed`() = runTest {
|
||||
val client = clientThrowing(CancellationException("scope cancelled"))
|
||||
|
||||
try {
|
||||
client.lookupOnce("9780134685991")
|
||||
fail("expected CancellationException to propagate")
|
||||
} catch (e: CancellationException) {
|
||||
// expected -- cancellation is normal control flow, not a lookup failure.
|
||||
}
|
||||
}
|
||||
|
||||
private fun clientThrowing(t: Throwable): GoogleBooksClient {
|
||||
val httpClient = OkHttpClient.Builder()
|
||||
.addInterceptor(Interceptor { throw t })
|
||||
.build()
|
||||
return GoogleBooksClient(httpClient, Json)
|
||||
}
|
||||
|
||||
private fun fixture(name: String): String =
|
||||
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
|
||||
.bufferedReader()
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Parses checked-in sample JSON fixtures — no network. Reuses the existing
|
||||
* googlebooks_*.json fixtures ([GoogleBooksClientTest] already established they're
|
||||
* shaped like a real `volumes?q=` response, which search and ISBN lookup share).
|
||||
*/
|
||||
class GoogleBooksSearchClientTest {
|
||||
|
||||
private val client = GoogleBooksSearchClient(OkHttpClient(), Json)
|
||||
|
||||
@Test
|
||||
fun `parses a successful response into SearchHits with null industryIdentifiers and no imageLinks handled`() {
|
||||
val body = """
|
||||
{"items": [{"volumeInfo": {"title": "Some Title", "authors": ["Some Author"]}}]}
|
||||
""".trimIndent()
|
||||
val hits = client.parseResponse(body)
|
||||
|
||||
assertEquals(1, hits.size)
|
||||
assertEquals("Some Title", hits[0].title)
|
||||
assertEquals(listOf("Some Author"), hits[0].authors)
|
||||
assertTrue(hits[0].isbn13s.isEmpty())
|
||||
assertEquals(null, hits[0].coverUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses the real success fixture and normalizes the cover url`() {
|
||||
val hits = client.parseResponse(fixture("googlebooks_success.json"))
|
||||
assertEquals(1, hits.size)
|
||||
assertEquals("Effective Java", hits[0].title)
|
||||
assertEquals(listOf("9780134685991"), hits[0].isbn13s)
|
||||
assertEquals(
|
||||
"https://books.google.com/books/content?id=ABC123XYZ&printsec=frontcover&img=1&zoom=1&w=400",
|
||||
hits[0].coverUrl,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a garbage publishedDate does not become a year`() {
|
||||
val body = """
|
||||
{"items": [{"volumeInfo": {"title": "Old Book", "publishedDate": "101-01-01"}}]}
|
||||
""".trimIndent()
|
||||
val hits = client.parseResponse(body)
|
||||
assertEquals(null, hits[0].year)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns NotFound for no items, not an empty Found`() {
|
||||
assertEquals(SearchSourceResult.NotFound, client.classify(200, fixture("googlebooks_no_items.json")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for a non-2xx status`() {
|
||||
assertEquals(SearchSourceResult.Failed("http 404", FailureKind.CLIENT_ERROR), client.classify(404, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for malformed json`() {
|
||||
assertEquals(SearchSourceResult.Failed("malformed json", FailureKind.MALFORMED), client.classify(200, fixture("malformed.json")))
|
||||
}
|
||||
|
||||
// --- requestUrl(): key only when configured, query url-encoded ---
|
||||
|
||||
@Test
|
||||
fun `requestUrl omits the key when blank`() {
|
||||
val url = client.requestUrl("dune")
|
||||
assertTrue(url.contains("q=dune"))
|
||||
assertFalse(url.contains("key="))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requestUrl carries the key only when configured`() {
|
||||
val keyed = GoogleBooksSearchClient(OkHttpClient(), Json, apiKey = "test-key-123")
|
||||
assertTrue(keyed.requestUrl("dune").contains("key=test-key-123"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requestUrl percent-encodes a query with url-breaking characters`() {
|
||||
val url = client.requestUrl("a&b c")
|
||||
assertFalse(url.contains("q=a&b"))
|
||||
assertTrue(url.contains("q=a%26b"))
|
||||
}
|
||||
|
||||
// --- redact(): the key must never survive into a shown reason ---
|
||||
|
||||
@Test
|
||||
fun `redact scrubs the api key from a leaked reason`() {
|
||||
val key = "test-key-123"
|
||||
val keyed = GoogleBooksSearchClient(OkHttpClient(), Json, apiKey = key)
|
||||
val leakedUrl = keyed.requestUrl("dune")
|
||||
check(leakedUrl.contains(key))
|
||||
|
||||
val leaking = SearchSourceResult.Failed("network error: connect to $leakedUrl failed", FailureKind.TRANSPORT)
|
||||
val redacted = keyed.redact(leaking) as SearchSourceResult.Failed
|
||||
|
||||
assertFalse(redacted.reason.contains(key))
|
||||
assertTrue(redacted.reason.contains("[REDACTED]"))
|
||||
}
|
||||
|
||||
// --- fetch(): non-IOException -> Failed(UNEXPECTED); CancellationException propagates ---
|
||||
|
||||
@Test
|
||||
fun `searchOnce reports Failed UNEXPECTED, not a crash, when the http call throws a non-IOException`() = runTest {
|
||||
val client = clientThrowing(IllegalStateException("boom"))
|
||||
val result = client.searchOnce("dune")
|
||||
val failed = result as? SearchSourceResult.Failed
|
||||
checkNotNull(failed) { "expected Failed, got $result" }
|
||||
assertEquals(FailureKind.UNEXPECTED, failed.kind)
|
||||
assertEquals("unexpected: IllegalStateException", failed.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancellationException propagates out of the client rather than becoming a Failed`() = runTest {
|
||||
val client = clientThrowing(CancellationException("scope cancelled"))
|
||||
try {
|
||||
client.searchOnce("dune")
|
||||
fail("expected CancellationException to propagate")
|
||||
} catch (e: CancellationException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
private fun clientThrowing(t: Throwable): GoogleBooksSearchClient {
|
||||
val httpClient = OkHttpClient.Builder().addInterceptor(Interceptor { throw t }).build()
|
||||
return GoogleBooksSearchClient(httpClient, Json)
|
||||
}
|
||||
|
||||
private fun fixture(name: String): String =
|
||||
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
|
||||
.bufferedReader()
|
||||
.readText()
|
||||
}
|
||||
@@ -1,149 +1,436 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Parses checked-in sample JSON fixtures (src/test/resources/fixtures) — no network
|
||||
* involved, offline-safe. [OpenLibraryClient.parseResponse] is the unit under test;
|
||||
* [OpenLibraryClient.lookup] itself is untested here because it requires a live socket.
|
||||
* Parses checked-in sample JSON fixtures (src/test/resources/fixtures) captured from
|
||||
* the live Editions API on 2026-09-20 — no network involved, offline-safe.
|
||||
*
|
||||
* Two layers are tested. The pure parse/classify functions take a body directly. The
|
||||
* multi-request paths — author resolution, the work fallback, the [AuthorNameCache] —
|
||||
* need a client that answers several URLs, so they run against [servingFixtures],
|
||||
* an OkHttp [Interceptor] that serves fixtures by URL without opening a socket.
|
||||
*/
|
||||
class OpenLibraryClientTest {
|
||||
|
||||
private val client = OpenLibraryClient(OkHttpClient(), Json)
|
||||
|
||||
@Test
|
||||
fun `parses a successful response into BookMetadata`() {
|
||||
val body = fixture("openlibrary_success.json")
|
||||
val result = client.parseResponse(body, "9780201558029")
|
||||
fun `parses an edition response into BookMetadata`() {
|
||||
val result = client.parseResponse(fixture("openlibrary_edition_gondolin.json"), "9781328613042")
|
||||
|
||||
checkNotNull(result)
|
||||
assertEquals("Concrete Mathematics", result.title)
|
||||
assertEquals("A Foundation for Computer Science", result.subtitle)
|
||||
assertEquals(listOf("Ronald L. Graham", "Donald E. Knuth"), result.authors)
|
||||
assertEquals("Addison-Wesley Professional", result.publisher)
|
||||
assertEquals("1994", result.publishedDate)
|
||||
assertEquals(672, result.pageCount)
|
||||
assertEquals("9780201558029", result.isbn13)
|
||||
assertEquals("0201558025", result.isbn10)
|
||||
assertEquals("https://covers.openlibrary.org/b/id/675832-L.jpg", result.coverUrl)
|
||||
assertEquals("The fall of Gondolin", result.title)
|
||||
assertEquals("Houghton Mifflin Harcourt", result.publisher)
|
||||
assertEquals("2018", result.publishedDate)
|
||||
assertEquals(302, result.pageCount)
|
||||
assertEquals("9781328613042", result.isbn13)
|
||||
assertEquals("1328613046", result.isbn10)
|
||||
assertEquals("https://covers.openlibrary.org/b/id/12451486-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.
|
||||
* The legacy endpoint never returned a description, so this is new information —
|
||||
* on this record it arrives as a bare string.
|
||||
*/
|
||||
@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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Two real books off the user's shelf that the app failed to identify on its
|
||||
* first on-device run, captured verbatim from the live API on 2026-09-09. Both
|
||||
* are small-press (Bethlehem Books) children's historical fiction — the exact
|
||||
* profile we assumed Open Library would be thin on. It isn't: both carry title,
|
||||
* author, publisher and cover art, and neither exposes an `isbn_13` identifier,
|
||||
* which is why the lookup ISBN has to survive as the fallback. Whatever went
|
||||
* wrong on the phone, it was NOT this parser and NOT Open Library's coverage.
|
||||
*/
|
||||
@Test
|
||||
fun `parses the real responses for the two books the app failed to identify`() {
|
||||
val hittite = checkNotNull(
|
||||
client.parseResponse(fixture("openlibrary_hittite_warrior.json"), "9781883937386"),
|
||||
fun `reads a description given as a bare string`() {
|
||||
val result = checkNotNull(
|
||||
client.parseResponse(fixture("openlibrary_edition_gondolin.json"), "9781328613042"),
|
||||
)
|
||||
assertTrue(
|
||||
"expected the Gondolin blurb, got ${result.description}",
|
||||
result.description.orEmpty().contains("Morgoth"),
|
||||
)
|
||||
}
|
||||
|
||||
/** ...and on this one as a typed object, `{"type": ..., "value": ...}`. Both are real. */
|
||||
@Test
|
||||
fun `reads a description given as a typed object`() {
|
||||
val result = checkNotNull(
|
||||
client.parseResponse(fixture("openlibrary_edition_hittite.json"), "9781883937386"),
|
||||
)
|
||||
assertEquals("Hittite warrior", result.title)
|
||||
val description = checkNotNull(result.description) { "expected the object-form description" }
|
||||
assertTrue("description should be unwrapped text, got: $description", description.isNotBlank())
|
||||
assertFalse("the JSON wrapper leaked into the text", description.contains("/type/text"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaves description null when the edition has none`() {
|
||||
val result = checkNotNull(
|
||||
client.parseResponse(fixture("openlibrary_edition_harp.json"), "9780898707168"),
|
||||
)
|
||||
assertNull(result.description)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open Library uses -1 in `covers` to mean "no cover here". This real record is
|
||||
* `[698345, -1]`; taking the list's first positive entry is what keeps us from
|
||||
* ever building covers.openlibrary.org/b/id/-1-L.jpg.
|
||||
*/
|
||||
@Test
|
||||
fun `skips the -1 no-cover sentinel when choosing a cover id`() {
|
||||
val result = checkNotNull(
|
||||
client.parseResponse(fixture("openlibrary_edition_harp.json"), "9780898707168"),
|
||||
)
|
||||
assertEquals("https://covers.openlibrary.org/b/id/698345-L.jpg", result.coverUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaves coverUrl null when every cover id is a sentinel`() {
|
||||
val body = """{"title": "No Art", "covers": [-1]}"""
|
||||
assertNull(checkNotNull(client.parseResponse(body, "9780201558029")).coverUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaves coverUrl null when the edition reports no covers at all`() {
|
||||
val body = """{"title": "No Art"}"""
|
||||
assertNull(checkNotNull(client.parseResponse(body, "9780201558029")).coverUrl)
|
||||
}
|
||||
|
||||
/** No isbn_13 in the record — the ISBN we looked up has to carry through. */
|
||||
@Test
|
||||
fun `falls back to the looked-up isbn when the record carries no isbn_13`() {
|
||||
val body = """{"title": "Hittite warrior", "isbn_10": ["1883937388"]}"""
|
||||
val result = checkNotNull(client.parseResponse(body, "9781883937386"))
|
||||
|
||||
assertEquals("9781883937386", result.isbn13)
|
||||
assertEquals("1883937388", result.isbn10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails soft on malformed json instead of throwing`() {
|
||||
assertNull(client.parseResponse(fixture("malformed.json"), "9780201558029"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Carried over from the legacy-endpoint tests: these are the two books that
|
||||
* failed on the user's phone in waves 5/6, and the reason this project knows the
|
||||
* coverage hypothesis was wrong. Re-pinned against the Editions API so the
|
||||
* endpoint move can't quietly regress them.
|
||||
*/
|
||||
@Test
|
||||
fun `parses the real responses for the two books the app once failed to identify`() = runTest {
|
||||
val client = servingFixtures()
|
||||
|
||||
val hittite = (client.lookupOnce("9781883937386") as? SourceResult.Found)?.metadata
|
||||
checkNotNull(hittite) { "expected Found for Hittite Warrior" }
|
||||
assertEquals("Hittite warrior", hittite.title)
|
||||
assertEquals(listOf("Joanne S. Williamson"), hittite.authors)
|
||||
assertEquals("Bethlehem Books", hittite.publisher)
|
||||
assertEquals(237, hittite.pageCount)
|
||||
assertEquals("1883937388", hittite.isbn10)
|
||||
// No isbn_13 in the record — the ISBN we looked up has to carry through.
|
||||
assertEquals("9781883937386", hittite.isbn13)
|
||||
assertEquals("https://covers.openlibrary.org/b/id/930599-L.jpg", hittite.coverUrl)
|
||||
|
||||
val shadowHawk = checkNotNull(
|
||||
client.parseResponse(fixture("openlibrary_shadow_hawk.json"), "9781883937676"),
|
||||
)
|
||||
val shadowHawk = (client.lookupOnce("9781883937676") as? SourceResult.Found)?.metadata
|
||||
checkNotNull(shadowHawk) { "expected Found for Shadow Hawk" }
|
||||
assertEquals("Shadow hawk", shadowHawk.title)
|
||||
assertEquals(listOf("Andre Norton"), shadowHawk.authors)
|
||||
assertEquals("9781883937676", shadowHawk.isbn13)
|
||||
checkNotNull(shadowHawk.coverUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when the isbn key is absent from the response`() {
|
||||
val body = fixture("openlibrary_not_found.json")
|
||||
assertNull(client.parseResponse(body, "9780201558029"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails soft on malformed json instead of throwing`() {
|
||||
val body = fixture("malformed.json")
|
||||
assertNull(client.parseResponse(body, "9780201558029"))
|
||||
assertEquals("https://covers.openlibrary.org/b/id/930617-L.jpg", shadowHawk.coverUrl)
|
||||
}
|
||||
|
||||
// --- 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 result = client.classify(200, fixture("openlibrary_edition_gondolin.json"), "9781328613042")
|
||||
|
||||
val found = result as? SourceResult.Found
|
||||
checkNotNull(found) { "expected Found, got $result" }
|
||||
assertEquals("Concrete Mathematics", found.metadata.title)
|
||||
assertEquals("The fall of Gondolin", found.metadata.title)
|
||||
}
|
||||
|
||||
/**
|
||||
* The semantic change that came with the endpoint move, and the one most worth
|
||||
* pinning down. The legacy `/api/books` reported a miss as 200 with an empty
|
||||
* object, so every non-2xx there was a failure. `/isbn/{isbn}.json` has no such
|
||||
* body to return and answers 404, which IS Open Library saying authoritatively
|
||||
* that it holds no such edition. Reporting that as Unavailable would put a retry
|
||||
* affordance in front of the user for a book no amount of retrying will find.
|
||||
*/
|
||||
@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)
|
||||
fun `classify reports NotFound for a 404, because on this endpoint 404 is authoritative`() {
|
||||
assertEquals(SourceResult.NotFound, client.classify(404, "<!DOCTYPE html><html>…", "9780201558029"))
|
||||
}
|
||||
|
||||
/**
|
||||
* ...but only 404. Every other 4xx is still a failure: SPEC.md's rule is that a
|
||||
* source we could not reach must never reach the user as a book that isn't there.
|
||||
*/
|
||||
@Test
|
||||
fun `classify reports Failed for a 404`() {
|
||||
val result = client.classify(404, null, "9780201558029")
|
||||
assertEquals(SourceResult.Failed("http 404", FailureKind.CLIENT_ERROR), result)
|
||||
fun `classify reports Failed for a 403, distinctly from NotFound`() {
|
||||
assertEquals(
|
||||
SourceResult.Failed("http 403", FailureKind.CLIENT_ERROR),
|
||||
client.classify(403, null, "9780201558029"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for a 429, distinctly from NotFound`() {
|
||||
val result = client.classify(429, null, "9780201558029")
|
||||
assertEquals(SourceResult.Failed("http 429 (rate limited)", FailureKind.RATE_LIMITED), result)
|
||||
assertEquals(
|
||||
SourceResult.Failed("http 429 (rate limited)", FailureKind.RATE_LIMITED),
|
||||
client.classify(429, null, "9780201558029"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for a 500`() {
|
||||
val result = client.classify(500, "Internal Server Error", "9780201558029")
|
||||
assertEquals(SourceResult.Failed("http 500 (server error)", FailureKind.SERVER_ERROR), result)
|
||||
assertEquals(
|
||||
SourceResult.Failed("http 500 (server error)", FailureKind.SERVER_ERROR),
|
||||
client.classify(500, "Internal Server Error", "9780201558029"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for a malformed body even on a 2xx status`() {
|
||||
val result = client.classify(200, fixture("malformed.json"), "9780201558029")
|
||||
assertEquals(SourceResult.Failed("malformed json", FailureKind.MALFORMED), result)
|
||||
assertEquals(
|
||||
SourceResult.Failed("malformed json", FailureKind.MALFORMED),
|
||||
client.classify(200, fixture("malformed.json"), "9780201558029"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for an empty body on a 2xx status`() {
|
||||
assertEquals(
|
||||
SourceResult.Failed("empty body", FailureKind.MALFORMED),
|
||||
client.classify(200, "", "9780201558029"),
|
||||
)
|
||||
}
|
||||
|
||||
// --- author resolution: the cost the Editions API adds, and the cache that pays it. ---
|
||||
|
||||
@Test
|
||||
fun `resolves author references to names`() = runTest {
|
||||
val client = servingFixtures()
|
||||
|
||||
val result = client.lookupOnce("9781328613042") as? SourceResult.Found
|
||||
checkNotNull(result) { "expected Found" }
|
||||
assertEquals(listOf("J.R.R. Tolkien"), result.metadata.authors)
|
||||
}
|
||||
|
||||
/**
|
||||
* ISBN 9780898707168 is on the user's own shelf and its edition record has NO
|
||||
* authors — they exist only on the work. The legacy endpoint resolved that for
|
||||
* us and returned "Laura M. Berquist", so without this fallback the endpoint
|
||||
* move would have silently dropped the author for books like it.
|
||||
*/
|
||||
@Test
|
||||
fun `falls back to the work when the edition record names no authors`() = runTest {
|
||||
val client = servingFixtures()
|
||||
|
||||
val result = client.lookupOnce("9780898707168") as? SourceResult.Found
|
||||
checkNotNull(result) { "expected Found" }
|
||||
assertEquals("The harp and laurel wreath", result.metadata.title)
|
||||
assertEquals(listOf("Laura M. Berquist"), result.metadata.authors)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not fetch the work when the edition already names its authors`() = runTest {
|
||||
val requests = mutableListOf<String>()
|
||||
val client = servingFixtures(log = requests)
|
||||
|
||||
client.lookupOnce("9781328613042")
|
||||
|
||||
assertTrue(
|
||||
"the work should not be fetched when the edition has authors; got $requests",
|
||||
requests.none { it.contains("/works/") },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of the cache. A second book by an author already seen resolves from
|
||||
* memory — which is the shape of the user's scanning session, where books by one
|
||||
* author sit together on the shelf and get scanned one after another.
|
||||
*/
|
||||
@Test
|
||||
fun `caches author names across lookups instead of re-fetching`() = runTest {
|
||||
val requests = mutableListOf<String>()
|
||||
val cache = AuthorNameCache()
|
||||
val client = servingFixtures(log = requests, cache = cache)
|
||||
|
||||
client.lookupOnce("9781328613042")
|
||||
val afterFirst = requests.count { it.contains("/authors/") }
|
||||
client.lookupOnce("9781328613042")
|
||||
val afterSecond = requests.count { it.contains("/authors/") }
|
||||
|
||||
assertEquals("the first lookup should resolve the author once", 1, afterFirst)
|
||||
assertEquals("the second lookup should not re-fetch the author", 1, afterSecond)
|
||||
assertEquals(1, cache.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cache shared between clients is reused by both`() = runTest {
|
||||
val requests = mutableListOf<String>()
|
||||
val cache = AuthorNameCache()
|
||||
|
||||
servingFixtures(log = requests, cache = cache).lookupOnce("9781328613042")
|
||||
servingFixtures(log = requests, cache = cache).lookupOnce("9781328613042")
|
||||
|
||||
assertEquals(1, requests.count { it.contains("/authors/") })
|
||||
}
|
||||
|
||||
/**
|
||||
* Author resolution is best-effort: the edition request already succeeded, so a
|
||||
* failure resolving a name must degrade the record rather than turn a book we
|
||||
* found into a lookup the user is told to retry.
|
||||
*/
|
||||
@Test
|
||||
fun `a failing author request degrades the record instead of failing the lookup`() = runTest {
|
||||
val client = servingFixtures(failUrlsContaining = "/authors/")
|
||||
|
||||
val result = client.lookupOnce("9781328613042") as? SourceResult.Found
|
||||
checkNotNull(result) { "expected Found despite the author request failing, got it as a failure" }
|
||||
assertEquals("The fall of Gondolin", result.metadata.title)
|
||||
assertEquals(emptyList<String>(), result.metadata.authors)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failing work request degrades the record instead of failing the lookup`() = runTest {
|
||||
val client = servingFixtures(failUrlsContaining = "/works/")
|
||||
|
||||
val result = client.lookupOnce("9780898707168") as? SourceResult.Found
|
||||
checkNotNull(result) { "expected Found despite the work request failing" }
|
||||
assertEquals("The harp and laurel wreath", result.metadata.title)
|
||||
assertEquals(emptyList<String>(), result.metadata.authors)
|
||||
}
|
||||
|
||||
/** A failed resolution must not be remembered — one bad moment shouldn't cost a name all session. */
|
||||
@Test
|
||||
fun `does not cache a failed author resolution`() = runTest {
|
||||
val cache = AuthorNameCache()
|
||||
servingFixtures(cache = cache, failUrlsContaining = "/authors/").lookupOnce("9781328613042")
|
||||
assertEquals(0, cache.size)
|
||||
|
||||
val result = servingFixtures(cache = cache).lookupOnce("9781328613042") as? SourceResult.Found
|
||||
assertEquals(listOf("J.R.R. Tolkien"), checkNotNull(result).metadata.authors)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 404 from the edition endpoint reaches the caller as NotFound`() = runTest {
|
||||
val client = servingFixtures()
|
||||
|
||||
assertEquals(SourceResult.NotFound, client.lookupOnce("9780000000000"))
|
||||
}
|
||||
|
||||
// --- fetch(): §1 of the 2026-09-12 crash fix -- same coverage as GoogleBooksClientTest.
|
||||
|
||||
@Test
|
||||
fun `lookupOnce reports Failed UNEXPECTED, not a crash, when the http call throws a non-IOException`() = runTest {
|
||||
val client = clientThrowing(IllegalStateException("boom"))
|
||||
|
||||
val result = client.lookupOnce("9780201558029")
|
||||
|
||||
val failed = result as? SourceResult.Failed
|
||||
checkNotNull(failed) { "expected Failed, got $result" }
|
||||
assertEquals(FailureKind.UNEXPECTED, failed.kind)
|
||||
assertEquals("unexpected: IllegalStateException", failed.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the UNEXPECTED reason names the exception class but never carries its message`() = runTest {
|
||||
val secretMessage = "connection reset while reading a great deal of private detail"
|
||||
val client = clientThrowing(IllegalStateException(secretMessage))
|
||||
|
||||
val failed = client.lookupOnce("9780201558029") as? SourceResult.Failed
|
||||
checkNotNull(failed)
|
||||
assertFalse(failed.reason.contains(secretMessage))
|
||||
assertTrue(failed.reason.contains("IllegalStateException"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancellationException propagates out of the client rather than becoming a Failed`() = runTest {
|
||||
val client = clientThrowing(CancellationException("scope cancelled"))
|
||||
|
||||
try {
|
||||
client.lookupOnce("9780201558029")
|
||||
fail("expected CancellationException to propagate")
|
||||
} catch (e: CancellationException) {
|
||||
// expected -- cancellation is normal control flow, not a lookup failure.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellation must survive the best-effort author path too, which swallows
|
||||
* Throwable by design. If it were swallowed there, dismissing the scan sheet
|
||||
* mid-lookup would leave the request running instead of unwinding.
|
||||
*/
|
||||
@Test
|
||||
fun `CancellationException from a supporting request propagates rather than being swallowed`() = runTest {
|
||||
val client = servingFixtures(throwOnUrlsContaining = "/authors/" to CancellationException("dismissed"))
|
||||
|
||||
try {
|
||||
client.lookupOnce("9781328613042")
|
||||
fail("expected CancellationException to propagate out of author resolution")
|
||||
} catch (e: CancellationException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
private fun clientThrowing(t: Throwable): OpenLibraryClient {
|
||||
val httpClient = OkHttpClient.Builder()
|
||||
.addInterceptor(Interceptor { throw t })
|
||||
.build()
|
||||
return OpenLibraryClient(httpClient, Json)
|
||||
}
|
||||
|
||||
/**
|
||||
* A client whose HTTP layer answers from the fixture set instead of the network,
|
||||
* so the multi-request paths (edition -> work -> author) are exercised end to end
|
||||
* offline. Unknown URLs answer 404, which is also how the not-found case is driven.
|
||||
*/
|
||||
private fun servingFixtures(
|
||||
log: MutableList<String>? = null,
|
||||
cache: AuthorNameCache = AuthorNameCache(),
|
||||
failUrlsContaining: String? = null,
|
||||
throwOnUrlsContaining: Pair<String, Throwable>? = null,
|
||||
): OpenLibraryClient {
|
||||
val bodies = mapOf(
|
||||
"https://openlibrary.org/isbn/9781328613042.json" to "openlibrary_edition_gondolin.json",
|
||||
"https://openlibrary.org/isbn/9780898707168.json" to "openlibrary_edition_harp.json",
|
||||
"https://openlibrary.org/isbn/9781883937386.json" to "openlibrary_edition_hittite.json",
|
||||
"https://openlibrary.org/isbn/9781883937676.json" to "openlibrary_edition_shadowhawk.json",
|
||||
"https://openlibrary.org/works/OL19479056W.json" to "openlibrary_work_harp.json",
|
||||
"https://openlibrary.org/authors/OL335577A.json" to "openlibrary_author_berquist.json",
|
||||
"https://openlibrary.org/authors/OL26320A.json" to "openlibrary_author_tolkien.json",
|
||||
"https://openlibrary.org/authors/OL244960A.json" to "openlibrary_author_williamson.json",
|
||||
"https://openlibrary.org/authors/OL27951A.json" to "openlibrary_author_norton.json",
|
||||
)
|
||||
val httpClient = OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
val url = chain.request().url.toString()
|
||||
log?.add(url)
|
||||
throwOnUrlsContaining?.let { (needle, t) -> if (needle in url) throw t }
|
||||
val failed = failUrlsContaining != null && failUrlsContaining in url
|
||||
val fixtureName = bodies[url]
|
||||
val code = if (failed || fixtureName == null) 404 else 200
|
||||
val body = if (code == 200) fixture(fixtureName!!) else "<!DOCTYPE html><html>not found</html>"
|
||||
Response.Builder()
|
||||
.request(chain.request())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(code)
|
||||
.message(if (code == 200) "OK" else "Not Found")
|
||||
.body(body.toResponseBody("application/json".toMediaType()))
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
return OpenLibraryClient(httpClient, Json, cache)
|
||||
}
|
||||
|
||||
private fun fixture(name: String): String =
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Parses checked-in sample JSON fixtures (src/test/resources/fixtures) — no network
|
||||
* involved, offline-safe. Mirrors [OpenLibraryClientTest]'s structure for the sibling
|
||||
* search endpoint.
|
||||
*/
|
||||
class OpenLibrarySearchClientTest {
|
||||
|
||||
private val client = OpenLibrarySearchClient(OkHttpClient(), Json)
|
||||
|
||||
@Test
|
||||
fun `parses a successful response, normalizing mixed isbn10-13 to one distinct isbn13`() {
|
||||
val hits = client.parseResponse(fixture("openlibrary_search_success.json"))
|
||||
|
||||
assertEquals(2, hits.size)
|
||||
val hittite = hits[0]
|
||||
assertEquals("Hittite Warrior", hittite.title)
|
||||
assertEquals(listOf("Joanne S. Williamson"), hittite.authors)
|
||||
assertEquals(1960, hittite.year)
|
||||
assertEquals("Bethlehem Books", hittite.publisher)
|
||||
assertEquals(237, hittite.pageCount)
|
||||
assertEquals("https://covers.openlibrary.org/b/id/930599-M.jpg", hittite.coverUrl)
|
||||
// "1883937388" (isbn-10) and "9781883937386" (isbn-13) are the SAME edition --
|
||||
// must normalize to one distinct value, not two.
|
||||
assertEquals(listOf("9781883937386"), hittite.isbn13s)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a doc with no isbn field parses with an empty isbn13s list, not a crash`() {
|
||||
val hits = client.parseResponse(fixture("openlibrary_search_success.json"))
|
||||
val noIsbn = hits[1]
|
||||
assertEquals("A Book With No ISBN On File", noIsbn.title)
|
||||
assertTrue(noIsbn.isbn13s.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a doc with no cover_i has a null coverUrl, never a synthesized one`() {
|
||||
val hits = client.parseResponse(fixture("openlibrary_search_success.json"))
|
||||
assertNull(hits[1].coverUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails soft on malformed json instead of throwing`() {
|
||||
assertTrue(client.parseResponse(fixture("malformed.json")).isEmpty())
|
||||
}
|
||||
|
||||
// --- classify(): the three-way per-source outcome ---
|
||||
|
||||
@Test
|
||||
fun `classify reports Found for docs with results`() {
|
||||
val result = client.classify(200, fixture("openlibrary_search_success.json"))
|
||||
val found = result as? SearchSourceResult.Found
|
||||
checkNotNull(found) { "expected Found, got $result" }
|
||||
assertEquals(2, found.hits.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports NotFound for an empty docs list, distinctly from a failure`() {
|
||||
val result = client.classify(200, fixture("openlibrary_search_no_docs.json"))
|
||||
assertEquals(SearchSourceResult.NotFound, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for a non-2xx status, not NotFound`() {
|
||||
assertEquals(SearchSourceResult.Failed("http 500 (server error)", FailureKind.SERVER_ERROR), client.classify(500, null))
|
||||
assertEquals(SearchSourceResult.Failed("http 429 (rate limited)", FailureKind.RATE_LIMITED), client.classify(429, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for malformed json even on a 2xx status`() {
|
||||
assertEquals(
|
||||
SearchSourceResult.Failed("malformed json", FailureKind.MALFORMED),
|
||||
client.classify(200, fixture("malformed.json")),
|
||||
)
|
||||
}
|
||||
|
||||
// --- requestUrl(): fields= (with isbn) must be present, and the query URL-encoded ---
|
||||
|
||||
@Test
|
||||
fun `requestUrl includes fields= with isbn -- without it OL omits isbns entirely`() {
|
||||
val url = client.requestUrl("dune")
|
||||
assertTrue(url.contains("fields="))
|
||||
assertTrue(url.contains("isbn"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requestUrl percent-encodes a query with url-breaking characters`() {
|
||||
val url = client.requestUrl("a&b c")
|
||||
// A literal, un-encoded "&" would split the query into two bogus parameters.
|
||||
assertFalse(url.contains("q=a&b"))
|
||||
assertTrue(url.contains("q=a%26b"))
|
||||
}
|
||||
|
||||
// --- fetch(): non-IOException -> Failed(UNEXPECTED); CancellationException propagates ---
|
||||
|
||||
@Test
|
||||
fun `searchOnce reports Failed UNEXPECTED, not a crash, when the http call throws a non-IOException`() = runTest {
|
||||
val client = clientThrowing(IllegalStateException("boom"))
|
||||
val result = client.searchOnce("dune")
|
||||
val failed = result as? SearchSourceResult.Failed
|
||||
checkNotNull(failed) { "expected Failed, got $result" }
|
||||
assertEquals(FailureKind.UNEXPECTED, failed.kind)
|
||||
assertEquals("unexpected: IllegalStateException", failed.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancellationException propagates out of the client rather than becoming a Failed`() = runTest {
|
||||
val client = clientThrowing(CancellationException("scope cancelled"))
|
||||
try {
|
||||
client.searchOnce("dune")
|
||||
fail("expected CancellationException to propagate")
|
||||
} catch (e: CancellationException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
private fun clientThrowing(t: Throwable): OpenLibrarySearchClient {
|
||||
val httpClient = OkHttpClient.Builder().addInterceptor(Interceptor { throw t }).build()
|
||||
return OpenLibrarySearchClient(httpClient, Json)
|
||||
}
|
||||
|
||||
private fun fixture(name: String): String =
|
||||
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
|
||||
.bufferedReader()
|
||||
.readText()
|
||||
}
|
||||
@@ -53,6 +53,20 @@ class RetryPolicyTest {
|
||||
assertFalse(RetryPolicy.isRetryable(FailureKind.MALFORMED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unexpected failure is not retried -- repeating an unknown failure could repeat its damage`() {
|
||||
assertFalse(RetryPolicy.isRetryable(FailureKind.UNEXPECTED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withRetry makes exactly one attempt for an unexpected failure`() = runTest {
|
||||
var calls = 0
|
||||
val unexpected = SourceResult.Failed("unexpected: IllegalStateException", FailureKind.UNEXPECTED)
|
||||
val result = withRetry(sleep = {}) { calls++; unexpected }
|
||||
assertEquals(1, calls)
|
||||
assertEquals(unexpected, result)
|
||||
}
|
||||
|
||||
// --- the loop ---
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.local.SyncState
|
||||
import org.modg.bookshelf.data.repo.encodeAuthors
|
||||
|
||||
class SearchResultMergerTest {
|
||||
|
||||
private fun book(
|
||||
id: String,
|
||||
title: String,
|
||||
authors: List<String>,
|
||||
isbn13: String? = null,
|
||||
isbn10: String? = null,
|
||||
) = BookEntity(
|
||||
id = id,
|
||||
title = title,
|
||||
authorsJson = encodeAuthors(authors),
|
||||
isbn13 = isbn13,
|
||||
isbn10 = isbn10,
|
||||
createdAt = 1,
|
||||
updatedAt = 1,
|
||||
syncState = SyncState.SYNCED,
|
||||
)
|
||||
|
||||
private fun hit(
|
||||
title: String?,
|
||||
authors: List<String> = emptyList(),
|
||||
isbn13s: List<String> = emptyList(),
|
||||
year: Int? = null,
|
||||
coverUrl: String? = null,
|
||||
publisher: String? = null,
|
||||
pageCount: Int? = null,
|
||||
) = SearchHit(title, authors = authors, isbn13s = isbn13s, year = year, coverUrl = coverUrl, publisher = publisher, pageCount = pageCount)
|
||||
|
||||
// --- identity: ISBN ---
|
||||
|
||||
@Test
|
||||
fun `a GB edition joins an OL work by a shared isbn13`() {
|
||||
val ol = hit("Dune", listOf("Frank Herbert"), isbn13s = listOf("9780441013593", "9780451128596"))
|
||||
val gb = hit("Dune", listOf("Frank Herbert"), isbn13s = listOf("9780441013593"))
|
||||
|
||||
val result = SearchResultMerger.merge(emptyList(), listOf(ol), listOf(gb))
|
||||
|
||||
assertEquals(1, result.online.size)
|
||||
assertEquals(setOf(SearchSource.OPEN_LIBRARY, SearchSource.GOOGLE_BOOKS), result.online[0].sources)
|
||||
}
|
||||
|
||||
// --- identity: title + author surname, near-duplicate author spellings ---
|
||||
|
||||
@Test
|
||||
fun `near-duplicate author spellings for the same work collapse into one entry`() {
|
||||
// Real example from the task brief: the same book returned as two OL works.
|
||||
val a = hit("Hittite Warrior", listOf("Joanne S. Williamson"))
|
||||
val b = hit("Hittite Warrior", listOf("Joanne Small Williamson"))
|
||||
|
||||
val result = SearchResultMerger.merge(emptyList(), listOf(a, b), emptyList())
|
||||
|
||||
assertEquals(1, result.online.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initials vs full name for the same author collapse via surname matching`() {
|
||||
val a = hit("The Hobbit", listOf("J.R.R. Tolkien"))
|
||||
val b = hit("The Hobbit", listOf("John Ronald Reuel Tolkien"))
|
||||
|
||||
val result = SearchResultMerger.merge(emptyList(), listOf(a), listOf(b))
|
||||
|
||||
assertEquals(1, result.online.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `five GB editions of the same title plus one OL work collapse to ONE online entry`() {
|
||||
val ol = hit("The Hobbit", listOf("J.R.R. Tolkien"))
|
||||
val gbEditions = List(5) { hit("The Hobbit", listOf("J. R. R. Tolkien")) }
|
||||
|
||||
val result = SearchResultMerger.merge(emptyList(), listOf(ol), gbEditions)
|
||||
|
||||
assertEquals(1, result.online.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `distinct books with the same title but different authors stay separate`() {
|
||||
val a = hit("Circe", listOf("Madeline Miller"))
|
||||
val b = hit("Circe", listOf("Someone Else"))
|
||||
|
||||
val result = SearchResultMerger.merge(emptyList(), listOf(a, b), emptyList())
|
||||
|
||||
assertEquals(2, result.online.size)
|
||||
}
|
||||
|
||||
// --- transitivity ---
|
||||
|
||||
@Test
|
||||
fun `transitivity -- A tilde B by isbn, B tilde C by title, all three land in one group`() {
|
||||
// A and C share NEITHER an isbn NOR a title+author -- only B bridges them, so
|
||||
// this only passes if grouping is transitive (union-find) rather than pairwise.
|
||||
val a = hit("Title A", listOf("Author A"), isbn13s = listOf("9781111111111"))
|
||||
val b = hit("Title B", listOf("Author B"), isbn13s = listOf("9781111111111")) // shares A's isbn
|
||||
val c = hit("Title B", listOf("Author B")) // shares B's title+author, nothing in common with A
|
||||
|
||||
val result = SearchResultMerger.merge(emptyList(), listOf(a), listOf(b, c))
|
||||
|
||||
assertEquals(1, result.online.size)
|
||||
}
|
||||
|
||||
// --- local books: placement, and the "reached only via an online hit" case ---
|
||||
|
||||
@Test
|
||||
fun `an online hit of a different edition of an owned book lands in inLibrary, not online`() {
|
||||
val owned = book("b1", "Dune", listOf("Frank Herbert"), isbn13 = "9780451128596")
|
||||
val differentEditionOnline = hit("Dune", listOf("Frank Herbert"), isbn13s = listOf("9780441013593"))
|
||||
|
||||
val result = SearchResultMerger.merge(listOf(owned), listOf(differentEditionOnline), emptyList())
|
||||
|
||||
assertEquals(listOf(owned), result.inLibrary)
|
||||
assertTrue("the online hit must not ALSO surface as a new online result", result.online.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an online hit unrelated to any local book stays in online`() {
|
||||
val owned = book("b1", "Dune", listOf("Frank Herbert"), isbn13 = "9780451128596")
|
||||
val unrelated = hit("Piranesi", listOf("Susanna Clarke"))
|
||||
|
||||
val result = SearchResultMerger.merge(listOf(owned), listOf(unrelated), emptyList())
|
||||
|
||||
assertEquals(listOf(owned), result.inLibrary)
|
||||
assertEquals(1, result.online.size)
|
||||
assertEquals("Piranesi", result.online[0].title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two local books sharing a group are both shown, not deduped away`() {
|
||||
val copy1 = book("b1", "Dune", listOf("Frank Herbert"), isbn13 = "9780441013593")
|
||||
val copy2 = book("b2", "Dune", listOf("Frank Herbert"), isbn13 = "9780441013593")
|
||||
|
||||
val result = SearchResultMerger.merge(listOf(copy1, copy2), emptyList(), emptyList())
|
||||
|
||||
assertEquals(setOf("b1", "b2"), result.inLibrary.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
// --- ranking ---
|
||||
|
||||
@Test
|
||||
fun `online results are ordered by the best rank either source returned a member at`() {
|
||||
// The real hit is OL position 0 AND GB position 0 (rank 0); the noise trails
|
||||
// at OL position 1 with nothing in GB backing it (rank 1) -- it must sort after.
|
||||
val olRealHit = hit("The Real Book", listOf("Real Author"), isbn13s = listOf("9781111111111"))
|
||||
val olNoise = hit("Loosely Related Noise", listOf("Nobody"))
|
||||
val gbRealHit = hit("The Real Book", listOf("Real Author"), isbn13s = listOf("9781111111111"))
|
||||
|
||||
val result = SearchResultMerger.merge(
|
||||
local = emptyList(),
|
||||
openLibrary = listOf(olRealHit, olNoise),
|
||||
googleBooks = listOf(gbRealHit),
|
||||
)
|
||||
|
||||
assertEquals(2, result.online.size)
|
||||
assertEquals("The Real Book", result.online[0].title)
|
||||
assertEquals("Loosely Related Noise", result.online[1].title)
|
||||
}
|
||||
|
||||
// --- field fill ---
|
||||
|
||||
@Test
|
||||
fun `title and authors come from OL when OL has a title, year and cover fall through independently`() {
|
||||
// Tied together by a shared isbn13 so they're one group despite OL and GB
|
||||
// disagreeing on title/author string -- exactly the shape a real work-vs-
|
||||
// edition pairing takes, and what makes the primary/secondary choice visible.
|
||||
val ol = hit("Concrete Mathematics", listOf("Ronald L. Graham"), isbn13s = listOf("9780201558029"), year = null, coverUrl = null)
|
||||
val gb = hit(
|
||||
"A Different Title Entirely",
|
||||
listOf("GB Author"),
|
||||
isbn13s = listOf("9780201558029"),
|
||||
year = 1994,
|
||||
coverUrl = "https://example.com/cover.jpg",
|
||||
)
|
||||
|
||||
val merged = SearchResultMerger.merge(emptyList(), listOf(ol), listOf(gb)).online.single()
|
||||
|
||||
assertEquals("Concrete Mathematics", merged.title) // OL wins because it has a title
|
||||
assertEquals(listOf("Ronald L. Graham"), merged.authors)
|
||||
assertEquals(1994, merged.year) // OL's is null, falls through to GB
|
||||
assertEquals("https://example.com/cover.jpg", merged.coverUrl)
|
||||
}
|
||||
|
||||
// --- ISBN rule: each branch ---
|
||||
|
||||
@Test
|
||||
fun `isbn rule -- OL isbn10 and isbn13 of the same edition normalize to one, and that one is used`() {
|
||||
val ol = hit("Hittite Warrior", listOf("Joanne S. Williamson"), isbn13s = listOf("9781883937386")) // already normalized by the client
|
||||
val merged = SearchResultMerger.merge(emptyList(), listOf(ol), emptyList()).online.single()
|
||||
assertEquals("9781883937386", merged.isbn13)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isbn rule -- OL has many isbns, GB has exactly one -- GB's isbn wins`() {
|
||||
val ol = hit("Popular Book", listOf("Author"), isbn13s = listOf("9781111111111", "9782222222222", "9783333333333"))
|
||||
val gb = hit("Popular Book", listOf("Author"), isbn13s = listOf("9781111111111"))
|
||||
val merged = SearchResultMerger.merge(emptyList(), listOf(ol), listOf(gb)).online.single()
|
||||
assertEquals("9781111111111", merged.isbn13)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isbn rule -- several GB isbns and no single-edition OL work -- no isbn is recorded`() {
|
||||
val ol = hit("Popular Book", listOf("Author"), isbn13s = listOf("9781111111111", "9782222222222"))
|
||||
val gbA = hit("Popular Book", listOf("Author"), isbn13s = listOf("9784444444444"))
|
||||
val gbB = hit("Popular Book", listOf("Author"), isbn13s = listOf("9785555555555"))
|
||||
val merged = SearchResultMerger.merge(emptyList(), listOf(ol), listOf(gbA, gbB)).online.single()
|
||||
assertNull(merged.isbn13)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isbn rule -- OL has no isbn field -- falls through to GB`() {
|
||||
val ol = hit("Popular Book", listOf("Author"), isbn13s = emptyList())
|
||||
val gb = hit("Popular Book", listOf("Author"), isbn13s = listOf("9781111111111"))
|
||||
val merged = SearchResultMerger.merge(emptyList(), listOf(ol), listOf(gb)).online.single()
|
||||
assertEquals("9781111111111", merged.isbn13)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isbn rule -- neither source resolves to a single isbn -- null, never a guess`() {
|
||||
val ol = hit("Popular Book", listOf("Author"), isbn13s = emptyList())
|
||||
val gbA = hit("Popular Book", listOf("Author"), isbn13s = listOf("9781111111111"))
|
||||
val gbB = hit("Popular Book", listOf("Author"), isbn13s = listOf("9782222222222"))
|
||||
val merged = SearchResultMerger.merge(emptyList(), listOf(ol), listOf(gbA, gbB)).online.single()
|
||||
assertNull(merged.isbn13)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty inputs produce empty results without throwing`() {
|
||||
val result = SearchResultMerger.merge(emptyList(), emptyList(), emptyList())
|
||||
assertTrue(result.inLibrary.isEmpty())
|
||||
assertTrue(result.online.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -49,4 +49,22 @@ class AuthRepositoryTest {
|
||||
|
||||
assertNull(settingsStore.userEmail.first())
|
||||
}
|
||||
|
||||
/**
|
||||
* Backstop for causes of stuck cursors we haven't thought of yet (see the
|
||||
* 2026-09-17 incident's one-time re-pull in SyncEngine.sync()): every fresh
|
||||
* sign-in reconciles fully, not just the specific recovery path.
|
||||
*/
|
||||
@Test
|
||||
fun `login success clears all three pull cursors so a fresh sign-in reconciles fully`() = runTest {
|
||||
settingsStore.setCursor(SettingsStore.COLLECTION_BOOKS, "2024-01-01 00:00:00.000Z")
|
||||
settingsStore.setCursor(SettingsStore.COLLECTION_SHELVES, "2024-01-01 00:00:00.000Z")
|
||||
settingsStore.setCursor(SettingsStore.COLLECTION_BOOKCASES, "2024-01-01 00:00:00.000Z")
|
||||
|
||||
repository.login(email = "reader@example.com", password = "hunter2")
|
||||
|
||||
assertNull(settingsStore.cursorFor(SettingsStore.COLLECTION_BOOKS).first())
|
||||
assertNull(settingsStore.cursorFor(SettingsStore.COLLECTION_SHELVES).first())
|
||||
assertNull(settingsStore.cursorFor(SettingsStore.COLLECTION_BOOKCASES).first())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package org.modg.bookshelf.data.repo
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.modg.bookshelf.data.local.BookDao
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.local.BookshelfDatabase
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* [BookRepository.createBook] no longer downloads anything: it takes an already-preloaded
|
||||
* cover file and copies it into app storage for SyncEngine to upload.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class BookRepositoryCoverTest {
|
||||
|
||||
// Not context.cacheDir: a file written there in @Before was observed missing by the test body.
|
||||
@get:Rule
|
||||
val tmp = TemporaryFolder()
|
||||
|
||||
private lateinit var db: BookshelfDatabase
|
||||
private lateinit var context: android.content.Context
|
||||
private lateinit var preloaded: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
preloaded = tmp.newFile("preloaded-cover").apply { writeBytes(byteArrayOf(1, 2, 3)) }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cover file is copied into app storage and recorded as localCoverPath`() = runTest {
|
||||
val repository = BookRepository(db.bookDao(), context)
|
||||
|
||||
val id = repository.createBook(title = "Dune", coverSourceUrl = "https://x/c.jpg", coverFile = preloaded)
|
||||
|
||||
val saved = checkNotNull(repository.getById(id))
|
||||
val local = File(checkNotNull(saved.localCoverPath))
|
||||
assertEquals(File(context.filesDir, "covers/$id.jpg"), local)
|
||||
assertArrayEquals(byteArrayOf(1, 2, 3), local.readBytes())
|
||||
assertEquals("https://x/c.jpg", saved.coverSourceUrl)
|
||||
assertTrue("a copy: the caller still owns the preload", preloaded.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no cover file means no localCoverPath and no download attempt`() = runTest {
|
||||
val repository = BookRepository(db.bookDao(), context)
|
||||
|
||||
val id = repository.createBook(title = "Dune", coverSourceUrl = "https://x/c.jpg")
|
||||
|
||||
assertNull(checkNotNull(repository.getById(id)).localCoverPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed write deletes the copied cover, leaving nothing unowned in app storage`() = runTest {
|
||||
val repository = BookRepository(ThrowingUpsertBookDao(db.bookDao()), context)
|
||||
|
||||
try {
|
||||
repository.createBook(title = "Dune", coverFile = preloaded)
|
||||
fail("expected the write failure to propagate")
|
||||
} catch (e: IllegalStateException) {
|
||||
// expected
|
||||
}
|
||||
|
||||
assertEquals(0, File(context.filesDir, "covers").listFiles()?.size ?: 0)
|
||||
assertTrue(preloaded.exists())
|
||||
assertTrue(db.bookDao().observeAll().first().isEmpty())
|
||||
}
|
||||
|
||||
private class ThrowingUpsertBookDao(private val delegate: BookDao) : BookDao by delegate {
|
||||
override suspend fun upsert(book: BookEntity) = throw IllegalStateException("disk full")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package org.modg.bookshelf.data.repo
|
||||
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
/**
|
||||
* The cover preload that replaced save-time downloading: the file must be ready (or the
|
||||
* failure visible) before Save, and no file may outlive the sheet that asked for it.
|
||||
*/
|
||||
class CoverPreloadTest {
|
||||
|
||||
@get:Rule
|
||||
val tmp = TemporaryFolder()
|
||||
|
||||
private val host = FakeCoverHost()
|
||||
private lateinit var preloadDir: File
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
preloadDir = File(tmp.root, "cover-preload")
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
host.release()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private val url = "https://covers.example.org/b/id/42-M.jpg"
|
||||
|
||||
// --- CoverDownloader ---
|
||||
|
||||
@Test
|
||||
fun `a successful download writes the bytes into the preload dir`() = runBlocking {
|
||||
host.serve(url, byteArrayOf(9, 8, 7))
|
||||
|
||||
val result = host.downloader(preloadDir).download(url)
|
||||
|
||||
val file = (result as CoverFetch.Ready).file
|
||||
assertEquals(preloadDir, file.parentFile)
|
||||
assertArrayEquals(byteArrayOf(9, 8, 7), file.readBytes())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an http error is Failed with the status in the reason, and leaves no file`() = runBlocking {
|
||||
host.fail(url, 503)
|
||||
|
||||
val result = host.downloader(preloadDir).download(url)
|
||||
|
||||
assertTrue(result is CoverFetch.Failed)
|
||||
assertTrue((result as CoverFetch.Failed).reason.contains("503"))
|
||||
assertEquals(0, preloadDir.listFiles()?.size ?: 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transport failure is Failed, not a crash, and leaves no file`() = runBlocking {
|
||||
host.reset(url)
|
||||
|
||||
val result = host.downloader(preloadDir).download(url)
|
||||
|
||||
assertTrue(result is CoverFetch.Failed)
|
||||
assertEquals(0, preloadDir.listFiles()?.size ?: 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sweepStale deletes only files older than the cutoff`() {
|
||||
preloadDir.mkdirs()
|
||||
val old = File(preloadDir, "old").apply { writeBytes(byteArrayOf(1)); setLastModified(1_000) }
|
||||
val fresh = File(preloadDir, "fresh").apply { writeBytes(byteArrayOf(1)); setLastModified(5_000) }
|
||||
|
||||
host.downloader(preloadDir).sweepStale(olderThanMillis = 3_000)
|
||||
|
||||
assertFalse(old.exists())
|
||||
assertTrue(fresh.exists())
|
||||
}
|
||||
|
||||
// --- CoverPreload ---
|
||||
|
||||
@Test
|
||||
fun `start downloads, and starting the same url again does not download twice`() = runBlocking {
|
||||
host.serve(url)
|
||||
val preload = CoverPreload(host.downloader(preloadDir), scope)
|
||||
|
||||
preload.start(url)
|
||||
preload.start(url)
|
||||
|
||||
val ready = preload.awaitSettled() as CoverPreloadState.Ready
|
||||
assertTrue(ready.file.exists())
|
||||
assertEquals(1, host.requestCount(url))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different url replaces the preload and deletes the old file`() = runBlocking {
|
||||
val other = "https://covers.example.org/b/id/43-M.jpg"
|
||||
host.serve(url)
|
||||
host.serve(other)
|
||||
val preload = CoverPreload(host.downloader(preloadDir), scope)
|
||||
|
||||
preload.start(url)
|
||||
val first = (preload.awaitSettled() as CoverPreloadState.Ready).file
|
||||
preload.start(other)
|
||||
val second = preload.awaitSettled() as CoverPreloadState.Ready
|
||||
|
||||
assertFalse(first.exists())
|
||||
assertEquals(other, second.url)
|
||||
assertTrue(second.file.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard deletes the downloaded file`() = runBlocking {
|
||||
host.serve(url)
|
||||
val preload = CoverPreload(host.downloader(preloadDir), scope)
|
||||
preload.start(url)
|
||||
val file = (preload.awaitSettled() as CoverPreloadState.Ready).file
|
||||
|
||||
preload.discard()
|
||||
|
||||
assertFalse(file.exists())
|
||||
assertEquals(CoverPreloadState.None, preload.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed preload can be retried`() = runBlocking {
|
||||
host.reset(url)
|
||||
val preload = CoverPreload(host.downloader(preloadDir), scope)
|
||||
preload.start(url)
|
||||
assertTrue(preload.awaitSettled() is CoverPreloadState.Failed)
|
||||
|
||||
host.serve(url)
|
||||
preload.retry()
|
||||
|
||||
assertTrue(preload.awaitSettled() is CoverPreloadState.Ready)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `awaitSettled waits for a download still in flight`() = runBlocking {
|
||||
host.serve(url)
|
||||
host.hold()
|
||||
val preload = CoverPreload(host.downloader(preloadDir, Dispatchers.IO), scope)
|
||||
preload.start(url)
|
||||
assertTrue(preload.state.value is CoverPreloadState.Loading)
|
||||
|
||||
host.release()
|
||||
val settled = withTimeout(10_000) { preload.awaitSettled() }
|
||||
|
||||
assertTrue(settled is CoverPreloadState.Ready)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a download discarded while in flight leaves no file behind`() = runBlocking {
|
||||
host.serve(url)
|
||||
host.hold()
|
||||
val preload = CoverPreload(host.downloader(preloadDir, Dispatchers.IO), scope)
|
||||
preload.start(url)
|
||||
pollUntil { host.requestCount(url) == 1 } // blocked inside the HTTP call
|
||||
|
||||
preload.discard()
|
||||
host.release()
|
||||
// The blocked call returns on its IO thread after cancellation and must clean up after itself.
|
||||
pollUntil { (preloadDir.listFiles()?.size ?: 0) == 0 }
|
||||
Thread.sleep(200) // and nothing lands afterwards
|
||||
|
||||
assertEquals(CoverPreloadState.None, preload.state.value)
|
||||
assertEquals(0, preloadDir.listFiles()?.size ?: 0)
|
||||
}
|
||||
|
||||
/** Bounded: a condition that never comes true fails the test instead of hanging the build. */
|
||||
private fun pollUntil(condition: () -> Boolean) {
|
||||
repeat(500) {
|
||||
if (condition()) return
|
||||
Thread.sleep(10)
|
||||
}
|
||||
fail("condition not met within 5s")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ready file that has vanished is downloaded again rather than handed to save`() = runBlocking {
|
||||
host.serve(url)
|
||||
val preload = CoverPreload(host.downloader(preloadDir), scope)
|
||||
preload.start(url)
|
||||
(preload.awaitSettled() as CoverPreloadState.Ready).file.delete() // e.g. Android cleared the cache dir
|
||||
|
||||
val settled = preload.awaitSettled() as CoverPreloadState.Ready
|
||||
|
||||
assertTrue(settled.file.exists())
|
||||
assertEquals(2, host.requestCount(url))
|
||||
assertEquals(url, preload.state.first().let { (it as CoverPreloadState.Ready).url })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.modg.bookshelf.data.repo
|
||||
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
|
||||
/**
|
||||
* An image host for cover-preload tests, answered by an interceptor (no sockets). Each URL
|
||||
* gets bytes, an HTTP error code, or a thrown IOException; anything unregistered is a 404.
|
||||
* [hold] makes downloads block until [release], to test Save waiting on a preload.
|
||||
*/
|
||||
class FakeCoverHost {
|
||||
private sealed interface Answer {
|
||||
class Bytes(val bytes: ByteArray) : Answer
|
||||
class Code(val code: Int) : Answer
|
||||
class Throw(val e: IOException) : Answer
|
||||
}
|
||||
|
||||
private val answers = ConcurrentHashMap<String, Answer>()
|
||||
private val requests = ConcurrentHashMap<String, Int>()
|
||||
@Volatile private var gate: CountDownLatch? = null
|
||||
|
||||
fun serve(url: String, bytes: ByteArray = byteArrayOf(1, 2, 3, 4)) { answers[url] = Answer.Bytes(bytes) }
|
||||
fun fail(url: String, code: Int) { answers[url] = Answer.Code(code) }
|
||||
fun reset(url: String, message: String = "connection reset") { answers[url] = Answer.Throw(IOException(message)) }
|
||||
fun requestCount(url: String): Int = requests[url] ?: 0
|
||||
|
||||
fun hold() { gate = CountDownLatch(1) }
|
||||
fun release() { gate?.countDown() }
|
||||
|
||||
val client: OkHttpClient = OkHttpClient.Builder().addInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
val url = request.url.toString()
|
||||
requests.merge(url, 1, Int::plus)
|
||||
gate?.await(10, TimeUnit.SECONDS)
|
||||
when (val answer = answers[url]) {
|
||||
is Answer.Bytes -> response(request, 200, answer.bytes)
|
||||
is Answer.Code -> response(request, answer.code, ByteArray(0))
|
||||
is Answer.Throw -> throw answer.e
|
||||
null -> response(request, 404, ByteArray(0))
|
||||
}
|
||||
}.build()
|
||||
|
||||
/**
|
||||
* Downloads run on the calling thread by default, so no IO-thread download can resume
|
||||
* into a later test after this one's Dispatchers.resetMain. Tests that [hold] must pass
|
||||
* a real dispatcher instead (a held download would block the test thread itself).
|
||||
*/
|
||||
fun downloader(preloadDir: File, dispatcher: CoroutineDispatcher = Dispatchers.Unconfined): CoverDownloader =
|
||||
CoverDownloader(client, preloadDir, dispatcher)
|
||||
|
||||
private fun response(request: okhttp3.Request, code: Int, bytes: ByteArray) = Response.Builder()
|
||||
.request(request)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(code)
|
||||
.message("fake")
|
||||
.body(bytes.toResponseBody("image/jpeg".toMediaType()))
|
||||
.build()
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.modg.bookshelf.data.repo
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
@@ -10,8 +11,10 @@ import org.modg.bookshelf.data.remote.BookcaseDto
|
||||
import org.modg.bookshelf.data.remote.PbListResponse
|
||||
import org.modg.bookshelf.data.remote.PocketBaseApi
|
||||
import org.modg.bookshelf.data.remote.ShelfDto
|
||||
import org.modg.bookshelf.data.remote.UserRecordDto
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
fun httpError(code: Int): HttpException {
|
||||
val body = "{}".toResponseBody("application/json".toMediaType())
|
||||
@@ -37,6 +40,16 @@ class FakePocketBaseApi : PocketBaseApi {
|
||||
val updateShelfErrors = mutableMapOf<String, HttpException>()
|
||||
val updateBookErrors = mutableMapOf<String, HttpException>()
|
||||
|
||||
/** Defaults to a normal, successful refresh — most tests aren't exercising the auth-expiry path. */
|
||||
var authRefreshHttpError: HttpException? = null
|
||||
var authRefreshIOException: IOException? = null
|
||||
var authRefreshCancellation: CancellationException? = null
|
||||
var refreshedToken: String = "refreshed-fake-token"
|
||||
var refreshedUserId: String = "fake-user-id"
|
||||
|
||||
/** One-shot: exercises the "a sync whose pull fails leaves the one-time-repull flag unset" case. */
|
||||
var nextListBooksError: HttpException? = null
|
||||
|
||||
fun seedBook(dto: BookDto) {
|
||||
books[dto.id] = dto
|
||||
}
|
||||
@@ -44,9 +57,19 @@ class FakePocketBaseApi : PocketBaseApi {
|
||||
override suspend fun authWithPassword(body: AuthWithPasswordRequest): AuthResponse =
|
||||
AuthResponse(token = "fake-token")
|
||||
|
||||
override suspend fun authRefresh(): AuthResponse {
|
||||
callLog += "authRefresh"
|
||||
authRefreshCancellation?.let { throw it }
|
||||
authRefreshIOException?.let { throw it }
|
||||
authRefreshHttpError?.let { throw it }
|
||||
return AuthResponse(token = refreshedToken, record = UserRecordDto(id = refreshedUserId))
|
||||
}
|
||||
|
||||
// ---- books ----
|
||||
|
||||
override suspend fun listBooks(filter: String?, sort: String, perPage: Int, page: Int): PbListResponse<BookDto> {
|
||||
callLog += "listBooks"
|
||||
nextListBooksError?.let { nextListBooksError = null; throw it }
|
||||
val cursor = filter?.substringAfter("'")?.substringBefore("'")
|
||||
val items = books.values.filter { cursor == null || it.updated > cursor }.sortedBy { it.updated }
|
||||
return PbListResponse(page = 1, totalPages = 1, totalItems = items.size, items = items)
|
||||
|
||||
@@ -2,11 +2,16 @@ package org.modg.bookshelf.data.repo
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -36,6 +41,11 @@ class SyncEngineTest {
|
||||
.build()
|
||||
settingsStore = SettingsStore(ApplicationProvider.getApplicationContext())
|
||||
settingsStore.setServerUrl("https://example.test")
|
||||
// DataStore's on-disk state leaks across Robolectric test methods in
|
||||
// this class (see SettingsStoreTest's setUp for the same issue) — the
|
||||
// one-time-repull flag being true from an earlier test would silently
|
||||
// change the repull tests' behavior.
|
||||
settingsStore.resetFullRepullDoneForTesting()
|
||||
api = FakePocketBaseApi()
|
||||
engine = SyncEngine(
|
||||
apiProvider = { api },
|
||||
@@ -198,4 +208,124 @@ class SyncEngineTest {
|
||||
assertEquals("My local edit", db.bookDao().getByIdIncludingDeleted("book1")?.title)
|
||||
assertEquals(SyncState.PENDING_UPDATE, db.bookDao().getByIdIncludingDeleted("book1")?.syncState)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- auth refresh (2026-09-17)
|
||||
|
||||
@Test
|
||||
fun `sync refreshes the token before any push or pull, and persists the refreshed token`() = runTest {
|
||||
db.bookcaseDao().upsert(bookcase("case1"))
|
||||
api.refreshedToken = "brand-new-token"
|
||||
api.refreshedUserId = "user-42"
|
||||
|
||||
val result = engine.sync()
|
||||
|
||||
assertTrue(result is SyncResult.Success)
|
||||
val refreshIdx = api.callLog.indexOf("authRefresh")
|
||||
val pushIdx = api.callLog.indexOf("createBookcase:case1")
|
||||
assertTrue("expected authRefresh before any push, got ${api.callLog}", refreshIdx == 0 && refreshIdx < pushIdx)
|
||||
assertEquals("brand-new-token", settingsStore.authToken.first())
|
||||
assertEquals("user-42", settingsStore.userId.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 401 on refresh is the 2026-09-17 incident's regression test - AuthExpired, token cleared, nothing else touched`() = runTest {
|
||||
settingsStore.setAuthToken("stale-5-day-old-token")
|
||||
settingsStore.setUserEmail("reader@example.com")
|
||||
db.bookDao().upsert(book("book1", state = SyncState.PENDING_UPDATE).copy(title = "unsynced edit"))
|
||||
api.authRefreshHttpError = httpError(401)
|
||||
|
||||
val result = engine.sync()
|
||||
|
||||
assertTrue(result is SyncResult.AuthExpired)
|
||||
assertNull("token must be cleared so the app doesn't keep hammering the server with it", settingsStore.authToken.first())
|
||||
assertEquals("https://example.test", settingsStore.serverUrl.first())
|
||||
assertEquals("reader@example.com", settingsStore.userEmail.first())
|
||||
assertEquals("no push/pull call may have been made", listOf("authRefresh"), api.callLog)
|
||||
val stillLocal = db.bookDao().getByIdIncludingDeleted("book1")
|
||||
assertEquals("the PENDING_UPDATE book must survive an unauthenticated sync attempt intact", "unsynced edit", stillLocal?.title)
|
||||
assertEquals(SyncState.PENDING_UPDATE, stillLocal?.syncState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 403 on refresh also reports AuthExpired`() = runTest {
|
||||
api.authRefreshHttpError = httpError(403)
|
||||
|
||||
assertTrue(engine.sync() is SyncResult.AuthExpired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an IOException on refresh is a plain Failure and does not clear the token`() = runTest {
|
||||
settingsStore.setAuthToken("still-good-token")
|
||||
api.authRefreshIOException = java.io.IOException("no network")
|
||||
|
||||
val result = engine.sync()
|
||||
|
||||
assertTrue(result is SyncResult.Failure)
|
||||
assertEquals("still-good-token", settingsStore.authToken.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 401 from a push call mid-sync reports AuthExpired and does not hard-delete the record`() = runTest {
|
||||
db.bookDao().upsert(book("book1", state = SyncState.PENDING_UPDATE).copy(title = "keep me"))
|
||||
api.updateBookErrors["book1"] = httpError(401)
|
||||
|
||||
val result = engine.sync()
|
||||
|
||||
assertTrue(result is SyncResult.AuthExpired)
|
||||
val stillLocal = db.bookDao().getByIdIncludingDeleted("book1")
|
||||
assertNotNull("a 401 must never be mistaken for the 404-means-deleted case", stillLocal)
|
||||
assertEquals("keep me", stillLocal?.title)
|
||||
assertEquals(SyncState.PENDING_UPDATE, stillLocal?.syncState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancellationException thrown during sync propagates instead of becoming a Failure`() = runTest {
|
||||
api.authRefreshCancellation = CancellationException("scope cancelled")
|
||||
|
||||
try {
|
||||
engine.sync()
|
||||
fail("expected CancellationException to propagate")
|
||||
} catch (e: CancellationException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- one-time full re-pull (2026-09-17)
|
||||
|
||||
@Test
|
||||
fun `first sync after the fix restores a book the incident hard-deleted, and marks the repull done`() = runTest {
|
||||
// Mirrors the incident: the pull cursor is already past the lost row's
|
||||
// `updated`, and the row is gone locally, but it is still intact on the server.
|
||||
settingsStore.setCursor(SettingsStore.COLLECTION_BOOKS, "2024-06-01 00:00:00.000Z")
|
||||
api.seedBook(BookDto(id = "lost-book", title = "Recovered", updated = "2024-01-01 00:00:00.000Z", created = "2024-01-01 00:00:00.000Z"))
|
||||
assertNull(db.bookDao().getByIdIncludingDeleted("lost-book"))
|
||||
|
||||
val result = engine.sync()
|
||||
|
||||
assertTrue(result is SyncResult.Success)
|
||||
assertEquals("Recovered", db.bookDao().getById("lost-book")?.title)
|
||||
assertTrue(settingsStore.fullRepullDone.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sync whose pull fails leaves the one-time-repull flag unset so the next sync retries it`() = runTest {
|
||||
api.nextListBooksError = httpError(500)
|
||||
|
||||
val result = engine.sync()
|
||||
|
||||
assertTrue(result is SyncResult.Failure)
|
||||
assertFalse(settingsStore.fullRepullDone.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `once the repull flag is set, a normal sync no longer clears cursors`() = runTest {
|
||||
settingsStore.setFullRepullDone()
|
||||
settingsStore.setCursor(SettingsStore.COLLECTION_BOOKS, "2024-06-01 00:00:00.000Z")
|
||||
// Older than the cursor: an incremental pull must NOT see this one.
|
||||
api.seedBook(BookDto(id = "old-book", title = "Too old", updated = "2024-01-01 00:00:00.000Z", created = "2024-01-01 00:00:00.000Z"))
|
||||
|
||||
engine.sync()
|
||||
|
||||
assertNull(db.bookDao().getByIdIncludingDeleted("old-book"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package org.modg.bookshelf.diagnostics
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import java.io.File
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* [CrashReporter.recordCrash] is exercised directly (internal visibility, same
|
||||
* split as [org.modg.bookshelf.ui.scan.ScanViewModel.performSave]) rather than
|
||||
* by actually crashing a thread. [CrashReporter.install]'s tests do go through
|
||||
* the real [Thread.UncaughtExceptionHandler] plumbing, since that chaining
|
||||
* behavior is the whole point of §3 — see [CrashReporter]'s KDoc.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class CrashReporterTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var filesDir: File
|
||||
private lateinit var reporter: CrashReporter
|
||||
private var originalHandler: Thread.UncaughtExceptionHandler? = null
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
filesDir = context.filesDir
|
||||
clearReports()
|
||||
reporter = CrashReporter(context)
|
||||
originalHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Thread.setDefaultUncaughtExceptionHandler(originalHandler)
|
||||
clearReports()
|
||||
}
|
||||
|
||||
private fun clearReports() {
|
||||
filesDir.listFiles { f -> f.name.startsWith("crash-") }?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun reportFiles(): List<File> = filesDir.listFiles { f -> f.name.startsWith("crash-") }?.toList().orEmpty()
|
||||
|
||||
@Test
|
||||
fun `recordCrash writes one file containing the exception class and its cause's message`() {
|
||||
val cause = IllegalStateException("disk full")
|
||||
val throwable = RuntimeException("wrapped", cause)
|
||||
|
||||
reporter.recordCrash(Thread.currentThread(), throwable)
|
||||
|
||||
val files = reportFiles()
|
||||
assertEquals(1, files.size)
|
||||
val content = files.single().readText()
|
||||
assertTrue(content.contains("RuntimeException"))
|
||||
assertTrue(content.contains("disk full"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps only the newest 5 crash reports`() {
|
||||
repeat(7) { i -> reporter.recordCrash(Thread.currentThread(), RuntimeException("crash $i")) }
|
||||
|
||||
assertEquals(5, reportFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `list reports the newest crash first`() {
|
||||
reporter.recordCrash(Thread.currentThread(), RuntimeException("first"))
|
||||
reporter.recordCrash(Thread.currentThread(), RuntimeException("second"))
|
||||
|
||||
val latest = reporter.latest()
|
||||
checkNotNull(latest)
|
||||
assertTrue(latest.headline.contains("second"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `install records the crash AND delegates to the previously-installed handler`() {
|
||||
var delegatedTo: Throwable? = null
|
||||
val previous = Thread.UncaughtExceptionHandler { _, e -> delegatedTo = e }
|
||||
Thread.setDefaultUncaughtExceptionHandler(previous)
|
||||
|
||||
reporter.install()
|
||||
val installed = checkNotNull(Thread.getDefaultUncaughtExceptionHandler())
|
||||
val throwable = RuntimeException("boom")
|
||||
installed.uncaughtException(Thread.currentThread(), throwable)
|
||||
|
||||
// Delegation is the load-bearing part -- without it Android never sees the
|
||||
// crash, and the process hangs instead of dying (see the class KDoc).
|
||||
assertEquals(throwable, delegatedTo)
|
||||
assertEquals(1, reportFiles().size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package org.modg.bookshelf.livemetadata
|
||||
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.data.metadata.LookupResult
|
||||
import org.modg.bookshelf.data.metadata.MetadataRepository
|
||||
|
||||
/**
|
||||
* OPT-IN live test — skipped unless `LIVE_METADATA=1` is set, exactly like
|
||||
* [org.modg.bookshelf.livesync.LiveSyncTest]. It talks to the real Open Library
|
||||
* and Google Books APIs over the real network.
|
||||
*
|
||||
* Written 2026-09-12 to investigate a crash the user hit on the FIRST scan after
|
||||
* the Google Books key landed (commit 486f6eb): the ISBN was displayed, the
|
||||
* spinner ran, then the app died. It has never reproduced.
|
||||
*
|
||||
* The structural reason to suspect this path: keyless Google Books returned 429
|
||||
* to every caller on the internet (docs/METADATA-SOURCES.md), so until that
|
||||
* commit, `GoogleBooksClient.classify`'s 2xx branch, `toBookMetadata()`,
|
||||
* `normalizeCoverUrl()` and the two-source merge had **never executed in
|
||||
* production**. The first real exercise of a code path is where a first crash
|
||||
* belongs.
|
||||
*
|
||||
* What this test is actually asserting is not "the lookup found the book" — that
|
||||
* is the subject of other tests and depends on flaky third parties. It asserts
|
||||
* that [MetadataRepository.lookup] **returns rather than throws**, which is the
|
||||
* contract both clients document ("Never throws: every outcome ... comes back as
|
||||
* a SourceResult") and which nothing currently enforces: `fetch` catches only
|
||||
* `IOException`, `classify` only `SerializationException`/`IllegalArgumentException`,
|
||||
* and `ScanViewModel.runLookup` has no try/catch at all, so anything else that
|
||||
* escapes kills the process.
|
||||
*
|
||||
* A JVM pass here does NOT clear the code — the phone differs in TLS provider,
|
||||
* memory pressure and Android API level. A JVM FAILURE, on the other hand, would
|
||||
* be the bug itself.
|
||||
*/
|
||||
class LiveMetadataLookupTest {
|
||||
|
||||
/** The two the user scanned that failed in wave 5/6, plus a mainstream control. */
|
||||
private val isbns = listOf(
|
||||
"9781883937386", // Hittite Warrior — Bethlehem Books
|
||||
"9781883937676", // Shadow Hawk — Bethlehem Books
|
||||
"9780140449136", // Crime and Punishment — Penguin
|
||||
)
|
||||
|
||||
/** Same shape as AppContainer.metadataHttpClient, including the measured timeouts. */
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.callTimeout(25, TimeUnit.SECONDS)
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = false
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `real lookups against both live sources return an outcome instead of throwing`() {
|
||||
assumeTrue(
|
||||
"Live metadata test skipped (set LIVE_METADATA=1, and GOOGLE_BOOKS_API_KEY for the keyed path)",
|
||||
System.getenv("LIVE_METADATA") != null,
|
||||
)
|
||||
val apiKey = System.getenv("GOOGLE_BOOKS_API_KEY").orEmpty()
|
||||
println("live metadata lookup: key ${if (apiKey.isBlank()) "ABSENT (keyless path)" else "present"}")
|
||||
|
||||
val repository = MetadataRepository(httpClient, json, apiKey)
|
||||
|
||||
// Each ISBN is run repeatedly: the crash under investigation happened once
|
||||
// and never again, so a single green pass proves very little. Any throw
|
||||
// from any iteration fails the test and names the iteration.
|
||||
val results = mutableListOf<String>()
|
||||
for (isbn in isbns) {
|
||||
repeat(REPEATS) { attempt ->
|
||||
val elapsed = System.currentTimeMillis()
|
||||
val result = try {
|
||||
runBlocking { repository.lookup(isbn) }
|
||||
} catch (t: Throwable) {
|
||||
throw AssertionError(
|
||||
"lookup($isbn) attempt ${attempt + 1} THREW ${t.javaClass.name}: ${t.message} " +
|
||||
"— this is the crash; MetadataRepository.lookup must never throw",
|
||||
t,
|
||||
)
|
||||
}
|
||||
val took = System.currentTimeMillis() - elapsed
|
||||
val summary = when (result) {
|
||||
is LookupResult.Found -> "Found(${result.metadata.title}, cover=${result.metadata.coverUrl != null})"
|
||||
is LookupResult.NotFound -> "NotFound"
|
||||
is LookupResult.Unavailable -> "Unavailable(${result.reason})"
|
||||
}
|
||||
results += "$isbn #${attempt + 1} ${took}ms $summary"
|
||||
}
|
||||
}
|
||||
|
||||
results.forEach(::println)
|
||||
assertTrue("expected one result per lookup", results.size == isbns.size * REPEATS)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val REPEATS = 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package org.modg.bookshelf.ui.add
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure validation logic for the add-by-hand form — no ViewModel/Room needed.
|
||||
* See [AddBookViewModelTest] for how [AddBookUiState.canSave] and the save
|
||||
* paths build on these.
|
||||
*/
|
||||
class AddBookModelsTest {
|
||||
|
||||
// --- BookDraft.fromRouteArgs ---
|
||||
|
||||
@Test
|
||||
fun `fromRouteArgs fills title and isbn when both present`() {
|
||||
val draft = BookDraft.fromRouteArgs(title = "Dune", isbn = "9780441013593")
|
||||
|
||||
assertEquals("Dune", draft.title)
|
||||
assertEquals("9780441013593", draft.isbn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromRouteArgs defaults absent args to blank, not the literal string null`() {
|
||||
val draft = BookDraft.fromRouteArgs(title = null, isbn = null)
|
||||
|
||||
assertEquals("", draft.title)
|
||||
assertEquals("", draft.isbn)
|
||||
}
|
||||
|
||||
// --- validateIsbn ---
|
||||
|
||||
@Test
|
||||
fun `blank isbn is valid — no ISBN is a legal book`() {
|
||||
assertEquals(IsbnFieldState.Blank, validateIsbn(""))
|
||||
assertEquals(IsbnFieldState.Blank, validateIsbn(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid isbn13 is accepted and carries no isbn10`() {
|
||||
val result = validateIsbn("9780441013593")
|
||||
|
||||
val valid = result as? IsbnFieldState.Valid
|
||||
checkNotNull(valid) { "expected Valid, got $result" }
|
||||
assertEquals("9780441013593", valid.isbn13)
|
||||
assertNull(valid.isbn10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid isbn10 is normalized to isbn13 and the isbn10 is kept`() {
|
||||
val result = validateIsbn("0-441-01359-7") // Dune's ISBN-10, hyphenated
|
||||
|
||||
val valid = result as? IsbnFieldState.Valid
|
||||
checkNotNull(valid) { "expected Valid, got $result" }
|
||||
assertEquals("9780441013593", valid.isbn13)
|
||||
assertEquals("0441013597", valid.isbn10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bad checksum is Invalid, not silently dropped`() {
|
||||
// wave 5 fixed exactly this silent-discard bug in the scan screen's
|
||||
// manual-ISBN dialog; this form must not reintroduce it.
|
||||
assertEquals(IsbnFieldState.Invalid, validateIsbn("9780441013594")) // last digit wrong
|
||||
assertEquals(IsbnFieldState.Invalid, validateIsbn("not-an-isbn"))
|
||||
}
|
||||
|
||||
// --- validatePages ---
|
||||
|
||||
@Test
|
||||
fun `blank pages is valid and means unknown, not zero`() {
|
||||
assertEquals(PagesFieldState.Blank, validatePages(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `digit-only pages is valid`() {
|
||||
assertEquals(PagesFieldState.Valid(412), validatePages("412"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-digit pages is rejected`() {
|
||||
assertEquals(PagesFieldState.Invalid, validatePages("412pp"))
|
||||
assertEquals(PagesFieldState.Invalid, validatePages("-5"))
|
||||
assertEquals(PagesFieldState.Invalid, validatePages("4.5"))
|
||||
}
|
||||
|
||||
// --- AddBookUiState.canSave ---
|
||||
|
||||
@Test
|
||||
fun `canSave is false with a blank title`() {
|
||||
val state = AddBookUiState(draft = BookDraft(title = ""))
|
||||
|
||||
assertTrue(!state.canSave)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canSave is true with only a title — every other field is optional`() {
|
||||
val state = AddBookUiState(draft = BookDraft(title = "Dune"))
|
||||
|
||||
assertTrue(state.canSave)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canSave is false while an isbn error is showing`() {
|
||||
val state = AddBookUiState(draft = BookDraft(title = "Dune", isbn = "not-an-isbn"))
|
||||
|
||||
assertTrue(!state.canSave)
|
||||
assertEquals("That isn't a valid ISBN — check the digits.", state.isbnError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canSave is false while a pages error is showing`() {
|
||||
val state = AddBookUiState(draft = BookDraft(title = "Dune", pages = "412pp"))
|
||||
|
||||
assertTrue(!state.canSave)
|
||||
assertEquals("Pages must be a number.", state.pagesError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canSave is false while a save is already in flight`() {
|
||||
val state = AddBookUiState(draft = BookDraft(title = "Dune"), isSaving = true)
|
||||
|
||||
assertTrue(!state.canSave)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package org.modg.bookshelf.ui.add
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.modg.bookshelf.data.local.BookDao
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.local.BookshelfDatabase
|
||||
import org.modg.bookshelf.data.prefs.SettingsStore
|
||||
import org.modg.bookshelf.data.repo.BookRepository
|
||||
import org.modg.bookshelf.data.repo.CoverPreloadState
|
||||
import org.modg.bookshelf.data.repo.FakeCoverHost
|
||||
import org.modg.bookshelf.data.repo.LocationRepository
|
||||
import org.modg.bookshelf.data.repo.decodeAuthors
|
||||
import org.modg.bookshelf.ui.scan.DuplicateStatus
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class AddBookViewModelTest {
|
||||
|
||||
private lateinit var db: BookshelfDatabase
|
||||
private lateinit var settingsStore: SettingsStore
|
||||
private lateinit var locationRepository: LocationRepository
|
||||
private lateinit var bookRepository: BookRepository
|
||||
private lateinit var viewModel: AddBookViewModel
|
||||
private lateinit var shelfId: String
|
||||
private lateinit var context: android.content.Context
|
||||
private val coverHost = FakeCoverHost()
|
||||
|
||||
@Before
|
||||
fun setUp() = runTest {
|
||||
// AddBookViewModel.uiState is a combine(...).stateIn(viewModelScope, ...) —
|
||||
// viewModelScope runs on Dispatchers.Main, which nothing else in this test
|
||||
// class supplies. Without this, the combine's collector coroutine either
|
||||
// never runs or runs on Robolectric's paused main looper, so uiState.value
|
||||
// stays stuck at its initial seed forever (and any `.first { predicate }`
|
||||
// await hangs). Unconfined so it drains synchronously with no explicit
|
||||
// advanceUntilIdle() needed at each assertion site.
|
||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
settingsStore = SettingsStore(context)
|
||||
locationRepository = LocationRepository(db.bookcaseDao(), db.shelfDao(), db.bookDao())
|
||||
bookRepository = BookRepository(db.bookDao(), context)
|
||||
val bookcaseId = locationRepository.createBookcase(name = "Living Room")
|
||||
shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf")
|
||||
|
||||
viewModel = AddBookViewModel(
|
||||
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
|
||||
bookRepository = bookRepository,
|
||||
locationRepository = locationRepository,
|
||||
settingsStore = settingsStore,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
// --- field mapping ---
|
||||
|
||||
@Test
|
||||
fun `save maps every field, splitting and trimming authors and dropping empty entries`() = runTest {
|
||||
viewModel.onTitleChanged(" Dune ")
|
||||
viewModel.onSubtitleChanged("A novel")
|
||||
viewModel.onAuthorsChanged(" Frank Herbert ,, Brian Herbert , ")
|
||||
viewModel.onPublisherChanged("Ace")
|
||||
viewModel.onPublishedDateChanged("1965")
|
||||
viewModel.onPagesChanged("412")
|
||||
viewModel.onIsbnChanged("9780441013593")
|
||||
viewModel.onDescriptionChanged("A desert planet.")
|
||||
viewModel.onShelfSelected(shelfId)
|
||||
|
||||
val bookId = viewModel.performSave(clearAfterSave = false)
|
||||
checkNotNull(bookId)
|
||||
|
||||
val saved = bookRepository.getById(bookId)
|
||||
checkNotNull(saved)
|
||||
assertEquals("Dune", saved.title)
|
||||
assertEquals("A novel", saved.subtitle)
|
||||
assertEquals(listOf("Frank Herbert", "Brian Herbert"), decodeAuthors(saved.authorsJson))
|
||||
assertEquals("Ace", saved.publisher)
|
||||
assertEquals("1965", saved.publishedDate)
|
||||
assertEquals(412, saved.pageCount)
|
||||
assertEquals("9780441013593", saved.isbn13)
|
||||
assertEquals("A desert planet.", saved.description)
|
||||
assertEquals(shelfId, saved.shelfId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank isbn saves as null, not a silent parse failure`() = runTest {
|
||||
viewModel.onTitleChanged("Untitled Pamphlet")
|
||||
|
||||
val bookId = viewModel.performSave(clearAfterSave = false)
|
||||
checkNotNull(bookId)
|
||||
|
||||
val saved = bookRepository.getById(bookId)
|
||||
checkNotNull(saved)
|
||||
assertNull(saved.isbn13)
|
||||
assertNull(saved.isbn10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a valid isbn10 is saved as both the normalized isbn13 and the original isbn10`() = runTest {
|
||||
viewModel.onTitleChanged("Dune")
|
||||
viewModel.onIsbnChanged("0-441-01359-7")
|
||||
|
||||
val bookId = viewModel.performSave(clearAfterSave = false)
|
||||
checkNotNull(bookId)
|
||||
|
||||
val saved = bookRepository.getById(bookId)
|
||||
checkNotNull(saved)
|
||||
assertEquals("9780441013593", saved.isbn13)
|
||||
assertEquals("0441013597", saved.isbn10)
|
||||
}
|
||||
|
||||
// --- duplicate warning ---
|
||||
|
||||
@Test
|
||||
fun `entering the isbn of an already-owned book surfaces the AlreadyOwned warning`() = runTest {
|
||||
bookRepository.createBook(title = "Dune", isbn13 = "9780441013593")
|
||||
|
||||
viewModel.onIsbnChanged("9780441013593")
|
||||
|
||||
val duplicate = viewModel.uiState.first { it.duplicate !is DuplicateStatus.New }.duplicate
|
||||
val alreadyOwned = duplicate as? DuplicateStatus.AlreadyOwned
|
||||
checkNotNull(alreadyOwned) { "expected AlreadyOwned, got $duplicate" }
|
||||
assertEquals("Dune", alreadyOwned.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an isbn nobody owns yet stays New`() = runTest {
|
||||
viewModel.onIsbnChanged("9780441013593")
|
||||
// Let the duplicate check actually run before asserting on it.
|
||||
bookRepository.findByIsbn13("9780441013593")
|
||||
|
||||
// .first(), not .value — see the KDoc on AddBookViewModel.uiState.
|
||||
assertEquals(DuplicateStatus.New, viewModel.uiState.first().duplicate)
|
||||
}
|
||||
|
||||
// --- save & add another ---
|
||||
|
||||
@Test
|
||||
fun `save and add another clears the form but keeps the shelf and increments the count`() = runTest {
|
||||
viewModel.onShelfSelected(shelfId)
|
||||
viewModel.onTitleChanged("Dune")
|
||||
viewModel.onAuthorsChanged("Frank Herbert")
|
||||
|
||||
viewModel.performSave(clearAfterSave = true)
|
||||
|
||||
// .first(), not .value — see the KDoc on AddBookViewModel.uiState.
|
||||
val state = viewModel.uiState.first()
|
||||
assertEquals("", state.draft.title)
|
||||
assertEquals("", state.draft.authors)
|
||||
assertEquals(shelfId, state.selectedShelfId)
|
||||
assertEquals(1, state.savedCount)
|
||||
assertEquals("Dune", state.lastSavedTitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plain save does not clear the form`() = runTest {
|
||||
viewModel.onTitleChanged("Dune")
|
||||
|
||||
viewModel.performSave(clearAfterSave = false)
|
||||
|
||||
// .first(), not .value — see the KDoc on AddBookViewModel.uiState.
|
||||
val state = viewModel.uiState.first()
|
||||
assertEquals("Dune", state.draft.title)
|
||||
assertEquals(1, state.savedCount)
|
||||
}
|
||||
|
||||
// --- failure handling: §4 of the task, mirroring ScanViewModel's runLookup guard ---
|
||||
|
||||
@Test
|
||||
fun `a repository failure leaves the form intact, reports the exception class, and does not throw`() = runTest {
|
||||
val vm = viewModelWithThrowingCreateBook(IllegalStateException("disk full"))
|
||||
vm.onTitleChanged("Dune")
|
||||
vm.onAuthorsChanged("Frank Herbert")
|
||||
|
||||
val result = vm.performSave(clearAfterSave = false) // must not throw
|
||||
|
||||
assertNull(result)
|
||||
// .first(), not .value — see the KDoc on AddBookViewModel.uiState.
|
||||
val state = vm.uiState.first()
|
||||
assertEquals("Dune", state.draft.title) // form intact
|
||||
assertEquals("Frank Herbert", state.draft.authors)
|
||||
assertEquals("IllegalStateException", state.saveError)
|
||||
assertTrue(!state.isSaving)
|
||||
assertEquals(0, state.savedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a CancellationException during save is rethrown, not swallowed as a failure`() = runTest {
|
||||
val vm = viewModelWithThrowingCreateBook(CancellationException("scope cancelled"))
|
||||
vm.onTitleChanged("Dune")
|
||||
|
||||
try {
|
||||
vm.performSave(clearAfterSave = false)
|
||||
fail("expected CancellationException to propagate")
|
||||
} catch (e: CancellationException) {
|
||||
// expected — a cancelled save is not a failed one.
|
||||
}
|
||||
}
|
||||
|
||||
// --- double-tap guard ---
|
||||
|
||||
@Test
|
||||
fun `a second save while one is in flight does not create a second book`() = runTest {
|
||||
viewModel.onTitleChanged("Dune")
|
||||
|
||||
val first = launch { viewModel.performSave(clearAfterSave = false) }
|
||||
val second = launch { viewModel.performSave(clearAfterSave = false) }
|
||||
first.join()
|
||||
second.join()
|
||||
|
||||
val allBooks = mutableListOf<BookEntity>()
|
||||
bookRepository.observeAll().let { flow -> allBooks.addAll(flow.first()) }
|
||||
assertEquals(1, allBooks.size)
|
||||
// .first(), not .value — see the KDoc on AddBookViewModel.uiState.
|
||||
assertEquals(1, viewModel.uiState.first().savedCount)
|
||||
}
|
||||
|
||||
// --- remembered shelf ---
|
||||
|
||||
@Test
|
||||
fun `saving to a shelf remembers it for next time`() = runTest {
|
||||
viewModel.onTitleChanged("Dune")
|
||||
viewModel.onShelfSelected(shelfId)
|
||||
|
||||
viewModel.performSave(clearAfterSave = false)
|
||||
|
||||
assertEquals(shelfId, settingsStore.lastShelfId.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saving as Not shelved does not overwrite the remembered shelf`() = runTest {
|
||||
settingsStore.setLastShelfId(shelfId)
|
||||
viewModel.onTitleChanged("Dune")
|
||||
viewModel.onShelfSelected(null)
|
||||
|
||||
viewModel.performSave(clearAfterSave = false)
|
||||
|
||||
assertEquals(shelfId, settingsStore.lastShelfId.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the remembered shelf pre-selects the picker on a fresh form`() = runTest {
|
||||
settingsStore.setLastShelfId(shelfId)
|
||||
|
||||
val vm = AddBookViewModel(
|
||||
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
|
||||
bookRepository = bookRepository,
|
||||
locationRepository = locationRepository,
|
||||
settingsStore = settingsStore,
|
||||
)
|
||||
|
||||
val preSelected = vm.uiState.first { it.selectedShelfId != null }
|
||||
assertEquals(shelfId, preSelected.selectedShelfId)
|
||||
}
|
||||
|
||||
// --- review fixes (2026-09-16) ---
|
||||
|
||||
private val coverUrl = "https://covers.example.org/b/id/42-M.jpg"
|
||||
|
||||
private fun viewModelWithDraft(draft: BookDraft, dispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.Unconfined) =
|
||||
AddBookViewModel(
|
||||
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload"), dispatcher),
|
||||
bookRepository = bookRepository,
|
||||
locationRepository = locationRepository,
|
||||
settingsStore = settingsStore,
|
||||
initialDraft = draft,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a cover carried in from a search hit is preloaded and saved as the book's cover file`() = runTest {
|
||||
coverHost.serve(coverUrl, byteArrayOf(7, 7))
|
||||
val vm = viewModelWithDraft(BookDraft(title = "Dune", coverSourceUrl = coverUrl))
|
||||
val preloaded = (vm.uiState.first { it.cover is CoverPreloadState.Ready }.cover as CoverPreloadState.Ready).file
|
||||
|
||||
val bookId = checkNotNull(vm.performSave(clearAfterSave = false))
|
||||
|
||||
val saved = checkNotNull(bookRepository.getById(bookId))
|
||||
assertTrue(java.io.File(checkNotNull(saved.localCoverPath)).readBytes().contentEquals(byteArrayOf(7, 7)))
|
||||
assertTrue("the preload is deleted once createBook has copied it", !preloaded.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save waits for a cover download that is still running`() = runTest {
|
||||
coverHost.serve(coverUrl)
|
||||
coverHost.hold()
|
||||
val vm = viewModelWithDraft(BookDraft(title = "Dune", coverSourceUrl = coverUrl), Dispatchers.IO)
|
||||
Thread { Thread.sleep(200); coverHost.release() }.start()
|
||||
|
||||
val bookId = checkNotNull(vm.performSave(clearAfterSave = false))
|
||||
|
||||
assertTrue(checkNotNull(bookRepository.getById(bookId)).localCoverPath != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cover that already failed when Save was tapped does not block the save`() = runTest {
|
||||
coverHost.reset(coverUrl)
|
||||
val vm = viewModelWithDraft(BookDraft(title = "Dune", coverSourceUrl = coverUrl))
|
||||
vm.uiState.first { it.cover is CoverPreloadState.Failed }
|
||||
|
||||
val bookId = checkNotNull(vm.performSave(clearAfterSave = false))
|
||||
|
||||
val saved = checkNotNull(bookRepository.getById(bookId))
|
||||
assertNull(saved.localCoverPath)
|
||||
assertEquals(coverUrl, saved.coverSourceUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cover that fails while Save waits stops the save and keeps the form`() = runTest {
|
||||
coverHost.fail(coverUrl, 404)
|
||||
coverHost.hold()
|
||||
val vm = viewModelWithDraft(BookDraft(title = "Dune", coverSourceUrl = coverUrl), Dispatchers.IO)
|
||||
Thread { Thread.sleep(200); coverHost.release() }.start()
|
||||
|
||||
assertNull(vm.performSave(clearAfterSave = false))
|
||||
|
||||
val state = vm.uiState.first { it.cover is CoverPreloadState.Failed }
|
||||
assertEquals("Dune", state.draft.title)
|
||||
assertTrue(!state.isSaving)
|
||||
assertTrue(bookRepository.observeAll().first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failing duplicate lookup shows no warning instead of crashing`() = runTest {
|
||||
val vm = AddBookViewModel(
|
||||
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
|
||||
bookRepository = BookRepository(ThrowingFindByIsbnBookDao(db.bookDao(), IllegalStateException("disk full")), context),
|
||||
locationRepository = locationRepository,
|
||||
settingsStore = settingsStore,
|
||||
)
|
||||
|
||||
vm.onIsbnChanged("9780441013593") // must not throw out of the launched check
|
||||
|
||||
assertEquals(DuplicateStatus.New, vm.uiState.first().duplicate)
|
||||
}
|
||||
|
||||
private class ThrowingFindByIsbnBookDao(
|
||||
private val delegate: BookDao,
|
||||
private val toThrow: Throwable,
|
||||
) : BookDao by delegate {
|
||||
override suspend fun findByIsbn13(isbn13: String): BookEntity? = throw toThrow
|
||||
}
|
||||
|
||||
private fun viewModelWithThrowingCreateBook(toThrow: Throwable): AddBookViewModel = AddBookViewModel(
|
||||
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
|
||||
bookRepository = BookRepository(ThrowingUpsertBookDao(db.bookDao(), toThrow), context),
|
||||
locationRepository = locationRepository,
|
||||
settingsStore = settingsStore,
|
||||
)
|
||||
|
||||
/** Delegates every query to the real DAO except [upsert], which [AddBookViewModel.performSave]'s createBook hits. */
|
||||
private class ThrowingUpsertBookDao(
|
||||
private val delegate: BookDao,
|
||||
private val toThrow: Throwable,
|
||||
) : BookDao by delegate {
|
||||
override suspend fun upsert(book: BookEntity) = throw toThrow
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,13 @@ class LibrarySyncPresenterTest {
|
||||
assertEquals("Sync failed — showing local library", state.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expired session is a distinct status from a plain failure, not "Sync failed"`() {
|
||||
val state = LibrarySyncPresenter.present(nowMillis = 1_000L, lastSyncMillis = null, isSyncing = false, lastOutcome = SyncOutcome.AUTH_EXPIRED)
|
||||
assertEquals(SyncStatus.AuthExpired, state.status)
|
||||
assertEquals("Signed out — sign in again to sync", state.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no server configured is offline, not an error`() {
|
||||
val state = LibrarySyncPresenter.present(nowMillis = 1_000L, lastSyncMillis = null, isSyncing = false, lastOutcome = SyncOutcome.NO_SERVER)
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
package org.modg.bookshelf.ui.library
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.modg.bookshelf.data.local.BookDao
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.local.BookshelfDatabase
|
||||
import org.modg.bookshelf.data.metadata.BookSearchRepository
|
||||
import org.modg.bookshelf.data.metadata.MetadataRepository
|
||||
import org.modg.bookshelf.data.metadata.OnlineBook
|
||||
import org.modg.bookshelf.data.prefs.SettingsStore
|
||||
import org.modg.bookshelf.data.repo.BookRepository
|
||||
import org.modg.bookshelf.data.repo.CoverDownloader
|
||||
import org.modg.bookshelf.data.repo.CoverPreloadState
|
||||
import org.modg.bookshelf.data.repo.FakeCoverHost
|
||||
import org.modg.bookshelf.data.repo.LocationRepository
|
||||
import org.modg.bookshelf.data.repo.SyncEngine
|
||||
import org.modg.bookshelf.data.repo.decodeAuthors
|
||||
import org.modg.bookshelf.data.remote.ApiProvider
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Wave 10 (online search). Covers the ViewModel-owned parts of the task's contract:
|
||||
* typing never hits the network, submit does, editing after a submit clears/cancels,
|
||||
* partial and total source failure, and the online-result save sheet's field mapping
|
||||
* and error handling. Parsing/merge logic itself is covered by
|
||||
* [org.modg.bookshelf.data.metadata.SearchResultMergerTest] and the client tests —
|
||||
* this file only exercises what's specific to owning that state in a ViewModel.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class LibraryViewModelTest {
|
||||
|
||||
private lateinit var db: BookshelfDatabase
|
||||
private lateinit var settingsStore: SettingsStore
|
||||
private lateinit var locationRepository: LocationRepository
|
||||
private lateinit var bookRepository: BookRepository
|
||||
private lateinit var context: android.content.Context
|
||||
private val coverHost = FakeCoverHost()
|
||||
|
||||
/** Every view model built by [viewModel], so [tearDown] can cancel each one. */
|
||||
private val viewModels = mutableListOf<LibraryViewModel>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
settingsStore = SettingsStore(context)
|
||||
locationRepository = LocationRepository(db.bookcaseDao(), db.shelfDao(), db.bookDao())
|
||||
bookRepository = BookRepository(db.bookDao(), context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every view model this test built has to be cancelled before [Dispatchers.resetMain].
|
||||
*
|
||||
* [LibraryViewModel] exposes eleven `stateIn(viewModelScope, WhileSubscribed(5_000), ...)`
|
||||
* flows, so its upstreams keep running for five seconds after their last collector
|
||||
* goes away. Nothing cleared the view model, so those coroutines outlived the test
|
||||
* that made them and were still touching `Dispatchers.Main` while the NEXT test's
|
||||
* setUp/tearDown reset it — which surfaces as a flaky
|
||||
* "Dispatchers.Main is used concurrently with setting it" in whichever test
|
||||
* happens to be running when the window expires, not in the one that leaked.
|
||||
* Found 2026-09-20 when unrelated new tests shifted the suite's timing.
|
||||
*/
|
||||
@After
|
||||
fun tearDown() {
|
||||
viewModels.forEach { it.viewModelScope.cancel() }
|
||||
viewModels.clear()
|
||||
// Guarded because a failure in setUp leaves this uninitialized, and an
|
||||
// UninitializedPropertyAccessException here would mask the real failure.
|
||||
if (::db.isInitialized) db.close()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(
|
||||
searchHttpClient: OkHttpClient = OkHttpClient(),
|
||||
coverDownloader: CoverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
|
||||
): LibraryViewModel = LibraryViewModel(
|
||||
coverDownloader = coverDownloader,
|
||||
computeDispatcher = Dispatchers.Main,
|
||||
bookRepository = bookRepository,
|
||||
locationRepository = locationRepository,
|
||||
syncEngine = SyncEngine(ApiProvider { null }, db.bookDao(), db.bookcaseDao(), db.shelfDao(), settingsStore),
|
||||
settingsStore = settingsStore,
|
||||
bookSearchRepository = BookSearchRepository(searchHttpClient, Json { ignoreUnknownKeys = true }),
|
||||
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
initialShelfId = null,
|
||||
).also { viewModels += it }
|
||||
|
||||
// --- sync (2026-09-17 auth-expiry incident) ---
|
||||
|
||||
@Test
|
||||
fun `an expired session maps to the AuthExpired sync bar state, not a generic failure`() = runTest {
|
||||
settingsStore.setServerUrl("https://example.test")
|
||||
val api = org.modg.bookshelf.data.repo.FakePocketBaseApi()
|
||||
api.authRefreshHttpError = org.modg.bookshelf.data.repo.httpError(401)
|
||||
val vm = LibraryViewModel(
|
||||
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
|
||||
computeDispatcher = Dispatchers.Main,
|
||||
bookRepository = bookRepository,
|
||||
locationRepository = locationRepository,
|
||||
syncEngine = SyncEngine(ApiProvider { api }, db.bookDao(), db.bookcaseDao(), db.shelfDao(), settingsStore),
|
||||
settingsStore = settingsStore,
|
||||
bookSearchRepository = BookSearchRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
initialShelfId = null,
|
||||
)
|
||||
|
||||
val bar = vm.uiState.first { it.syncBar.status == org.modg.bookshelf.ui.components.SyncStatus.AuthExpired }.syncBar
|
||||
|
||||
assertEquals(org.modg.bookshelf.ui.components.SyncStatus.AuthExpired, bar.status)
|
||||
assertEquals("Signed out — sign in again to sync", bar.label)
|
||||
}
|
||||
|
||||
// --- typing vs submit ---
|
||||
|
||||
@Test
|
||||
fun `typing does NOT trigger an online search`() = runTest {
|
||||
val vm = viewModel()
|
||||
vm.onQueryChange("d")
|
||||
vm.onQueryChange("du")
|
||||
vm.onQueryChange("dune")
|
||||
|
||||
assertEquals(OnlineSearchState.NotSearched, vm.onlineSearch.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `submit with fewer than 2 characters does nothing`() = runTest {
|
||||
val vm = viewModel()
|
||||
vm.onQueryChange("d")
|
||||
vm.submitOnlineSearch()
|
||||
|
||||
assertEquals(OnlineSearchState.NotSearched, vm.onlineSearch.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `submit runs the online search and populates online results`() = runTest {
|
||||
val vm = viewModel(fakeSearchClient(olBody = olHit("Dune", "Frank Herbert"), gbBody = gbEmpty()))
|
||||
vm.onQueryChange("dune")
|
||||
vm.submitOnlineSearch()
|
||||
|
||||
val done = vm.onlineSearch.first { it is OnlineSearchState.Done } as OnlineSearchState.Done
|
||||
assertEquals(1, done.online.size)
|
||||
assertEquals("Dune", done.online[0].title)
|
||||
assertNull(done.openLibraryFailure)
|
||||
assertNull(done.googleBooksFailure)
|
||||
}
|
||||
|
||||
// --- editing after a submit ---
|
||||
|
||||
@Test
|
||||
fun `editing the query after a submit clears the online section and cancels the in-flight search`() = runTest {
|
||||
val gate = CountDownLatch(1)
|
||||
val vm = viewModel(gatedFakeSearchClient(gate, olBody = olHit("Dune", "Frank Herbert"), gbBody = gbEmpty()))
|
||||
vm.onQueryChange("dune")
|
||||
vm.submitOnlineSearch()
|
||||
|
||||
// .first(), not .value -- see the KDoc convention this codebase already uses
|
||||
// (e.g. AddBookViewModel.uiState): a bare .value read with no live collector
|
||||
// only ever sees the seed passed to stateIn. The fake call is blocked on the
|
||||
// gate, so the phase genuinely stays Searching until collection observes it.
|
||||
val searching = vm.onlineSearch.first { it !is OnlineSearchState.NotSearched }
|
||||
assertTrue("expected Searching, got $searching", searching is OnlineSearchState.Searching)
|
||||
|
||||
vm.onQueryChange("something else")
|
||||
|
||||
assertEquals(OnlineSearchState.NotSearched, vm.onlineSearch.first())
|
||||
gate.countDown() // release the blocked background call
|
||||
// Wait for the cancelled job to actually finish unwinding on its own thread before
|
||||
// this test returns -- otherwise it can still be dispatching onto Dispatchers.Main
|
||||
// after tearDown() (or even the next test's setUp()) touches it, which fails with
|
||||
// "Dispatchers.Main is used concurrently with setting it" in whatever test runs next.
|
||||
vm.onlineSearchJob?.join()
|
||||
}
|
||||
|
||||
// --- partial and total source failure ---
|
||||
|
||||
@Test
|
||||
fun `one source failing still shows the other source's results, with a failure line naming it`() = runTest {
|
||||
val vm = viewModel(fakeSearchClient(olCode = 404, olBody = "", gbBody = gbHit("Dune", "Frank Herbert")))
|
||||
vm.onQueryChange("dune")
|
||||
vm.submitOnlineSearch()
|
||||
|
||||
val done = vm.onlineSearch.first { it is OnlineSearchState.Done } as OnlineSearchState.Done
|
||||
assertEquals(1, done.online.size)
|
||||
assertEquals("Dune", done.online[0].title)
|
||||
checkNotNull(done.openLibraryFailure)
|
||||
assertNull(done.googleBooksFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both sources failing is reported as failures, never as an authoritative no-results`() = runTest {
|
||||
val vm = viewModel(fakeSearchClient(olCode = 404, olBody = "", gbCode = 404, gbBody = ""))
|
||||
vm.onQueryChange("dune")
|
||||
vm.submitOnlineSearch()
|
||||
|
||||
val done = vm.onlineSearch.first { it is OnlineSearchState.Done } as OnlineSearchState.Done
|
||||
assertTrue(done.online.isEmpty())
|
||||
checkNotNull(done.openLibraryFailure)
|
||||
checkNotNull(done.googleBooksFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both sources answering with nothing is an authoritative empty result, no failures`() = runTest {
|
||||
val vm = viewModel(fakeSearchClient(olBody = olEmpty(), gbBody = gbEmpty()))
|
||||
vm.onQueryChange("zzzznonexistentzzzz")
|
||||
vm.submitOnlineSearch()
|
||||
|
||||
val done = vm.onlineSearch.first { it is OnlineSearchState.Done } as OnlineSearchState.Done
|
||||
assertTrue(done.online.isEmpty())
|
||||
assertNull(done.openLibraryFailure)
|
||||
assertNull(done.googleBooksFailure)
|
||||
}
|
||||
|
||||
// --- save sheet ---
|
||||
|
||||
@Test
|
||||
fun `save maps every online book field to createBook`() = runTest {
|
||||
val bookcaseId = locationRepository.createBookcase(name = "Living Room")
|
||||
val shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf")
|
||||
val vm = viewModel()
|
||||
val onlineBook = OnlineBook(
|
||||
title = "Dune",
|
||||
subtitle = "A novel",
|
||||
authors = listOf("Frank Herbert"),
|
||||
year = 1965,
|
||||
publisher = "Ace",
|
||||
pageCount = 412,
|
||||
coverUrl = "https://example.com/cover.jpg",
|
||||
isbn13 = "9780441013593",
|
||||
)
|
||||
coverHost.serve("https://example.com/cover.jpg")
|
||||
vm.onOnlineResultTapped(onlineBook)
|
||||
vm.onSaveSheetShelfSelected(shelfId)
|
||||
|
||||
val bookId = vm.performSaveOnlineResult()
|
||||
checkNotNull(bookId)
|
||||
|
||||
val saved = bookRepository.getById(bookId)
|
||||
checkNotNull(saved)
|
||||
assertEquals("Dune", saved.title)
|
||||
assertEquals("A novel", saved.subtitle)
|
||||
assertEquals(listOf("Frank Herbert"), decodeAuthors(saved.authorsJson))
|
||||
assertEquals("Ace", saved.publisher)
|
||||
assertEquals("1965", saved.publishedDate)
|
||||
assertEquals(412, saved.pageCount)
|
||||
assertEquals("9780441013593", saved.isbn13)
|
||||
assertEquals("https://example.com/cover.jpg", saved.coverSourceUrl)
|
||||
assertEquals(shelfId, saved.shelfId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `after a save the sheet closes and the book appears in inLibrary via the Room flow`() = runTest {
|
||||
val vm = viewModel(fakeSearchClient(olBody = olHit("Dune", "Frank Herbert", isbn = "9780441013593"), gbBody = gbEmpty()))
|
||||
vm.onQueryChange("dune")
|
||||
vm.submitOnlineSearch()
|
||||
val beforeSave = vm.onlineSearch.first { it is OnlineSearchState.Done } as OnlineSearchState.Done
|
||||
assertEquals(1, beforeSave.online.size)
|
||||
assertTrue(beforeSave.inLibrary.isEmpty())
|
||||
|
||||
vm.onOnlineResultTapped(beforeSave.online[0])
|
||||
vm.performSaveOnlineResult()
|
||||
|
||||
assertEquals(OnlineSaveSheetState.Hidden, vm.saveSheet.first())
|
||||
val afterSave = vm.onlineSearch.first { (it as? OnlineSearchState.Done)?.online?.isEmpty() == true } as OnlineSearchState.Done
|
||||
assertEquals(1, afterSave.inLibrary.size)
|
||||
assertEquals("Dune", afterSave.inLibrary[0].title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a throwing save keeps the sheet open with an error and does not throw`() = runTest {
|
||||
val throwingBookRepository = BookRepository(ThrowingUpsertBookDao(db.bookDao(), IllegalStateException("disk full")), context)
|
||||
val vm = LibraryViewModel(
|
||||
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
|
||||
computeDispatcher = Dispatchers.Main,
|
||||
bookRepository = throwingBookRepository,
|
||||
locationRepository = locationRepository,
|
||||
syncEngine = SyncEngine(ApiProvider { null }, db.bookDao(), db.bookcaseDao(), db.shelfDao(), settingsStore),
|
||||
settingsStore = settingsStore,
|
||||
bookSearchRepository = BookSearchRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
initialShelfId = null,
|
||||
)
|
||||
vm.onOnlineResultTapped(OnlineBook(title = "Dune"))
|
||||
|
||||
val result = vm.performSaveOnlineResult() // must not throw
|
||||
|
||||
assertNull(result)
|
||||
val shown = vm.saveSheet.first() as? OnlineSaveSheetState.Shown
|
||||
checkNotNull(shown)
|
||||
assertEquals("IllegalStateException", shown.saveError)
|
||||
assertTrue(!shown.isSaving)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a CancellationException during save propagates rather than being swallowed`() = runTest {
|
||||
val vm = LibraryViewModel(
|
||||
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
|
||||
computeDispatcher = Dispatchers.Main,
|
||||
bookRepository = BookRepository(ThrowingUpsertBookDao(db.bookDao(), CancellationException("scope cancelled")), context),
|
||||
locationRepository = locationRepository,
|
||||
syncEngine = SyncEngine(ApiProvider { null }, db.bookDao(), db.bookcaseDao(), db.shelfDao(), settingsStore),
|
||||
settingsStore = settingsStore,
|
||||
bookSearchRepository = BookSearchRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
initialShelfId = null,
|
||||
)
|
||||
vm.onOnlineResultTapped(OnlineBook(title = "Dune"))
|
||||
|
||||
try {
|
||||
vm.performSaveOnlineResult()
|
||||
org.junit.Assert.fail("expected CancellationException to propagate")
|
||||
} catch (e: CancellationException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second save while one is in flight does not create a second book`() = runTest {
|
||||
val vm = viewModel()
|
||||
vm.onOnlineResultTapped(OnlineBook(title = "Dune"))
|
||||
|
||||
val first = kotlinx.coroutines.CoroutineScope(Dispatchers.Unconfined).launch { vm.performSaveOnlineResult() }
|
||||
val second = kotlinx.coroutines.CoroutineScope(Dispatchers.Unconfined).launch { vm.performSaveOnlineResult() }
|
||||
first.join()
|
||||
second.join()
|
||||
|
||||
assertEquals(1, bookRepository.observeAll().first().size)
|
||||
}
|
||||
|
||||
// --- review fixes (2026-09-16) ---
|
||||
|
||||
@Test
|
||||
fun `an owned edition reached only through an online ISBN is shown in inLibrary, not dropped`() = runTest {
|
||||
// Its own text doesn't match the query at all; only the OL work's ISBN list ties it in.
|
||||
bookRepository.createBook(title = "Warrior of the Hittites", isbn13 = "9781883937386")
|
||||
val vm = viewModel(fakeSearchClient(olBody = olHit("Hittite Warrior", "Joanne Williamson", isbn = "1883937388"), gbBody = gbEmpty()))
|
||||
vm.uiState.first { it.books.isNotEmpty() } // the owned book has loaded from Room before the search merges against it
|
||||
vm.onQueryChange("hittite williamson")
|
||||
vm.submitOnlineSearch()
|
||||
|
||||
val done = vm.onlineSearch.first { it is OnlineSearchState.Done } as OnlineSearchState.Done
|
||||
|
||||
assertEquals(listOf("Warrior of the Hittites"), done.inLibrary.map { it.title })
|
||||
assertTrue(done.online.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shelf filter narrows inLibrary and counts what it hides instead of dropping it`() = runTest {
|
||||
val bookcaseId = locationRepository.createBookcase(name = "Living Room")
|
||||
val top = locationRepository.createShelf(bookcaseId, label = "Top")
|
||||
val bottom = locationRepository.createShelf(bookcaseId, label = "Bottom")
|
||||
bookRepository.createBook(title = "Dune", authors = listOf("Frank Herbert"), shelfId = top)
|
||||
bookRepository.createBook(title = "Dune Messiah", authors = listOf("Frank Herbert"), shelfId = bottom)
|
||||
val vm = viewModel(fakeSearchClient(olBody = olEmpty(), gbBody = gbEmpty()))
|
||||
vm.onFilterChange(LibraryFilter.Shelf(top))
|
||||
vm.onQueryChange("dune herbert")
|
||||
vm.submitOnlineSearch()
|
||||
|
||||
val done = vm.onlineSearch.first { it is OnlineSearchState.Done && it.inLibraryOutsideFilter > 0 } as OnlineSearchState.Done
|
||||
|
||||
assertEquals(listOf("Dune"), done.inLibrary.map { it.title })
|
||||
assertEquals(1, done.inLibraryOutsideFilter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Edit details closes the sheet, so returning to the library can't save the same book twice`() = runTest {
|
||||
val cover = "https://covers.example.org/dune.jpg"
|
||||
coverHost.serve(cover)
|
||||
val vm = viewModel()
|
||||
val book = OnlineBook(title = "Dune", coverUrl = cover)
|
||||
vm.onOnlineResultTapped(book)
|
||||
val preloaded = (vm.cover.first { it is CoverPreloadState.Ready } as CoverPreloadState.Ready).file
|
||||
|
||||
val taken = vm.takeForEditing()
|
||||
|
||||
assertEquals(book, taken?.first)
|
||||
assertEquals(OnlineSaveSheetState.Hidden, vm.saveSheet.value)
|
||||
assertTrue("the sheet's preloaded cover must not be left behind", !preloaded.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save waits for a cover still downloading and stores the cover file with the book`() = runTest {
|
||||
val cover = "https://covers.example.org/dune.jpg"
|
||||
coverHost.serve(cover, byteArrayOf(4, 2))
|
||||
coverHost.hold()
|
||||
val vm = viewModel(coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload"), Dispatchers.IO))
|
||||
vm.onOnlineResultTapped(OnlineBook(title = "Dune", coverUrl = cover))
|
||||
assertTrue(vm.cover.value is CoverPreloadState.Loading)
|
||||
Thread { Thread.sleep(200); coverHost.release() }.start()
|
||||
|
||||
val bookId = checkNotNull(vm.performSaveOnlineResult())
|
||||
|
||||
val saved = checkNotNull(bookRepository.getById(bookId))
|
||||
val localCover = java.io.File(checkNotNull(saved.localCoverPath))
|
||||
assertTrue(localCover.readBytes().contentEquals(byteArrayOf(4, 2)))
|
||||
assertEquals(CoverPreloadState.None, vm.cover.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cover that fails while save waits stops the save, and saving again goes ahead without it`() = runTest {
|
||||
val cover = "https://covers.example.org/dune.jpg"
|
||||
coverHost.fail(cover, 503)
|
||||
coverHost.hold()
|
||||
val vm = viewModel(coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload"), Dispatchers.IO))
|
||||
vm.onOnlineResultTapped(OnlineBook(title = "Dune", coverUrl = cover))
|
||||
Thread { Thread.sleep(200); coverHost.release() }.start()
|
||||
|
||||
assertNull("the user hasn't seen the failure yet — don't save past it", vm.performSaveOnlineResult())
|
||||
assertTrue(vm.cover.value is CoverPreloadState.Failed)
|
||||
assertTrue(bookRepository.observeAll().first().isEmpty())
|
||||
|
||||
val bookId = checkNotNull(vm.performSaveOnlineResult()) // tapped "Save without cover"
|
||||
val saved = checkNotNull(bookRepository.getById(bookId))
|
||||
assertNull(saved.localCoverPath)
|
||||
assertEquals(cover, saved.coverSourceUrl)
|
||||
}
|
||||
|
||||
private class ThrowingUpsertBookDao(
|
||||
private val delegate: BookDao,
|
||||
private val toThrow: Throwable,
|
||||
) : BookDao by delegate {
|
||||
override suspend fun upsert(book: BookEntity) = throw toThrow
|
||||
}
|
||||
|
||||
// --- fake HTTP for BookSearchRepository ---
|
||||
|
||||
private fun fakeSearchClient(
|
||||
olCode: Int = 200,
|
||||
olBody: String,
|
||||
gbCode: Int = 200,
|
||||
gbBody: String,
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor { chain -> respond(chain.request(), olCode, olBody, gbCode, gbBody) }
|
||||
.build()
|
||||
|
||||
private fun gatedFakeSearchClient(
|
||||
gate: CountDownLatch,
|
||||
olBody: String,
|
||||
gbBody: String,
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor { chain -> gate.await(); respond(chain.request(), 200, olBody, 200, gbBody) }
|
||||
.build()
|
||||
|
||||
private fun respond(request: Request, olCode: Int, olBody: String, gbCode: Int, gbBody: String): Response = when {
|
||||
request.url.host.contains("openlibrary") -> jsonResponse(request, olCode, olBody)
|
||||
request.url.host.contains("googleapis") -> jsonResponse(request, gbCode, gbBody)
|
||||
else -> error("unexpected host: ${request.url.host}")
|
||||
}
|
||||
|
||||
private fun jsonResponse(request: Request, code: Int, body: String): Response = Response.Builder()
|
||||
.request(request)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(code)
|
||||
.message("")
|
||||
.body(body.toResponseBody("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
private fun olHit(title: String, author: String, isbn: String? = null) = """
|
||||
{"docs": [{"title": "$title", "author_name": ["$author"]${isbn?.let { ", \"isbn\": [\"$it\"]" }.orEmpty()}}]}
|
||||
""".trimIndent()
|
||||
|
||||
private fun olEmpty() = """{"docs": []}"""
|
||||
|
||||
private fun gbHit(title: String, author: String) = """
|
||||
{"items": [{"volumeInfo": {"title": "$title", "authors": ["$author"]}}]}
|
||||
""".trimIndent()
|
||||
|
||||
private fun gbEmpty() = """{"items": []}"""
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.modg.bookshelf.ui.library
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.local.SyncState
|
||||
import org.modg.bookshelf.data.repo.encodeAuthors
|
||||
|
||||
class LocalBookMatcherTest {
|
||||
|
||||
private fun book(id: String, title: String, authors: List<String>, subtitle: String? = null, isbn13: String? = null) = BookEntity(
|
||||
id = id,
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
authorsJson = encodeAuthors(authors),
|
||||
isbn13 = isbn13,
|
||||
createdAt = 1,
|
||||
updatedAt = 1,
|
||||
syncState = SyncState.SYNCED,
|
||||
)
|
||||
|
||||
private val hobbit = book("b1", "The Hobbit", listOf("J.R.R. Tolkien"), isbn13 = "9780547928227")
|
||||
private val bronte = book("b2", "Jane Eyre", listOf("Charlotte Brontë"))
|
||||
private val dune = book("b3", "Dune", listOf("Frank Herbert"))
|
||||
|
||||
@Test
|
||||
fun `title plus author across two fields matches`() {
|
||||
val result = LocalBookMatcher.match(listOf(hobbit, dune), "hobbit tolkien")
|
||||
assertEquals(listOf(hobbit), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `squashed initials match a token with no spaces`() {
|
||||
val result = LocalBookMatcher.match(listOf(hobbit, dune), "jrr tolkien")
|
||||
assertEquals(listOf(hobbit), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `token order does not matter`() {
|
||||
val result = LocalBookMatcher.match(listOf(hobbit, dune), "tolkien hobbit")
|
||||
assertEquals(listOf(hobbit), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `diacritics are stripped for matching`() {
|
||||
val result = LocalBookMatcher.match(listOf(bronte, dune), "bronte")
|
||||
assertEquals(listOf(bronte), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a query token matching nothing excludes the book`() {
|
||||
val result = LocalBookMatcher.match(listOf(hobbit), "hobbit nonexistentword")
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a token can match the isbn`() {
|
||||
val result = LocalBookMatcher.match(listOf(hobbit, dune), "9780547928227")
|
||||
assertEquals(listOf(hobbit), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank query matches nothing`() {
|
||||
assertTrue(LocalBookMatcher.match(listOf(hobbit, dune), " ").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a subtitle token matches`() {
|
||||
val withSubtitle = book("b4", "The Way of Kings", listOf("Brandon Sanderson"), subtitle = "The Stormlight Archive")
|
||||
val result = LocalBookMatcher.match(listOf(withSubtitle, dune), "stormlight")
|
||||
assertEquals(listOf(withSubtitle), result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package org.modg.bookshelf.ui.nav
|
||||
|
||||
import android.net.Uri
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* [Routes.addBook] must percent-encode its args well enough to survive the
|
||||
* query-string round trip Navigation Compose actually performs on a real
|
||||
* device: it parses the built route as a [Uri] and extracts each `{arg}`
|
||||
* placeholder via [Uri.getQueryParameter], which does standard percent-
|
||||
* decoding (not `+`-as-space form-decoding) — so this test decodes the same
|
||||
* way, via a real [Uri] under Robolectric, rather than re-implementing
|
||||
* decoding by hand and only proving the two sides of one hand-rolled pair
|
||||
* agree with each other.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class RoutesTest {
|
||||
|
||||
private fun decode(route: String, arg: String): String? =
|
||||
Uri.parse("scheme://host/$route").getQueryParameter(arg)
|
||||
|
||||
@Test
|
||||
fun `round-trips a title containing query-breaking characters`() {
|
||||
val title = "Hänsel & Gretel / Part 1?"
|
||||
val route = Routes.addBook(title = title)
|
||||
|
||||
assertEquals(title, decode(route, Routes.ADD_BOOK_TITLE_ARG))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `round-trips a title containing a hash`() {
|
||||
val title = "Chapter #3: The Return"
|
||||
val route = Routes.addBook(title = title)
|
||||
|
||||
assertEquals(title, decode(route, Routes.ADD_BOOK_TITLE_ARG))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `round-trips spaces and non-ASCII`() {
|
||||
val title = "Le Petit Prince — a été traduit"
|
||||
val route = Routes.addBook(title = title)
|
||||
|
||||
assertEquals(title, decode(route, Routes.ADD_BOOK_TITLE_ARG))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `round-trips both title and isbn together`() {
|
||||
val title = "Q&A: Life, the Universe / Everything?"
|
||||
val isbn = "978-0-13-468599-1"
|
||||
val route = Routes.addBook(title = title, isbn = isbn)
|
||||
|
||||
assertEquals(title, decode(route, Routes.ADD_BOOK_TITLE_ARG))
|
||||
assertEquals(isbn, decode(route, Routes.ADD_BOOK_ISBN_ARG))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `omits absent args entirely rather than encoding a literal null`() {
|
||||
assertEquals("add", Routes.addBook())
|
||||
assertEquals("add?title=${Uri.encode("Dune")}", Routes.addBook(title = "Dune"))
|
||||
|
||||
val isbnOnlyRoute = Routes.addBook(isbn = "9780441013593")
|
||||
assertNull(decode(isbnOnlyRoute, Routes.ADD_BOOK_TITLE_ARG))
|
||||
assertEquals("9780441013593", decode(isbnOnlyRoute, Routes.ADD_BOOK_ISBN_ARG))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bare add route matches the ADD_BOOK pattern's base path`() {
|
||||
assertEquals("add", Routes.addBook().substringBefore("?"))
|
||||
assertEquals("add", Routes.ADD_BOOK.substringBefore("?"))
|
||||
}
|
||||
|
||||
// --- wave 10: the full draft (online search's "Edit details") round-trips too ---
|
||||
|
||||
@Test
|
||||
fun `round-trips every draft field an online search result can carry`() {
|
||||
val route = Routes.addBook(
|
||||
title = "The Hobbit",
|
||||
subtitle = "or There and Back Again",
|
||||
authors = "J.R.R. Tolkien, Someone Else",
|
||||
publisher = "Houghton Mifflin",
|
||||
publishedDate = "1937",
|
||||
pages = "310",
|
||||
isbn = "9780547928227",
|
||||
description = "A hobbit goes on an adventure & returns home.",
|
||||
coverSourceUrl = "https://covers.openlibrary.org/b/id/1-M.jpg?x=1&y=2",
|
||||
)
|
||||
|
||||
assertEquals("The Hobbit", decode(route, Routes.ADD_BOOK_TITLE_ARG))
|
||||
assertEquals("or There and Back Again", decode(route, Routes.ADD_BOOK_SUBTITLE_ARG))
|
||||
assertEquals("J.R.R. Tolkien, Someone Else", decode(route, Routes.ADD_BOOK_AUTHORS_ARG))
|
||||
assertEquals("Houghton Mifflin", decode(route, Routes.ADD_BOOK_PUBLISHER_ARG))
|
||||
assertEquals("1937", decode(route, Routes.ADD_BOOK_PUBLISHED_DATE_ARG))
|
||||
assertEquals("310", decode(route, Routes.ADD_BOOK_PAGES_ARG))
|
||||
assertEquals("9780547928227", decode(route, Routes.ADD_BOOK_ISBN_ARG))
|
||||
assertEquals("A hobbit goes on an adventure & returns home.", decode(route, Routes.ADD_BOOK_DESCRIPTION_ARG))
|
||||
assertEquals("https://covers.openlibrary.org/b/id/1-M.jpg?x=1&y=2", decode(route, Routes.ADD_BOOK_COVER_URL_ARG))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `omitting the new draft fields leaves them out entirely, not as literal nulls`() {
|
||||
val route = Routes.addBook(title = "Dune")
|
||||
assertNull(decode(route, Routes.ADD_BOOK_SUBTITLE_ARG))
|
||||
assertNull(decode(route, Routes.ADD_BOOK_DESCRIPTION_ARG))
|
||||
assertNull(decode(route, Routes.ADD_BOOK_COVER_URL_ARG))
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,34 @@ package org.modg.bookshelf.ui.scan
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.modg.bookshelf.data.local.BookDao
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.local.BookshelfDatabase
|
||||
import org.modg.bookshelf.data.metadata.BookMetadata
|
||||
import org.modg.bookshelf.data.metadata.MetadataRepository
|
||||
import org.modg.bookshelf.data.prefs.SettingsStore
|
||||
import org.modg.bookshelf.data.repo.BookRepository
|
||||
import org.modg.bookshelf.data.repo.CoverPreloadState
|
||||
import org.modg.bookshelf.data.repo.FakeCoverHost
|
||||
import kotlinx.coroutines.async
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.modg.bookshelf.data.repo.LocationRepository
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
@@ -26,6 +40,7 @@ import org.robolectric.annotation.Config
|
||||
* so it survives to the next scanning session — except "Not shelved" (null),
|
||||
* which must never overwrite what's already remembered.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ScanViewModelTest {
|
||||
@@ -35,10 +50,15 @@ class ScanViewModelTest {
|
||||
private lateinit var viewModel: ScanViewModel
|
||||
private lateinit var locationRepository: LocationRepository
|
||||
private lateinit var shelfId: String
|
||||
private lateinit var context: android.content.Context
|
||||
private val coverHost = FakeCoverHost()
|
||||
|
||||
@Before
|
||||
fun setUp() = runTest {
|
||||
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||
// The cover preload launches on viewModelScope (Dispatchers.Main); unconfined so it
|
||||
// runs without idling Robolectric's main looper.
|
||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
@@ -48,6 +68,7 @@ class ScanViewModelTest {
|
||||
shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf")
|
||||
|
||||
viewModel = ScanViewModel(
|
||||
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
|
||||
bookRepository = BookRepository(db.bookDao(), context),
|
||||
locationRepository = locationRepository,
|
||||
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
@@ -58,6 +79,7 @@ class ScanViewModelTest {
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -97,4 +119,165 @@ class ScanViewModelTest {
|
||||
|
||||
assertEquals(shelfId, settingsStore.lastShelfId.first())
|
||||
}
|
||||
|
||||
// --- runLookup: §2 of the 2026-09-12 crash fix -- nothing below this point, including
|
||||
// Room itself, is proven not to throw on a real device; runLookup must survive it. ---
|
||||
|
||||
@Test
|
||||
fun `runLookup surfaces an unexpected repository failure as LookupFailed instead of crashing`() = runTest {
|
||||
val vm = viewModelWithThrowingBookDao(IllegalStateException("disk full"))
|
||||
|
||||
vm.runLookup("9780765326355") // must not throw
|
||||
|
||||
val failed = vm.sheetState.value as? ScanSheetState.LookupFailed
|
||||
checkNotNull(failed) { "expected LookupFailed, got ${vm.sheetState.value}" }
|
||||
assertTrue(failed.reason.contains("unexpected"))
|
||||
assertTrue(failed.reason.contains("IllegalStateException"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runLookup rethrows CancellationException rather than swallowing it into LookupFailed`() = runTest {
|
||||
val vm = viewModelWithThrowingBookDao(CancellationException("scope cancelled"))
|
||||
|
||||
try {
|
||||
vm.runLookup("9780765326355")
|
||||
fail("expected CancellationException to propagate")
|
||||
} catch (e: CancellationException) {
|
||||
// expected -- a cancelled lookup is not a failed one.
|
||||
}
|
||||
}
|
||||
|
||||
// --- review fixes (2026-09-16): cover preload + a guarded save ---
|
||||
|
||||
@Test
|
||||
fun `a successful lookup preloads its cover, and save stores it as the book's cover file`() = runTest {
|
||||
val olFixture = fixture("openlibrary_edition_gondolin.json")
|
||||
val coverUrl = "https://covers.openlibrary.org/b/id/12451486-L.jpg" // built from the fixture's cover id
|
||||
coverHost.serve(coverUrl, byteArrayOf(5, 5, 5))
|
||||
val vm = ScanViewModel(
|
||||
bookRepository = BookRepository(db.bookDao(), context),
|
||||
locationRepository = locationRepository,
|
||||
metadataRepository = MetadataRepository(fakeMetadataClient(olFixture), Json { ignoreUnknownKeys = true }),
|
||||
settingsStore = settingsStore,
|
||||
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
|
||||
)
|
||||
|
||||
vm.runLookup("9781328613042")
|
||||
val found = vm.sheetState.value as ScanSheetState.Found
|
||||
val preloaded = (vm.cover.first { it is CoverPreloadState.Ready } as CoverPreloadState.Ready).file
|
||||
assertTrue(vm.performSave(found.isbn13, found.metadata))
|
||||
|
||||
val saved = db.bookDao().findByIsbn13("9781328613042")
|
||||
checkNotNull(saved)
|
||||
assertTrue(java.io.File(checkNotNull(saved.localCoverPath)).readBytes().contentEquals(byteArrayOf(5, 5, 5)))
|
||||
assertTrue("the preload is discarded once saved", !preloaded.exists())
|
||||
assertEquals(ScanSheetState.Hidden, vm.sheetState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failing save is reported on the sheet instead of crashing`() = runTest {
|
||||
val vm = viewModelWithThrowingUpsert(IllegalStateException("disk full"))
|
||||
|
||||
val saved = vm.performSave("9780765326355", BookMetadata(title = "The Way of Kings")) // must not throw
|
||||
|
||||
assertTrue(!saved)
|
||||
assertEquals(ScanSaveStatus(isSaving = false, error = "IllegalStateException"), vm.saveStatus.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a CancellationException during save propagates`() = runTest {
|
||||
val vm = viewModelWithThrowingUpsert(CancellationException("scope cancelled"))
|
||||
try {
|
||||
vm.performSave("9780765326355", BookMetadata(title = "The Way of Kings"))
|
||||
fail("expected CancellationException to propagate")
|
||||
} catch (e: CancellationException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second tap while a save is in flight does not create a second book`() = runTest {
|
||||
// Hold the first save inside its database write, so the second tap deterministically
|
||||
// lands while it is in flight (not after it finished and closed the sheet).
|
||||
val gate = kotlinx.coroutines.CompletableDeferred<Unit>()
|
||||
val vm = ScanViewModel(
|
||||
bookRepository = BookRepository(GatedUpsertBookDao(db.bookDao(), gate), context),
|
||||
locationRepository = locationRepository,
|
||||
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
settingsStore = settingsStore,
|
||||
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
|
||||
)
|
||||
val first = kotlinx.coroutines.CoroutineScope(Dispatchers.Unconfined).async {
|
||||
vm.performSave("9780765326355", BookMetadata(title = "The Way of Kings"))
|
||||
}
|
||||
assertTrue(vm.saveStatus.value.isSaving)
|
||||
|
||||
val secondSaved = vm.performSave("9780765326355", BookMetadata(title = "The Way of Kings"))
|
||||
gate.complete(Unit)
|
||||
|
||||
assertTrue(!secondSaved)
|
||||
assertTrue(first.await())
|
||||
assertEquals(1, db.bookDao().observeAll().first().size)
|
||||
}
|
||||
|
||||
private class GatedUpsertBookDao(
|
||||
private val delegate: BookDao,
|
||||
private val gate: kotlinx.coroutines.CompletableDeferred<Unit>,
|
||||
) : BookDao by delegate {
|
||||
override suspend fun upsert(book: BookEntity) {
|
||||
gate.await()
|
||||
delegate.upsert(book)
|
||||
}
|
||||
}
|
||||
|
||||
private fun viewModelWithThrowingUpsert(toThrow: Throwable): ScanViewModel = ScanViewModel(
|
||||
bookRepository = BookRepository(ThrowingUpsertBookDao(db.bookDao(), toThrow), context),
|
||||
locationRepository = locationRepository,
|
||||
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
settingsStore = settingsStore,
|
||||
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
|
||||
)
|
||||
|
||||
private class ThrowingUpsertBookDao(
|
||||
private val delegate: BookDao,
|
||||
private val toThrow: Throwable,
|
||||
) : BookDao by delegate {
|
||||
override suspend fun upsert(book: BookEntity) = throw toThrow
|
||||
}
|
||||
|
||||
/** Open Library answers with [olBody]; Google Books has nothing. No sockets. */
|
||||
private fun fakeMetadataClient(olBody: String): OkHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
val (code, body) = if (request.url.host.contains("openlibrary")) 200 to olBody else 200 to """{"totalItems": 0}"""
|
||||
okhttp3.Response.Builder()
|
||||
.request(request)
|
||||
.protocol(okhttp3.Protocol.HTTP_1_1)
|
||||
.code(code)
|
||||
.message("")
|
||||
.body(body.toResponseBody(null))
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
private fun fixture(name: String): String =
|
||||
checkNotNull(javaClass.classLoader?.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
|
||||
.bufferedReader()
|
||||
.readText()
|
||||
|
||||
private fun viewModelWithThrowingBookDao(toThrow: Throwable): ScanViewModel = ScanViewModel(
|
||||
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
|
||||
bookRepository = BookRepository(ThrowingFindByIsbnBookDao(db.bookDao(), toThrow), context),
|
||||
locationRepository = locationRepository,
|
||||
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
settingsStore = settingsStore,
|
||||
)
|
||||
|
||||
/** Delegates every query to the real DAO except [findByIsbn13], which is [ScanViewModel.runLookup]'s first call. */
|
||||
private class ThrowingFindByIsbnBookDao(
|
||||
private val delegate: BookDao,
|
||||
private val toThrow: Throwable,
|
||||
) : BookDao by delegate {
|
||||
override suspend fun findByIsbn13(isbn13: String): BookEntity? = throw toThrow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.modg.bookshelf.ui.screens
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import app.cash.paparazzi.DeviceConfig
|
||||
import app.cash.paparazzi.Paparazzi
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.ui.add.AddBookCallbacks
|
||||
import org.modg.bookshelf.ui.add.AddBookContent
|
||||
import org.modg.bookshelf.ui.add.AddBookUiState
|
||||
import org.modg.bookshelf.ui.add.BookDraft
|
||||
import org.modg.bookshelf.ui.scan.DuplicateStatus
|
||||
import org.modg.bookshelf.ui.theme.BookshelfTheme
|
||||
|
||||
/**
|
||||
* SPEC gap: add-by-hand form for books with no barcode. Renders the REAL
|
||||
* [AddBookContent] fed with static [AddBookUiState] — no hand-rolled lookalike
|
||||
* (see [ScreenFixtures]'s KDoc for why a real [org.modg.bookshelf.AppContainer]
|
||||
* can't drive this inside Paparazzi, and HANDOFF.md for why wave 6's Locations
|
||||
* shortcut isn't repeated here).
|
||||
*/
|
||||
class AddBookScreenPaparazziTest {
|
||||
|
||||
@get:Rule
|
||||
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
|
||||
|
||||
@Test
|
||||
fun addBookEmptyLight() = snapshotBoth("add-book-empty") {
|
||||
AddBookContent(state = AddBookUiState(), callbacks = noopCallbacks())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addBookFilledWithDuplicateLight() = snapshotBoth("add-book-filled-duplicate") {
|
||||
AddBookContent(
|
||||
state = AddBookUiState(
|
||||
draft = BookDraft(
|
||||
title = "Dune",
|
||||
subtitle = "",
|
||||
authors = "Frank Herbert",
|
||||
publisher = "Ace",
|
||||
publishedDate = "1965",
|
||||
pages = "412",
|
||||
isbn = "9780441013593",
|
||||
description = "A desert planet, a young man's destiny, and the spice that binds it all.",
|
||||
),
|
||||
bookcases = ScreenFixtures.bookcases,
|
||||
shelves = ScreenFixtures.shelves,
|
||||
selectedShelfId = ScreenFixtures.topShelf.id,
|
||||
duplicate = DuplicateStatus.AlreadyOwned(bookId = "b1", title = "Dune"),
|
||||
),
|
||||
callbacks = noopCallbacks(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addBookIsbnErrorLight() = snapshotBoth("add-book-isbn-error") {
|
||||
AddBookContent(
|
||||
state = AddBookUiState(
|
||||
draft = BookDraft(title = "Old Pamphlet", isbn = "not-an-isbn"),
|
||||
bookcases = ScreenFixtures.bookcases,
|
||||
shelves = ScreenFixtures.shelves,
|
||||
),
|
||||
callbacks = noopCallbacks(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun noopCallbacks() = AddBookCallbacks(
|
||||
onTitleChanged = {},
|
||||
onSubtitleChanged = {},
|
||||
onAuthorsChanged = {},
|
||||
onPublisherChanged = {},
|
||||
onPublishedDateChanged = {},
|
||||
onPagesChanged = {},
|
||||
onIsbnChanged = {},
|
||||
onDescriptionChanged = {},
|
||||
onShelfSelected = {},
|
||||
onSave = {},
|
||||
onSaveAndAddAnother = {},
|
||||
onBack = {},
|
||||
)
|
||||
|
||||
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
|
||||
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
|
||||
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,16 @@ package org.modg.bookshelf.ui.screens
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.EditNote
|
||||
import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Warehouse
|
||||
@@ -16,7 +20,10 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.cash.paparazzi.DeviceConfig
|
||||
@@ -24,17 +31,25 @@ import app.cash.paparazzi.Paparazzi
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.metadata.OnlineBook
|
||||
import org.modg.bookshelf.data.repo.CoverPreloadState
|
||||
import org.modg.bookshelf.data.metadata.SearchSource
|
||||
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
||||
import org.modg.bookshelf.ui.components.EmptyState
|
||||
import org.modg.bookshelf.ui.components.PaperSurface
|
||||
import org.modg.bookshelf.ui.components.PrimaryButton
|
||||
import org.modg.bookshelf.ui.components.SecondaryButton
|
||||
import org.modg.bookshelf.ui.components.SyncStatus
|
||||
import org.modg.bookshelf.ui.components.SyncStatusBar
|
||||
import org.modg.bookshelf.ui.library.LibraryBookCard
|
||||
import org.modg.bookshelf.ui.library.LibraryFilter
|
||||
import org.modg.bookshelf.ui.library.LibraryScreen
|
||||
import org.modg.bookshelf.ui.library.LibrarySearchResultsContent
|
||||
import org.modg.bookshelf.ui.library.LibrarySortOption
|
||||
import org.modg.bookshelf.ui.library.LibraryToolbar
|
||||
import org.modg.bookshelf.ui.library.OnlineResultSaveSheet
|
||||
import org.modg.bookshelf.ui.library.OnlineSaveSheetState
|
||||
import org.modg.bookshelf.ui.library.OnlineSearchState
|
||||
import org.modg.bookshelf.ui.theme.BookshelfTheme
|
||||
|
||||
/**
|
||||
@@ -56,12 +71,162 @@ class LibraryScreenPaparazziTest {
|
||||
@Test
|
||||
fun libraryEmptyLight() = snapshotBoth("library-empty") { Empty() }
|
||||
|
||||
/** 2026-09-17 incident: the sync bar's distinct "session expired" state — never "Sync failed". */
|
||||
@Test
|
||||
fun libraryAuthExpiredLight() = snapshotBoth("library-auth-expired") { AuthExpired() }
|
||||
|
||||
@Test
|
||||
fun librarySearchBothSectionsLight() = snapshotBoth("library-search-both-sections") {
|
||||
SearchShell(
|
||||
localMatches = listOf(ScreenFixtures.books[1]), // "The Hobbit" -- already owned
|
||||
// Owned "The Hobbit" is NOT repeated online — the merge puts it in exactly one
|
||||
// section. The count line is an owned match hidden by the shelf filter.
|
||||
onlineSearch = OnlineSearchState.Done(
|
||||
query = "hobbit",
|
||||
inLibrary = listOf(ScreenFixtures.books[1]),
|
||||
online = onlineResults.drop(1),
|
||||
openLibraryFailure = null,
|
||||
googleBooksFailure = null,
|
||||
inLibraryOutsideFilter = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun librarySearchLoadingLight() = snapshotBoth("library-search-loading") {
|
||||
SearchShell(localMatches = emptyList(), onlineSearch = OnlineSearchState.Searching(query = "hobbit"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun librarySearchOneSourceFailedLight() = snapshotBoth("library-search-one-failed") {
|
||||
SearchShell(
|
||||
localMatches = emptyList(),
|
||||
onlineSearch = OnlineSearchState.Done(
|
||||
query = "hobbit",
|
||||
inLibrary = emptyList(),
|
||||
online = onlineResults,
|
||||
openLibraryFailure = "tls connection reset, 3 attempts",
|
||||
googleBooksFailure = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun libraryOnlineSaveSheetLight() = snapshotBoth("library-online-save-sheet") {
|
||||
Surface {
|
||||
OnlineResultSaveSheet(
|
||||
state = OnlineSaveSheetState.Shown(
|
||||
book = onlineResults[0],
|
||||
selectedShelfId = ScreenFixtures.topShelf.id,
|
||||
),
|
||||
bookcases = ScreenFixtures.bookcases,
|
||||
shelves = ScreenFixtures.shelves,
|
||||
recentShelfId = ScreenFixtures.topShelf.id,
|
||||
onShelfSelected = {},
|
||||
onSave = {},
|
||||
onEditDetails = {},
|
||||
onSkip = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun libraryOnlineSaveSheetCoverFailedLight() = snapshotBoth("library-online-save-sheet-cover-failed") {
|
||||
Surface {
|
||||
OnlineResultSaveSheet(
|
||||
state = OnlineSaveSheetState.Shown(
|
||||
book = onlineResults[0],
|
||||
selectedShelfId = ScreenFixtures.topShelf.id,
|
||||
),
|
||||
bookcases = ScreenFixtures.bookcases,
|
||||
shelves = ScreenFixtures.shelves,
|
||||
recentShelfId = ScreenFixtures.topShelf.id,
|
||||
onShelfSelected = {},
|
||||
onSave = {},
|
||||
onEditDetails = {},
|
||||
onSkip = {},
|
||||
cover = CoverPreloadState.Failed("https://covers.openlibrary.org/b/id/1-M.jpg", "tls connection reset"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Two online hits: one merged from both sources, one Open-Library-only -- the shape [SearchResultMerger] actually produces. */
|
||||
private val onlineResults = listOf(
|
||||
OnlineBook(
|
||||
title = "The Hobbit",
|
||||
authors = listOf("J.R.R. Tolkien"),
|
||||
year = 1937,
|
||||
publisher = "Houghton Mifflin",
|
||||
isbn13 = "9780547928227",
|
||||
sources = setOf(SearchSource.OPEN_LIBRARY, SearchSource.GOOGLE_BOOKS),
|
||||
),
|
||||
OnlineBook(
|
||||
title = "The Annotated Hobbit",
|
||||
authors = listOf("J.R.R. Tolkien", "Douglas A. Anderson"),
|
||||
year = 2002,
|
||||
sources = setOf(SearchSource.OPEN_LIBRARY),
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun Populated() = Shell(books = ScreenFixtures.books, syncStatus = SyncStatus.Synced, syncLabel = "Synced • 2m ago")
|
||||
|
||||
@Composable
|
||||
private fun Empty() = Shell(books = emptyList(), syncStatus = SyncStatus.Offline, syncLabel = "Not synced yet")
|
||||
|
||||
@Composable
|
||||
private fun AuthExpired() = Shell(
|
||||
books = ScreenFixtures.books,
|
||||
syncStatus = SyncStatus.AuthExpired,
|
||||
syncLabel = "Signed out — sign in again to sync",
|
||||
)
|
||||
|
||||
/** Same chrome as [Shell] but with a non-blank query, so [LibraryScreen] shows the sectioned search-results view instead of the grid. */
|
||||
@Composable
|
||||
private fun SearchShell(localMatches: List<BookEntity>, onlineSearch: OnlineSearchState) {
|
||||
BookshelfScaffold(
|
||||
title = "Bookshelf",
|
||||
actions = {
|
||||
IconButton(onClick = {}) { Icon(Icons.Outlined.Warehouse, contentDescription = "Bookcases & shelves") }
|
||||
IconButton(onClick = {}) { Icon(Icons.Outlined.Settings, contentDescription = "Settings") }
|
||||
},
|
||||
floatingActionButton = {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
SmallFloatingActionButton(onClick = {}) { Icon(Icons.Outlined.EditNote, contentDescription = "Add a book by hand") }
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
FloatingActionButton(onClick = {}) { Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book") }
|
||||
}
|
||||
},
|
||||
syncStatusBar = { SyncStatusBar(status = SyncStatus.Synced, label = "Synced • 2m ago") },
|
||||
) { innerPadding ->
|
||||
PaperSurface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LibraryToolbar(
|
||||
query = "hobbit",
|
||||
onQueryChange = {},
|
||||
sortOption = LibrarySortOption.TITLE,
|
||||
onSortOptionChange = {},
|
||||
filter = LibraryFilter.All,
|
||||
onFilterChange = {},
|
||||
bookcases = ScreenFixtures.bookcases,
|
||||
shelves = ScreenFixtures.shelves,
|
||||
)
|
||||
LibrarySearchResultsContent(
|
||||
query = "hobbit",
|
||||
localMatches = localMatches,
|
||||
inLibraryOutsideFilter = (onlineSearch as? OnlineSearchState.Done)?.inLibraryOutsideFilter ?: 0,
|
||||
onlineSearch = onlineSearch,
|
||||
onBookClick = {},
|
||||
onSubmitOnlineSearch = {},
|
||||
onRetryOnlineSearch = {},
|
||||
onOnlineResultClick = {},
|
||||
onEnterByHand = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Shell(books: List<BookEntity>, syncStatus: SyncStatus, syncLabel: String) {
|
||||
BookshelfScaffold(
|
||||
@@ -71,7 +236,11 @@ class LibraryScreenPaparazziTest {
|
||||
IconButton(onClick = {}) { Icon(Icons.Outlined.Settings, contentDescription = "Settings") }
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = {}) { Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book") }
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
SmallFloatingActionButton(onClick = {}) { Icon(Icons.Outlined.EditNote, contentDescription = "Add a book by hand") }
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
FloatingActionButton(onClick = {}) { Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book") }
|
||||
}
|
||||
},
|
||||
syncStatusBar = { SyncStatusBar(status = syncStatus, label = syncLabel) },
|
||||
) { innerPadding ->
|
||||
@@ -91,7 +260,12 @@ class LibraryScreenPaparazziTest {
|
||||
EmptyState(
|
||||
title = "Your shelves are empty",
|
||||
message = "Scan a barcode to add your first book.",
|
||||
action = { PrimaryButton(text = "Scan a book", onClick = {}) },
|
||||
action = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
PrimaryButton(text = "Scan a book", onClick = {})
|
||||
SecondaryButton(text = "Add by hand", onClick = {})
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
|
||||
@@ -7,13 +7,17 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.cash.paparazzi.DeviceConfig
|
||||
import app.cash.paparazzi.Paparazzi
|
||||
import java.io.File
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.diagnostics.CrashReport
|
||||
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
||||
import org.modg.bookshelf.ui.components.GoldDivider
|
||||
import org.modg.bookshelf.ui.components.PaperSurface
|
||||
@@ -21,6 +25,7 @@ import org.modg.bookshelf.ui.components.PrimaryButton
|
||||
import org.modg.bookshelf.ui.components.SecondaryButton
|
||||
import org.modg.bookshelf.ui.components.SyncStatus
|
||||
import org.modg.bookshelf.ui.components.SyncStatusBar
|
||||
import org.modg.bookshelf.ui.settings.DiagnosticsSection
|
||||
import org.modg.bookshelf.ui.settings.InfoRow
|
||||
import org.modg.bookshelf.ui.settings.SectionHeading
|
||||
import org.modg.bookshelf.ui.settings.formatLastSync
|
||||
@@ -28,9 +33,11 @@ import org.modg.bookshelf.ui.theme.BookshelfTheme
|
||||
|
||||
/**
|
||||
* SPEC.md "settings" screen — server, account (now showing the signed-in
|
||||
* email per the wave-4 fix), sync status, book/cover counts. Rebuilds
|
||||
* [org.modg.bookshelf.ui.settings.SettingsScreen]'s shell around its own real
|
||||
* [SectionHeading]/[InfoRow]/[formatLastSync], per the pattern in [ScreenFixtures].
|
||||
* email per the wave-4 fix), sync status, book/cover counts, and — added
|
||||
* 2026-09-12 alongside [org.modg.bookshelf.diagnostics.CrashReporter] — a
|
||||
* Diagnostics section. Rebuilds [org.modg.bookshelf.ui.settings.SettingsScreen]'s
|
||||
* shell around its own real [SectionHeading]/[InfoRow]/[formatLastSync]/
|
||||
* [DiagnosticsSection], per the pattern in [ScreenFixtures].
|
||||
*/
|
||||
class SettingsScreenPaparazziTest {
|
||||
|
||||
@@ -40,11 +47,25 @@ class SettingsScreenPaparazziTest {
|
||||
@Test
|
||||
fun settingsPopulatedLight() = snapshotBoth("settings-populated") { Populated() }
|
||||
|
||||
@Composable
|
||||
private fun Populated() = Shell()
|
||||
/** SPEC "an empty 'Diagnostics' section that looks broken is worse than none" — the no-crashes state. */
|
||||
@Test
|
||||
fun settingsDiagnosticsEmptyLight() = snapshotBoth("settings-diagnostics-empty") { DiagnosticsEmpty() }
|
||||
|
||||
/** 2026-09-17 incident: "Your session expired. Sign in again." — never "Sync failed: HTTP 401". */
|
||||
@Test
|
||||
fun settingsAuthExpiredLight() = snapshotBoth("settings-auth-expired") { AuthExpired() }
|
||||
|
||||
@Composable
|
||||
private fun Shell() {
|
||||
private fun Populated() = Shell(crash = sampleCrash, authExpired = false)
|
||||
|
||||
@Composable
|
||||
private fun DiagnosticsEmpty() = Shell(crash = null, authExpired = false)
|
||||
|
||||
@Composable
|
||||
private fun AuthExpired() = Shell(crash = null, authExpired = true)
|
||||
|
||||
@Composable
|
||||
private fun Shell(crash: CrashReport?, authExpired: Boolean) {
|
||||
val lastSync = System.currentTimeMillis() - 2 * 60 * 1000L
|
||||
BookshelfScaffold(
|
||||
title = "Settings",
|
||||
@@ -52,7 +73,11 @@ class SettingsScreenPaparazziTest {
|
||||
IconButton(onClick = {}) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") }
|
||||
},
|
||||
syncStatusBar = {
|
||||
SyncStatusBar(status = SyncStatus.Synced, label = "Synced • ${formatLastSync(lastSync)}")
|
||||
if (authExpired) {
|
||||
SyncStatusBar(status = SyncStatus.AuthExpired, label = "Signed out — sign in again to sync")
|
||||
} else {
|
||||
SyncStatusBar(status = SyncStatus.Synced, label = "Synced • ${formatLastSync(lastSync)}")
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
PaperSurface(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -70,18 +95,41 @@ class SettingsScreenPaparazziTest {
|
||||
|
||||
SectionHeading("Sync")
|
||||
InfoRow(label = "Last synced", value = formatLastSync(lastSync))
|
||||
PrimaryButton(text = "Sync now", onClick = {}, modifier = Modifier.padding(top = 8.dp))
|
||||
if (authExpired) {
|
||||
Text(
|
||||
text = "Your session expired. Sign in again.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
PrimaryButton(text = "Sign in", onClick = {}, modifier = Modifier.padding(top = 8.dp))
|
||||
} else {
|
||||
PrimaryButton(text = "Sync now", onClick = {}, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
|
||||
SectionHeading("Library")
|
||||
InfoRow(label = "Books", value = ScreenFixtures.books.size.toString())
|
||||
InfoRow(label = "Covers", value = "0")
|
||||
|
||||
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
|
||||
SectionHeading("Diagnostics")
|
||||
DiagnosticsSection(crash = crash, onViewTrace = {}, onShare = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val sampleCrash = CrashReport(
|
||||
file = File("crash-sample.txt"),
|
||||
timestamp = "2026-09-12T03:14:07Z",
|
||||
headline = "java.lang.IllegalStateException: unexpected: IllegalStateException",
|
||||
fullText = "Bookshelf crash report\ntimestamp: 2026-09-12T03:14:07Z\n---\n" +
|
||||
"java.lang.IllegalStateException: unexpected: IllegalStateException\n\tat ...",
|
||||
)
|
||||
|
||||
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
|
||||
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
|
||||
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package org.modg.bookshelf.ui.settings
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.modg.bookshelf.data.local.BookshelfDatabase
|
||||
import org.modg.bookshelf.data.prefs.SettingsStore
|
||||
import org.modg.bookshelf.data.remote.ApiProvider
|
||||
import org.modg.bookshelf.data.repo.AuthRepository
|
||||
import org.modg.bookshelf.data.repo.BookRepository
|
||||
import org.modg.bookshelf.data.repo.FakePocketBaseApi
|
||||
import org.modg.bookshelf.data.repo.SyncEngine
|
||||
import org.modg.bookshelf.data.repo.httpError
|
||||
import org.modg.bookshelf.diagnostics.CrashReporter
|
||||
import org.modg.bookshelf.ui.components.SyncStatus
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* 2026-09-17 incident: [SettingsViewModel.syncNow] used to surface an expired
|
||||
* session as "Sync failed: HTTP 401", which reads as "try again later" rather
|
||||
* than "you must sign in again". Covers the distinct [SyncResult.AuthExpired]
|
||||
* mapping this class's SPEC section adds.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class SettingsViewModelTest {
|
||||
|
||||
private lateinit var db: BookshelfDatabase
|
||||
private lateinit var settingsStore: SettingsStore
|
||||
private lateinit var api: FakePocketBaseApi
|
||||
private lateinit var context: android.content.Context
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
settingsStore = SettingsStore(context)
|
||||
api = FakePocketBaseApi()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(): SettingsViewModel = SettingsViewModel(
|
||||
authRepository = AuthRepository(ApiProvider { api }, settingsStore),
|
||||
settingsStore = settingsStore,
|
||||
bookRepository = BookRepository(db.bookDao(), context),
|
||||
syncEngine = SyncEngine(ApiProvider { api }, db.bookDao(), db.bookcaseDao(), db.shelfDao(), settingsStore),
|
||||
crashReporter = CrashReporter(context),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a plain sync failure keeps the generic error message`() = runTest {
|
||||
settingsStore.setServerUrl("https://example.test")
|
||||
api.authRefreshIOException = java.io.IOException("no network")
|
||||
val vm = viewModel()
|
||||
|
||||
vm.syncNow()
|
||||
|
||||
// Not "!isSyncing" — that's also (trivially) true of the very first
|
||||
// combined emission, before this sync's failure has landed.
|
||||
val state = vm.uiState.first { it.syncError != null }
|
||||
assertFalse(state.syncAuthExpired)
|
||||
assertEquals(SyncStatus.Error, state.syncStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expired session maps to syncAuthExpired, not a generic sync error`() = runTest {
|
||||
settingsStore.setServerUrl("https://example.test")
|
||||
api.authRefreshHttpError = httpError(401)
|
||||
val vm = viewModel()
|
||||
|
||||
vm.syncNow()
|
||||
|
||||
val state = vm.uiState.first { it.syncAuthExpired }
|
||||
assertTrue(state.syncAuthExpired)
|
||||
assertEquals(SyncStatus.AuthExpired, state.syncStatus)
|
||||
assertEquals(null, state.syncError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a later successful sync clears a previous auth-expired state`() = runTest {
|
||||
settingsStore.setServerUrl("https://example.test")
|
||||
api.authRefreshHttpError = httpError(401)
|
||||
val vm = viewModel()
|
||||
vm.syncNow()
|
||||
vm.uiState.first { it.syncAuthExpired }
|
||||
|
||||
api.authRefreshHttpError = null
|
||||
vm.syncNow()
|
||||
|
||||
val state = vm.uiState.first { !it.isSyncing && !it.syncAuthExpired }
|
||||
assertFalse(state.syncAuthExpired)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.modg.bookshelf.ui.setup
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.modg.bookshelf.data.prefs.SettingsStore
|
||||
import org.modg.bookshelf.data.remote.ApiProvider
|
||||
import org.modg.bookshelf.data.repo.AuthRepository
|
||||
import org.modg.bookshelf.data.repo.FakePocketBaseApi
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* 2026-09-17 incident: after an auth expiry clears only the token (see
|
||||
* SettingsStore.clearAuthToken), re-signing-in should be typing the password
|
||||
* only. Covers that the setup screen's ViewModel pre-fills what's already
|
||||
* stored, whether that's from a first-run-in-progress or a post-expiry return trip.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class SetupViewModelTest {
|
||||
|
||||
private lateinit var settingsStore: SettingsStore
|
||||
private lateinit var authRepository: AuthRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
settingsStore = SettingsStore(ApplicationProvider.getApplicationContext())
|
||||
authRepository = AuthRepository(ApiProvider { FakePocketBaseApi() }, settingsStore)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pre-fills the stored server URL and email when present`() = runTest {
|
||||
settingsStore.setServerUrl("https://library.montanaro.home")
|
||||
settingsStore.setUserEmail("reader@example.com")
|
||||
|
||||
val viewModel = SetupViewModel(authRepository)
|
||||
|
||||
val state = viewModel.uiState.first {
|
||||
it.serverUrl == "https://library.montanaro.home" && it.email == "reader@example.com"
|
||||
}
|
||||
assertEquals("https://library.montanaro.home", state.serverUrl)
|
||||
assertEquals("reader@example.com", state.email)
|
||||
assertEquals("", state.password) // never pre-filled — SPEC "typing the password only"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name": "Laura M. Berquist", "personal_name": "Laura M. Berquist", "last_modified": {"type": "/type/datetime", "value": "2008-09-05 16:35:29.434581"}, "key": "/authors/OL335577A", "type": {"key": "/type/author"}, "id": 898991, "revision": 2}
|
||||
@@ -0,0 +1 @@
|
||||
{"name": "Andre Norton", "bio": {"type": "/type/text", "value": "Andre Norton was born Alice Mary Norton in Cleveland, Ohio, the daughter of rug company owner and his wife. She began writing while she was in high school, and she was the editor of a literary page in the school's paper. She also wrote her first novel, Ralestone Luck, which was published in 1938. Her first published novel was The Prince Commands (1934). She graduated from high school in 1930 and began studying teaching at Flora Stone Mather College of Western Reserve University. In 1932 she dropped out early due to economic conditions and began working for the Cleveland Library System. In 1934, she legally changed her name to Andre Alice Norton, the pen name she had adopted to increase her marketability since boys were the main audience for fantasy. In 1941, she bought a bookstore called the Mystery House in Mount Rainier, Maryland, but the business failed and she returned to the Cleveland Public Library. In 1950 she became a reader for the Gnome Press Co. In 1958 she became a full-time author. In 1966 she moved to Florida for health reasons, and then to Murfreesboro, Tennessee. In 1977, she received the Gandalf Grand Master Award from the World Science Fiction Society, and in 1983 she received the Damon Knight Memorial Grand Master Award from the Science Fiction and Fantasy Writers of America. She died in March of 2005 of congestive heart failure.\r\n\r\nShe has been called the Grande Dame of Science Fiction and Fantasy. Over the course of her career, she published over 300 published titles read by four generations. Shortly after her death, the Science Fiction and Fantasy Writers of America created the Andre Norton Award for outstanding work of fantasy or science fiction for Young Adults."}, "remote_ids": {"viaf": "121640202", "wikidata": "Q235899", "bookbrainz": "c3dc7911-ce39-41bf-ad75-07dbc347e721", "musicbrainz": "3bb1d81c-9497-45e1-b257-f1635755ba28", "goodreads": "4766", "isni": "0000000114809327", "imdb": "nm0636157", "lc_naf": "n80126299", "librarything": "nortonandre", "librivox": "358", "project_gutenberg": "7021", "opac_sbn": "CFIV037867"}, "alternate_names": ["Andre Alice Norton", "Andrew North", "Alice Mary Norton", "Alice M. Norton", "Andr\u00e9 Norton", "Alice Andre Norton", "Andre NORTON", "Andre\u0301 Norton", "Norton Andr\u00e9", "Andre\u0301 NORTON", "Mar\u00eda del Carmen Guti\u00e9rrez S\u00e1nchez", "Carmela Guti\u00e9rrez de Gambra", "Miguel Arazuri", "Andr\u00e9 Ronsac", "Clara San Miguel", "Alice Norton", "Enid Colman", "Andre\u0307 Norton", "Andre Norton Mercedes Lackey", "Norton Andre", "Andre Norton and Mercedes Lackey", "Andre Norton-", "Norton, Andre, Griffin, P. M.", "Andre Norton -", "Andrew [aka Andre Norton North", "Andre Andre Norton", "Andre Alice Andre Alice Norton", "Norton Andre Alice", "andre norton", "Andre norton", "Andrew, North"], "death_date": "17 March 2005", "personal_name": "Andre Norton", "type": {"key": "/type/author"}, "links": [{"title": "andre-norton-books.com", "url": "http://www.andre-norton-books.com/", "type": {"key": "/type/link"}}], "fuller_name": "Andre Alice Norton", "photos": [14612221, 5542283, 7412718, 7390867, 14613606], "birth_date": "17 February 1912", "source_records": ["amazon:1374845965", "bwb:9780099100218", "bwb:9781437804874", "bwb:9781520267586", "amazon:0575005254", "bwb:9798736238385", "amazon:042605055X", "bwb:9798729436309", "bwb:9781973137955", "promise:bwb_daily_pallets_2021-03-30", "promise:bwb_daily_pallets_2022-08-13", "promise:bwb_daily_pallets_2021-01-13", "promise:bwb_daily_pallets_2022-06-06:O8-ANT-625", "promise:bwb_daily_pallets_2023-04-19:W8-BLD-821", "promise:bwb_daily_pallets_2023-05-22:292-BAB-432", "promise:bwb_daily_pallets_2023-05-24:P8-CKJ-015", "bwb:9788184566833", "bwb:9781976559105", "bwb:9781981935871", "bwb:9781497390560", "bwb:9781721068869", "ia:andrenortonexile0000andr", "ia:bwb_P9-DFF-436", "ia:bwb_S0-ASU-251", "ia:merlinsmirror0000andr", "bwb:9781986788106"], "key": "/authors/OL27951A", "latest_revision": 29, "revision": 29, "created": {"type": "/type/datetime", "value": "2008-04-01T03:28:50.625462"}, "last_modified": {"type": "/type/datetime", "value": "2025-10-30T20:32:14.601101"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"alternate_names": ["Dzhon R. R. Tolkin", "J. R. R. Tolkien", "J. R. R. Tolki\u00ebn", "J. R.R. Tolkien", "John R. R. Tolkien", "John Ronald Reuel Tolkien", "Tolkien", "Yue Han Luo Na De Rui Er Tuo Er Jin", "J.R.R Tolkien", "J.R.R. TOLKIEN", "Tolkien, J. R. R. (John Ronald Reuel)"], "personal_name": "J. R. R. Tolkien", "type": {"key": "/type/author"}, "death_date": "2 September 1973", "source_records": ["ia:herrabliss0000tolk", "amazon:0007557272", "amazon:0007637675", "amazon:8845927555", "bwb:9780008360108", "amazon:3867170355", "amazon:8830102717", "amazon:2266017519", "amazon:0007270615", "ia:futatsunoto0000unse", "amazon:0342851705", "ia:leseigneurdesann0000tolk_k6z7", "amazon:3423590610", "amazon:1865035955", "amazon:3608939849", "amazon:0007317255", "amazon:902254771X", "amazon:0344895459", "ia:twotowers0002tolk_r5g9", "ia:returnofkingbein0003tolk_m6s4", "amazon:226700092X", "ia:lordofrings0000tolk_j2h8", "ia:lacomunidaddelan0000tolk", "amazon:0618260293", "amazon:1705047610", "amazon:2267017415", "amazon:0007635559", "amazon:360893829X", "bwb:9781782012696", "marc:marc_uic/UIC_2022.mrc:9810667:974", "amazon:1483034577", "amazon:8845293890", "bwb:9786070791390", "promise:bwb_daily_pallets_2022-07-28", "promise:bwb_daily_pallets_2023-02-22:W8-BNK-802", "promise:bwb_daily_pallets_2020-04-30", "amazon:9022531953", "promise:bwb_daily_pallets_2022-06-06", "amazon:9600403678", "bwb:9781693418167", "amazon:8481362581", "amazon:8467909234", "amazon:2356414916", "amazon:8377856816", "amazon:2267030519", "amazon:8377856824", "promise:bwb_daily_pallets_2022-05-16", "promise:bwb_daily_pallets_2022-03-17", "amazon:5170075308", "promise:bwb_daily_pallets_2022-12-12", "ia:lesaventuresdeto0000tolk_t0n6", "promise:bwb_daily_pallets_2022-08-30:KR-117-775", "amazon:0007927045", "amazon:8412076540", "promise:bwb_daily_pallets_2022-09-07:W7-CXN-847", "amazon:0261103687", "amazon:912409546X", "bwb:9780344895463", "promise:bwb_daily_pallets_2022-08-02", "promise:bwb_daily_pallets_2022-04-01:O8-ADR-484", "amazon:8381164842", "promise:bwb_daily_pallets_2023-04-19:P8-BNX-316", "promise:bwb_daily_pallets_2023-04-11:W8-BRW-606", "promise:bwb_daily_pallets_2023-04-07:KS-199-194", "promise:bwb_daily_pallets_2023-03-31:KS-198-493", "promise:bwb_daily_pallets_2023-03-29:KS-198-041", "promise:bwb_daily_pallets_2023-05-15:KN-103-569", "marc:harvard_bibliographic_metadata/ab.bib.11.20150123.full.mrc:381341198:1041", "promise:bwb_daily_pallets_2023-06-01:KS-537-871", "promise:bwb_daily_pallets_2023-07-05:W8-BAY-330", "bwb:9780261103962", "promise:bwb_daily_pallets_2023-07-24:KS-589-214", "promise:bwb_daily_pallets_2023-08-21:W8-BVD-810", "promise:bwb_daily_pallets_2023-09-21:W8-BVW-542", "promise:bwb_daily_pallets_2023-10-10:W8-ADE-653", "amazon:505002255X", "bwb:9780008663162", "promise:bwb_daily_pallets_2023-10-19:O9-ASF-492", "marc:harvard_bibliographic_metadata/ab.bib.11.20150123.full.mrc:653596550:803", "promise:bwb_daily_pallets_2023-12-08:O9-CDZ-574", "promise:bwb_daily_pallets_2023-12-15:KT-657-103", "bwb:9780008669232", "promise:bwb_daily_pallets_2024-02-13:KT-307-140", "promise:bwb_daily_pallets_2024-03-19:KT-278-709", "promise:bwb_daily_pallets_2024-03-26:P9-DQU-862", "promise:bwb_daily_pallets_2022-04-20:KQ-937-250", "bwb:9780008705312", "marc:marc_columbia/Columbia-extract-20221130-010.mrc:238301614:1243", "bwb:9798855136401", "bwb:9781548547455", "bwb:9781530308248", "bwb:9781548547295", "ia:bwb_KQ-385-673", "ia:bwb_S0-AWJ-105", "ia:bwb_W9-DFW-842", "ia:foliosociety3vol0000unse", "ia:habbit0000jrrt", "ia:twotowersbiengse0000jrrt", "promise:bwb_daily_pallets_2021-04-01", "promise:bwb_daily_pallets_2024-12-03:KU-143-063", "bwb:9798786358774", "amazon:853361554X", "promise:bwb_daily_pallets_2024-11-26:KU-427-215", "promise:bwb_daily_pallets_2024-11-01:S0-ACE-347", "marc:harvard_bibliographic_metadata/20220215_036.bib.mrc:285447599:1475", "marc:UniversityOfMichiganMarcCatalogRecords/umich_bib.mrc:403884050:601"], "photos": [6155606, 6433524, 6433525, 6433526, 5731377, -1], "links": [{"title": "The Tolkien Society", "url": "http://www.tolkiensociety.org/index.html", "type": {"key": "/type/link"}}, {"title": "The Official Website of the JRR Tolkien Estate", "url": "http://www.tolkienestate.com/", "type": {"key": "/type/link"}}, {"title": "TheOneRing.com", "url": "http://www.theonering.com/", "type": {"key": "/type/link"}}, {"title": "Planet Tolkien", "url": "http://www.planet-tolkien.com/", "type": {"key": "/type/link"}}, {"title": "The Tolkien Library: Collecting Tolkien books", "url": "http://www.tolkienlibrary.com/", "type": {"key": "/type/link"}}, {"title": "Tolkien Gateway - The J.R.R. Tolkien encyclopedia", "url": "https://tolkiengateway.net", "type": {"key": "/type/link"}}], "name": "J.R.R. Tolkien", "key": "/authors/OL26320A", "entity_type": "person", "bio": "John Ronald Reuel Tolkien (1892-1973) was a major scholar of the English language, specialising in Old and Middle English. Twice Professor of Anglo-Saxon (Old EnLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"name": "Joanne S. Williamson", "personal_name": "Joanne S. Williamson", "last_modified": {"type": "/type/datetime", "value": "2008-09-07 10:49:48.04627"}, "key": "/authors/OL244960A", "type": {"key": "/type/author"}, "id": 640059, "revision": 2}
|
||||
@@ -0,0 +1 @@
|
||||
{"description": "\"In the Tale of The Fall of Gondolin are two of the greatest powers in the world. There is Morgoth of the uttermost evil, unseen in this story but ruling over a vast military power from his fortress of Angband. Deeply opposed to Morgoth is Ulmo, second in might only to Manw\u00eb, chief of the Valar: he is called the Lord of Waters, of all seas, lakes, and rivers under the sky. But he works in secret in Middle-earth to support the Noldor, the kindred of the Elves among whom were numbered H\u00farin and T\u00farin Turambar. Central to this enmity of the gods is the city of Gondolin, beautiful but undiscoverable. It was built and peopled by Noldorin Elves who, when they dwelt in Valinor, the land of the gods, rebelled against their rule and fled to Middle-earth. Turgon King of Gondolin is hated and feared above all his enemies by Morgoth, who seeks in vain to discover the marvellously hidden city, while the gods in Valinor in heated debate largely refuse to intervene in support of Ulmo's desires and designs. Into this world comes Tuor, cousin of T\u00farin, the instrument of Ulmo's designs. Guided unseen by him Tuor sets out from the land of his birth on the fearful journey to Gondolin, and in one of the most arresting moments in the history of Middle-earth the sea-god himself appears to him, rising out of the ocean in the midst of a storm. In Gondolin he becomes great; he is wedded to Idril, Turgon's daughter, and their son is E\u00e4rendel, whose birth and profound importance in days to come is foreseen by Ulmo. At last comes the terrible ending. Morgoth learns through an act of supreme treachery all that he needs to mount a devastating attack on the city, with Balrogs and dragons and numberless Orcs. After a minutely observed account of the fall of Gondolin, the tale ends with the escape of T\u00farin and Idril, with the child E\u00e4rendel, looking back from a cleft in the mountains as they flee southward, at the blazing wreckage of their city. They were journeying into a new story, the Tale of E\u00e4rendel, which Tolkien never wrote, but which is sketched out in this book from other sources. Following his presentation of Beren and L\u00fathien Christopher Tolkien has used the same 'history in sequence' mode in the writing of this edition of The Fall of Gondolin. In the words of J.R.R. Tolkien, it was 'the first real story of this imaginary world' and, together with Beren and L\u00fathien and The Children of H\u00farin, he regarded it as one of the three 'Great Tales' of the Elder Days.\" - Amazon.com", "local_id": ["urn:sfpl:31223125471871", "urn:sfpl:31223125472390", "urn:sfpl:31223125472317", "urn:sfpl:31223125471996", "urn:sfpl:31223125471921", "urn:sfpl:31223125472143", "urn:sfpl:31223125472069", "urn:sfpl:31223125472473", "urn:sfpl:31223125471970", "urn:sfpl:31223125471988", "urn:sfpl:31223124987539", "urn:sfpl:31223124987562", "urn:sfpl:31223124987554", "urn:sfpl:31223125471947", "urn:sfpl:31223125471954", "urn:sfpl:31223125472234", "urn:sfpl:31223125472077", "urn:sfpl:31223125472051", "urn:sfpl:31223125472382", "urn:sfpl:31223125472465", "urn:sfpl:31223125472150", "urn:sfpl:31223125471939", "urn:sfpl:31223125472309", "urn:sfpl:31223125472226", "urn:sfpl:31223125471855", "urn:sfpl:31223125471863", "urn:sfpl:31223125471962", "urn:sfpl:31223125471848", "urn:bwbsku:O7-BSQ-211"], "key": "/books/OL26961988M", "authors": [{"key": "/authors/OL26320A"}], "contributions": ["Tolkien, Christopher, editor", "Lee, Alan, illustrator"], "languages": [{"key": "/languages/eng"}], "pagination": "302 pages", "source_records": ["marc:marc_openlibraries_sanfranciscopubliclibrary/sfpl_chq_2018_12_24_run06.mrc:160111339:9415", "bwb:9781328613042", "promise:bwb_daily_pallets_2021-07-13", "marc:marc_columbia/Columbia-extract-20221130-028.mrc:9422547:1219", "marc:marc_nuls/NULS_PHC_180925.mrc:29920693:1162", "marc:harvard_bibliographic_metadata/20220215_034.bib.mrc:169983458:3735"], "title": "The fall of Gondolin", "notes": "Includes bibliographical references (pages 287-300).", "number_of_pages": 302, "edition_name": "First U.S. edition.", "subjects": ["Middle Earth (Imaginary place)", "Elves", "Fiction"], "publish_date": "2018", "publish_country": "mau", "by_statement": "by J.R.R. Tolkien ; edited by Christopher Tolkien ; with illustrations by Alan Lee", "works": [{"key": "/works/OL8997784W"}], "type": {"key": "/type/edition"}, "identifiers": {}, "isbn_10": ["1328613046"], "isbn_13": ["9781328613042"], "lccn": ["2018145861", "2018037911"], "oclc_numbers": ["1048595707", "1031309781"], "ocaid": "fallofgondolin0000tolk", "classifications": {}, "dewey_decimal_class": ["823/.912"], "lc_classifications": ["PR6039.O32 F36 2018", "PR6039.O32F354 2018", "PR6039.O32 F35 2018", "PR6039.O32 F354 2018"], "publishers": ["Houghton Mifflin Harcourt"], "covers": [12451486], "latest_revision": 10, "revision": 10, "created": {"type": "/type/datetime", "value": "2019-05-24T08:52:24.747977"}, "last_modified": {"type": "/tLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"notes": {"type": "/type/text", "value": "Includes indexes."}, "identifiers": {"goodreads": ["616823"], "librarything": ["34410"]}, "title": "The harp and laurel wreath", "subtitle": "poetry and dictation for the classical curriculum", "publish_date": "1999", "publishers": ["Ignatius Press"], "isbn_10": ["0898707161"], "covers": [698345, -1], "lc_classifications": ["PR1175.3 .H37 1999", "PR1175.3.H37 1999"], "publish_places": ["San Francisco"], "contributions": ["Berquist, Laura M."], "pagination": "493 p. :", "source_records": ["marc:marc_records_scriblio_net/part27.dat:104221239:832", "bwb:9780898707168", "marc:marc_loc_2016/BooksAll.2016.part27.utf8:50197729:832", "promise:bwb_daily_pallets_2023-04-21:T3-AZY-915"], "dewey_decimal_class": ["821.008"], "languages": [{"key": "/languages/eng"}], "lccn": ["98073638"], "subjects": ["Children's poetry, English.", "Children's poetry, American.", "Poetry -- Problems, exercises, etc."], "publish_country": "cau", "by_statement": "edited by Laura M. Berquist ; illustrated by Christopher J. Pelicano.", "oclc_numbers": ["41609032"], "type": {"key": "/type/edition"}, "local_id": ["urn:bwbsku:T3-AZY-915"], "ocaid": "isbn_9780898707168", "key": "/books/OL395070M", "number_of_pages": 493, "works": [{"key": "/works/OL19479056W"}], "latest_revision": 11, "revision": 11, "created": {"type": "/type/datetime", "value": "2008-04-01T03:28:50.625462"}, "last_modified": {"type": "/type/datetime", "value": "2024-09-03T19:12:46.986154"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"publishers": ["Bethlehem Books"], "number_of_pages": 237, "description": {"type": "/type/text", "value": "In ca. 1200 B.C., Uriah the Hittite leaves his conquered homeland and, following his father's instruction, seeks refuge with an old family friend, eventually finding himself in a great battle between the Canaanite forces of Sisera and the Hebrew forces of Barak."}, "isbn_10": ["1883937388"], "subject_place": ["Palestine"], "pagination": "xvii, 237 p. ;", "covers": [930599], "lc_classifications": ["PZ7.W672 Hi 1999"], "key": "/books/OL395004M", "authors": [{"key": "/authors/OL244960A"}], "ocaid": "hittitewarrior00will", "publish_places": ["Warsaw, ND"], "subject_time": ["To 70 A.D."], "genres": ["Fiction.", "Juvenile fiction."], "source_records": ["ia:hittitewarrior00will", "bwb:9781883937386", "marc:marc_loc_2016/BooksAll.2016.part27.utf8:50130386:1337", "promise:bwb_daily_pallets_2022-11-10"], "title": "Hittite warrior", "dewey_decimal_class": ["[Fic]"], "notes": {"type": "/type/text", "value": "\"Ages 10-up\"--P. 4 of cover."}, "identifiers": {"goodreads": ["613497"], "librarything": ["33463"]}, "languages": [{"key": "/languages/eng"}], "lccn": ["98073485"], "subjects": ["Sisera (Biblical figure) -- Fiction.", "Barak (Biblical figure) -- Fiction.", "Bible O.T. -- History of Biblical events -- Juvenile Fiction.", "Bible O.T. -- History of Biblical events -- Fiction.", "Jews -- Palestine -- History -- To 70 A.D. -- Fiction.", "Canaanites -- Fiction.", "Palestine -- History -- To 70 A.D. -- Juvenile fiction.", "Palestine -- History -- To 70 A.D. -- Fiction."], "publish_date": "1999", "publish_country": "ndu", "by_statement": "Joanne Williamson.", "works": [{"key": "/works/OL2028038W"}], "type": {"key": "/type/edition"}, "local_id": ["urn:bwbsku:O8-BVF-683"], "latest_revision": 9, "revision": 9, "created": {"type": "/type/datetime", "value": "2008-04-01T03:28:50.625462"}, "last_modified": {"type": "/type/datetime", "value": "2022-12-04T06:49:23.101355"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"publishers": ["Bethlehem Books", "Ignatius Press"], "number_of_pages": 246, "description": {"type": "/type/text", "value": "When his position at court is usurped by his half brother, a young Egyptian nobleman seeks to regain his standing through military victories against the enemy."}, "isbn_10": ["1883937671"], "subject_place": ["Egypt"], "pagination": "246 p. ;", "covers": [930617], "lc_classifications": ["PZ7.N82 Sh 2001"], "key": "/books/OL3958151M", "authors": [{"key": "/authors/OL27951A"}], "publish_places": ["Bathgate, N.D", "San Francisco"], "subject_time": ["To 332 B.C."], "languages": [{"key": "/languages/eng"}], "genres": ["Juvenile fiction.", "Fiction."], "title": "Shadow hawk", "dewey_decimal_class": ["[Fic]"], "identifiers": {"librarything": ["34114"], "goodreads": ["83108"]}, "edition_name": "1st Bethlehem Books ed.", "lccn": ["2001092576"], "subjects": ["Egypt -- History -- To 332 B.C. -- Juvenile fiction.", "Egypt -- History -- To 332 B.C. -- Fiction."], "publish_date": "2001", "publish_country": "ndu", "series": ["Living history library"], "by_statement": "Andre Norton.", "oclc_numbers": ["52047589"], "works": [{"key": "/works/OL473297W"}], "type": {"key": "/type/edition"}, "source_records": ["marc:marc_loc_2016/BooksAll.2016.part28.utf8:238848352:909", "ia:shadowhawk0000nort"], "ocaid": "shadowhawk0000nort", "latest_revision": 11, "revision": 11, "created": {"type": "/type/datetime", "value": "2008-04-01T03:28:50.625462"}, "last_modified": {"type": "/type/datetime", "value": "2025-06-19T17:18:13.216687"}}
|
||||
@@ -1 +0,0 @@
|
||||
{"ISBN:9781883937386": {"url": "http://openlibrary.org/books/OL395004M/Hittite_warrior", "key": "/books/OL395004M", "title": "Hittite warrior", "authors": [{"url": "http://openlibrary.org/authors/OL244960A/Joanne_S._Williamson", "name": "Joanne S. Williamson"}], "number_of_pages": 237, "pagination": "xvii, 237 p. ;", "by_statement": "Joanne Williamson.", "identifiers": {"goodreads": ["613497"], "librarything": ["33463"], "isbn_10": ["1883937388"], "lccn": ["98073485"], "openlibrary": ["OL395004M"]}, "classifications": {"lc_classifications": ["PZ7.W672 Hi 1999"], "dewey_decimal_class": ["[Fic]"]}, "publishers": [{"name": "Bethlehem Books"}], "publish_places": [{"name": "Warsaw, ND"}], "publish_date": "1999", "subjects": [{"name": "Bible", "url": "https://openlibrary.org/subjects/bible"}, {"name": "Canaanites", "url": "https://openlibrary.org/subjects/canaanites"}, {"name": "Fiction", "url": "https://openlibrary.org/subjects/fiction"}, {"name": "History", "url": "https://openlibrary.org/subjects/history"}, {"name": "History of Biblical events", "url": "https://openlibrary.org/subjects/history_of_biblical_events"}, {"name": "Jews", "url": "https://openlibrary.org/subjects/jews"}, {"name": "Juvenile fiction", "url": "https://openlibrary.org/subjects/juvenile_fiction"}, {"name": "Hittites", "url": "https://openlibrary.org/subjects/hittites"}, {"name": "Middle east, history", "url": "https://openlibrary.org/subjects/middle_east,_history"}, {"name": "Juvenile Fiction", "url": "https://openlibrary.org/subjects/juvenile_fiction"}], "subject_places": [{"name": "Palestine", "url": "https://openlibrary.org/subjects/place:palestine"}], "subject_people": [{"name": "Barak (Biblical figure)", "url": "https://openlibrary.org/subjects/person:barak_(biblical_figure)"}, {"name": "Deborah (Biblical judge)", "url": "https://openlibrary.org/subjects/person:deborah_(biblical_judge)"}, {"name": "Sisera (Biblical figure)", "url": "https://openlibrary.org/subjects/person:sisera_(biblical_figure)"}], "subject_times": [{"name": "To 70 A.D.", "url": "https://openlibrary.org/subjects/time:to_70_a.d."}], "notes": "\"Ages 10-up\"--P. 4 of cover.", "ebooks": [{"preview_url": "https://archive.org/details/hittitewarrior00will", "availability": "borrow", "formats": {}, "borrow_url": "https://openlibrary.org/books/OL395004M/Hittite_warrior/borrow", "checkedout": true}], "cover": {"small": "https://covers.openlibrary.org/b/id/930599-S.jpg", "medium": "https://covers.openlibrary.org/b/id/930599-M.jpg", "large": "https://covers.openlibrary.org/b/id/930599-L.jpg"}}}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"numFound": 0,
|
||||
"start": 0,
|
||||
"docs": []
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"numFound": 2,
|
||||
"start": 0,
|
||||
"docs": [
|
||||
{
|
||||
"key": "/works/OL2028038W",
|
||||
"title": "Hittite Warrior",
|
||||
"author_name": ["Joanne S. Williamson"],
|
||||
"first_publish_year": 1960,
|
||||
"cover_i": 930599,
|
||||
"isbn": ["1883937388", "9781883937386"],
|
||||
"publisher": ["Bethlehem Books"],
|
||||
"number_of_pages_median": 237
|
||||
},
|
||||
{
|
||||
"key": "/works/OL9999999W",
|
||||
"title": "A Book With No ISBN On File",
|
||||
"author_name": ["Some Author"],
|
||||
"first_publish_year": 1901
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"ISBN:9781883937676": {"url": "http://openlibrary.org/books/OL3958151M/Shadow_hawk", "key": "/books/OL3958151M", "title": "Shadow hawk", "authors": [{"url": "http://openlibrary.org/authors/OL27951A/Andre_Norton", "name": "Andre Norton"}], "number_of_pages": 246, "pagination": "246 p. ;", "by_statement": "Andre Norton.", "identifiers": {"librarything": ["34114"], "goodreads": ["83108"], "isbn_10": ["1883937671"], "lccn": ["2001092576"], "oclc": ["52047589"], "openlibrary": ["OL3958151M"]}, "classifications": {"lc_classifications": ["PZ7.N82 Sh 2001"], "dewey_decimal_class": ["[Fic]"]}, "publishers": [{"name": "Bethlehem Books"}, {"name": "Ignatius Press"}], "publish_places": [{"name": "Bathgate, N.D"}, {"name": "San Francisco"}], "publish_date": "2001", "subjects": [{"name": "History", "url": "https://openlibrary.org/subjects/history"}, {"name": "Juvenile fiction", "url": "https://openlibrary.org/subjects/juvenile_fiction"}, {"name": "Egypt in fiction", "url": "https://openlibrary.org/subjects/egypt_in_fiction"}, {"name": "Fiction", "url": "https://openlibrary.org/subjects/fiction"}, {"name": "Children's stories", "url": "https://openlibrary.org/subjects/children's_stories"}, {"name": "Egypt -- History -- To 332 B.C. -- Juvenile fiction.", "url": "https://openlibrary.org/subjects/egypt_--_history_--_to_332_b.c._--_juvenile_fiction."}, {"name": "Fiction, science fiction, general", "url": "https://openlibrary.org/subjects/fiction,_science_fiction,_general"}], "subject_places": [{"name": "Egypt", "url": "https://openlibrary.org/subjects/place:egypt"}], "subject_times": [{"name": "To 332 B.C.", "url": "https://openlibrary.org/subjects/time:to_332_b.c."}], "ebooks": [{"preview_url": "https://archive.org/details/shadowhawk0000nort", "availability": "borrow", "formats": {}, "borrow_url": "https://openlibrary.org/books/OL3958151M/Shadow_hawk/borrow", "checkedout": false}], "cover": {"small": "https://covers.openlibrary.org/b/id/930617-S.jpg", "medium": "https://covers.openlibrary.org/b/id/930617-M.jpg", "large": "https://covers.openlibrary.org/b/id/930617-L.jpg"}}}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"ISBN:9780201558029": {
|
||||
"publishers": [{"name": "Addison-Wesley Professional"}],
|
||||
"title": "Concrete Mathematics",
|
||||
"subtitle": "A Foundation for Computer Science",
|
||||
"identifiers": {
|
||||
"isbn_10": ["0201558025"],
|
||||
"isbn_13": ["9780201558029"]
|
||||
},
|
||||
"authors": [
|
||||
{"name": "Ronald L. Graham", "url": "https://openlibrary.org/authors/OL123456A"},
|
||||
{"name": "Donald E. Knuth"}
|
||||
],
|
||||
"number_of_pages": 672,
|
||||
"publish_date": "1994",
|
||||
"cover": {
|
||||
"small": "https://covers.openlibrary.org/b/id/675832-S.jpg",
|
||||
"medium": "https://covers.openlibrary.org/b/id/675832-M.jpg",
|
||||
"large": "https://covers.openlibrary.org/b/id/675832-L.jpg"
|
||||
},
|
||||
"key": "/books/OL1234567M",
|
||||
"url": "https://openlibrary.org/books/OL1234567M",
|
||||
"notes": "unused field to prove unknown keys are ignored",
|
||||
"excerpts": [{"text": "some excerpt"}]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"created": {"type": "/type/datetime", "value": "2019-04-03T10:45:51.646607"}, "subjects": ["Problems, exercises", "Children's poetry, English", "Poetry", "Children's poetry, American", "Poetry (poetic works by one author)"], "latest_revision": 2, "key": "/works/OL19479056W", "title": "The harp and laurel wreath", "authors": [{"type": {"key": "/type/author_role"}, "author": {"key": "/authors/OL335577A"}}], "type": {"key": "/type/work"}, "last_modified": {"type": "/type/datetime", "value": "2020-08-23T12:34:39.612826"}, "covers": [698345], "revision": 2}
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |