Compare commits

..
17 Commits
Author SHA1 Message Date
Spriteandclaude ab83287b5d Move Open Library lookup to the Editions API; stop asking Google Books for its placeholder
Two user-reported shelf-testing symptoms, both a third party answering
misleadingly and the app believing it.

1. Open Library's /api/books?bibkeys=... now 404s for EVERY ISBN, including
   books OL demonstrably still holds, with OL's own x-ol-stats header on the
   response while /isbn/, /search.json, /api/volumes/brief and covers.* all
   serve normally. OL's docs call it the "Legacy Books API" that "may be phased
   out" and it is gone from their API index, so this reads as a retirement
   rather than an outage. The app had been effectively single-sourced on Google
   Books since it broke: every failure the user saw was a book GB lacks.

   Lookup now uses /isbn/{isbn}.json — current, non-legacy, edition-level, and
   the only option of the three that carries a description. /api/volumes/brief
   is a near drop-in for the old response shape and was rejected precisely
   because it is also legacy.

   Its costs, all handled: authors are references, so AuthorNameCache resolves
   and caches them for the process lifetime (books by one author get scanned in
   runs off one shelf); an edition may carry NO authors, in which case they live
   on the work — 9780898707168 on the user's own shelf is exactly this, so
   without the work fallback the move would have silently dropped its author;
   author/work requests are best-effort and can only degrade a record, never
   turn Found into Unavailable.

   404 on this endpoint is authoritative NotFound. The legacy endpoint reported
   a miss as 200 with an empty object, which is why every non-2xx there was a
   failure. Every other non-2xx still is.

2. Google Books answers zoom=2 with a grey "image not available" PNG at HTTP
   200 — not a 404 — for any volume it holds no full preview of. Coil loads it
   as a success, so BookCover's placeholder never fires and the cover pipeline
   uploads Google's placeholder to PocketBase as the book's cover. Measured over
   18 real volumes: 11 placeholders at zoom=2, 0 at zoom=1&w=400. zoom=0/3/6 are
   placeholders too. normalizeCoverUrl now pins zoom=1, adds w=400 and strips
   edge=curl.

SPEC.md's "Book metadata lookup" is rewritten with both rules and the evidence
for them — it was the source of the zoom=2 instruction, and would otherwise be
the reason someone restores it.

Also fixed, because it blocked verification: LibraryViewModelTest never cleared
the view models it built, and LibraryViewModel's eleven WhileSubscribed(5_000)
flows kept running five seconds into later tests, racing resetMain(). It now
cancels each viewModelScope in tearDown.

NOT fixed, reported instead: AddBookViewModel.performSave's in-flight guard is a
check-then-act and two coroutines can both pass it. Unrelated to this change
(that VM has no metadata dependency) and out of scope. See HAZARD #13.

Verified: assembleDebug exit 0; testDebugUnitTest --rerun-tasks 378 tests,
2 skipped, 0 failures (was 355); verifyPaparazziDebug exit 0, no pixels moved;
0 "always 'false'" warnings; no build files touched. LIVE_METADATA=1 live test
ran (not skipped): 9/9 Found with cover art, with the GB key absent, so Open
Library alone answered through the new endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 02:13:01 +00:00
Spriteandclaude 4695731f91 docs: wave 11 accepted; SPEC records auth refresh + why the 404 rule needs it
HANDOFF: expired-token incident, local data loss, verification incl. live
sync against the public URL, phone recovery path, HAZARD #12 (slow storage).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:57:47 +00:00
Spriteandclaude 009cb9a7cc Wave 11 (M-authexpiry): refresh auth token every sync; recover rows lost to the expired-token sync
Committed by tasks/wave-chain.sh after its mechanical gate passed
(assembleDebug, testDebugUnitTest = 355 tests, verifyPaparazziDebug, no build
files touched, no "always 'false'"). ORCHESTRATOR REVIEW STILL PENDING.
Prompt: tasks/M-authexpiry.txt. Worker: $4.886013000000001, 123 turns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:54:41 +00:00
Spriteandclaude 81f63bffc0 M-authexpiry prompt: measured baseline 337 tests
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:58:57 +00:00
Spriteandclaude 62cd61e707 Plan wave 11 (M-authexpiry): refresh token each sync, recover lost rows
wave-chain.sh: commit subject now comes from tasks/<task>.subject instead
of being hard-coded for waves 9/10; drop a stale session trailer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:39:41 +00:00
Spriteandclaude ab1d294657 server: user auth tokens valid 180 days (was PocketBase default 5)
Phone sync broke 2026-09-17 with HTTP 400: its 5-day token had expired and
the app never refreshes it, so writes arrived anonymous and the rules
rejected them. User chose 180 days. App-side refresh follows in wave 11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:28:12 +00:00
Spriteandclaude 3c30a20324 Review fixes for waves 9-10; preload covers so Save stops waiting on them
Orchestrator review of b600df6 and cf82cbc (the chain's gate only proved they built).

Fixed:
- Search: "In your library" now shows the merged set (owned editions reached only via
  an online ISBN were removed from "Online" and shown nowhere); the shelf filter applies
  to it and hidden owned matches are counted.
- "Edit details" closes the online save sheet (left open, it invited a duplicate save).
- A failed remembered-shelf write after a successful insert no longer reports
  "Couldn't save" (a retry would duplicate the book).
- Library search no longer normalizes every book on every keystroke on the main thread.
- Add-by-hand duplicate check and the scan sheet's save are guarded (no crash, no
  double-tap duplicate, CancellationException rethrown).

Covers (design decided with the user): createBook used to download the cover before
writing the row, so every save waited on the image host, and a failed download silently
saved no cover file. The cover now downloads as soon as a sheet has its URL; Save waits
for it if needed and copies the file into place. A failed download shows on the sheet
with Retry, and Save becomes "Save without cover". Unsaved preloads are discarded, and
leftovers from a killed process are swept at startup.

337 tests (was 308), 2 skipped, 0 failures; assembleDebug and verifyPaparazziDebug green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:01:54 +00:00
Spriteandclaude cf82cbc484 Wave 10 (L-search): search own library + Open Library + Google Books, deduplicated
Committed by tasks/wave-chain.sh after its mechanical gate passed
(assembleDebug, testDebugUnitTest = 308 tests, verifyPaparazziDebug, no build
files touched, no "always 'false'"). ORCHESTRATOR REVIEW STILL PENDING.
Prompt: tasks/L-search.txt. Worker: $10.452535, 157 turns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWoivrRUEmJLFFwbsrGkqQ
2026-09-15 18:28:37 +00:00
Spriteandclaude b600df69c3 Wave 9 (K-manual): add a book by hand, no ISBN required
Committed by tasks/wave-chain.sh after its mechanical gate passed
(assembleDebug, testDebugUnitTest = 241 tests, verifyPaparazziDebug, no build
files touched, no "always 'false'"). ORCHESTRATOR REVIEW STILL PENDING.
Prompt: tasks/K-manual.txt. Worker: $5.051114400000003, 108 turns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWoivrRUEmJLFFwbsrGkqQ
2026-09-15 11:58:37 +00:00
Spriteandclaude 9b74f50565 Plan waves 9-10: manual entry, then online search; chained runner
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWoivrRUEmJLFFwbsrGkqQ
2026-09-13 17:30:38 +00:00
Spriteandclaude 0c0b8466cc docs: first-command check no longer counts its own shell as a worker
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQb3d68LqWD4EdND3C8jkD
2026-09-13 11:38:26 +00:00
Spriteandclaude 007a1f426d wave-guard: match workers by argv shape; accept wave 8; record HAZARD #10
The guard's `pgrep -f 'run-task\.sh'` matched the orchestrator's own stale
launching shell, whose command line contains that text, so it kept the sprite
awake for 17 hours after J-crashsafe finished. workers_running() now requires
argv[1] to be the runner script and argv[2] to be one of this wave's tasks.

Also commits the wave-7 sid/sentinel that were never added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQb3d68LqWD4EdND3C8jkD
2026-09-13 11:33:21 +00:00
Spriteandclaude 34097269c9 Lookup path can't crash the app; persist crash reports for off-device reading
The wave-7 build crashed on the first scan on a real phone and never reproduced.
A live off-device run of the real lookup (93546ed) found nothing wrong with the
parse/merge code. What it did find: nothing on that path was exception-safe. Both
clients caught only IOException, classify caught only serialization errors, and
ScanViewModel.runLookup had no guard at all, so any other throwable — platform
TLS, an OkHttp internal, anything this JVM can't reproduce — killed the process
instead of surfacing on the scan sheet.

- OpenLibraryClient and GoogleBooksClient catch Throwable -> new
  FailureKind.UNEXPECTED, never retried. The reason names the exception CLASS
  only; its message can carry the request URL and therefore the API key.
- ScanViewModel.runLookup guards the same way, which also covers its Room call.
- CancellationException is rethrown ahead of every catch-all: dismissing the
  sheet cancels the lookup, and that must not render as a failure.
- diagnostics.CrashReporter persists uncaught stack traces and chains to the
  previously installed handler in a finally, so the process still dies normally
  even if writing the report fails.
- Settings gains a Diagnostics section: last crash, View trace, Share.

Not fixed, flagged by the worker: performSave/performSaveManualEntry make the
same unguarded Room calls on the save path.

Verified by the orchestrator: assembleDebug (--rerun-tasks) exit 0;
testDebugUnitTest exit 0, 205 tests (was 190), 2 skipped, 0 failures, counted
from TEST-*.xml; verifyPaparazziDebug exit 0; 0 "always 'false'" warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQb3d68LqWD4EdND3C8jkD
2026-09-13 11:31:00 +00:00
Spriteandclaude 1295d88d6e docs: record wave 8 (J-crashsafe) in flight
The user's first scan on the wave-7 build crashed and never reproduced. Their
theory fits structurally — the Google Books Found path had never executed in
production before the key landed — but nine live lookups through the real
repository, including both of their ISBNs, threw nothing, so the parse/merge
code is exonerated by evidence rather than by argument. Written down so no
future worker "fixes" code that was measured working.

What the wave fixes is the defect found while looking: the lookup path catches
only IOException and the ViewModel catches nothing, so any other throwable kills
the process instead of reaching the user as "couldn't be reached" — and the app
has no crash capture at all, which is why one crash left no evidence.

Committed with an explicit pathspec: a wave is in flight (HAZARD #7).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpdG8VnRfS3KkisR3HUAE
2026-09-12 17:51:13 +00:00
Spriteandclaude 0455794603 server: anonymous LIST of users returns 403, not 200 with an empty array
Found while re-verifying the security posture against the public hostname now
that the server is internet-exposed. pb_hooks/main.pb.js covered bookcases,
shelves and books but never users, so an anonymous GET of the accounts
collection answered 200 with an empty array.

Nothing leaked: two real accounts exist and the declarative listRule filtered
both out, so no account, email or id was ever visible to an anonymous caller.
But "200 with []" is the exact wrong signal this hook exists to remove — some
clients read it as an allowed request — and the accounts collection is the last
place to leave it. Defensible while the server was localhost-only; not now.

Re-verified over the internet after restarting the service: books, shelves,
bookcases and users all 403 anonymous, self-registration 403, health 200.

Also re-verified that login still works, since this hook now runs on a
collection the app authenticates against: auth-with-password returns 200 with a
token, and an authenticated LIST of all four collections still returns 200. The
hook rejects unauthenticated list/search only, and auth-with-password is not a
list request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpdG8VnRfS3KkisR3HUAE
2026-09-12 17:47:43 +00:00
Spriteandclaude 93546ed724 Opt-in live test: the real metadata lookup, against both real sources
Investigating the crash the user hit on the first scan after the Google Books
key landed. The structural suspicion was sound: keyless Google Books 429'd every
caller, so GoogleBooksClient's 2xx branch, toBookMetadata(), normalizeCoverUrl()
and the two-source merge had never once executed in production before 486f6eb.
First exercise of a code path is where a first crash belongs.

It does not reproduce here. Nine live lookups through the real
MetadataRepository.lookup — both of the user's previously-failing ISBNs plus a
control, three times each, with the real key — all returned Found with cover
art, 254ms to 4.7s, nothing thrown. So it is not a parse, merge or cover-URL
bug in any form this machine can provoke, which is worth knowing before anyone
spends a wave rewriting that code.

The test asserts the contract rather than the content: that lookup RETURNS
instead of throwing. That is what both clients' KDoc claims ("Never throws")
and what nothing currently enforces.

Gated on LIVE_METADATA=1 like LiveSyncTest, so the normal suite stays offline
and deterministic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpdG8VnRfS3KkisR3HUAE
2026-09-12 17:36:11 +00:00
Spriteandclaude 1385ad286f docs: the server is on the sprite and internet-exposed
The long-standing open question "where will the server actually live" is
answered for now: it stays on this sprite, bound to 0.0.0.0:8090 and published
over HTTPS by the sprite proxy. Several docs asserted the opposite — HANDOFF
said "127.0.0.1:8090, deliberately NOT internet-exposed (no --http-port, so the
sprite proxy can't reach it)", which is flatly wrong today.

The consequence is the part worth writing down: PocketBase's API rules are now
the only thing between this library and the internet. There is no NAT, no VPN,
no reverse proxy. So the anonymous-access curls stop being a formality, and they
have to run against the PUBLIC hostname — localhost cannot tell you what the
world can reach. Re-verified that way: books/shelves/bookcases LIST all 403,
self-registration 403, health 200.

One gap found while re-verifying, recorded but NOT fixed: users LIST answers 200
with an empty array instead of 403. Nothing is disclosed — two real accounts
exist and the listRule filters both out — but it is the same wrong-signal quirk
pb_hooks/main.pb.js exists to close, and that hook never listed the users
collection.

SPEC's offline-first rationale is amended rather than its rule: the reason is no
longer residential NAT but a sprite that suspends when idle and wakes on
request. The rule is unchanged and does not depend on which.

server/deploy/ still documents systemd/Docker/Tailscale on home hardware; it now
says up front that this is the intended end state, not what is running.

Also corrected, since it was adjacent and plainly false: README still claimed the
app had never run on a physical device. It has, since 2026-09-09. What is true is
that no *automated* test runs on a device — there is no emulator on this box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpdG8VnRfS3KkisR3HUAE
2026-09-12 17:32:17 +00:00
148 changed files with 9192 additions and 333 deletions

No files matched your search

+40 -11
View File
@@ -124,6 +124,34 @@ that keystore).
## Deploying the server ## 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 See [`server/README.md`](server/README.md) for the schema and provisioning
scripts, and [`server/deploy/`](server/deploy/) for running PocketBase scripts, and [`server/deploy/`](server/deploy/) for running PocketBase
long-term (systemd or Docker), reaching it from outside your home network 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: Read this before assuming more polish than exists:
- **The app has never run on a physical device or emulator.** This - **No automated testing runs on a device.** The app itself has been
environment has no KVM, so there is no Android emulator available. installed and used on a real phone since 2026-09-09, and several rounds of
Everything here was verified via `./gradlew assembleDebug`, fixes came out of that; but this environment has no KVM, so there is no
`testDebugUnitTest` (JVM/Robolectric unit tests), and Paparazzi screenshot emulator, and every *automated* check is a JVM one. Verification is
rendering (`src/test/snapshots/images/`) — real logic paths (ISBN `./gradlew assembleDebug`, `testDebugUnitTest` (JVM/Robolectric unit
checksums, metadata merging, sync conflict resolution, DAO queries) are tests), and Paparazzi screenshot rendering
unit-tested, and every screen has been rendered to a static PNG in both (`src/test/snapshots/images/`) — real logic paths (ISBN checksums,
light and dark theme, but nothing has been tap-tested on an actual screen. metadata merging, sync conflict resolution, DAO queries) are unit-tested,
Camera/barcode scanning in particular has only been exercised through unit and every screen is rendered to a static PNG in both light and dark theme.
tests of the pure logic (`IsbnBarcodeAnalyzer`/`ScanCodeFilter`), never a Camera/barcode scanning is covered only through unit tests of the pure
live camera. 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** - **Sync has been round-tripped against a real PocketBase exactly once**
(a live-server test covering auth, push with client-generated ids, pull, (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 last-write-wins, tombstones, and a byte-for-byte cover round-trip — see
@@ -1,6 +1,7 @@
package org.modg.bookshelf package org.modg.bookshelf
import android.content.Context import android.content.Context
import java.io.File
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -11,7 +12,10 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import org.modg.bookshelf.data.local.BookshelfDatabase 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.data.metadata.MetadataRepository
import org.modg.bookshelf.diagnostics.CrashReporter
import org.modg.bookshelf.data.prefs.SettingsStore import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.remote.ApiProvider import org.modg.bookshelf.data.remote.ApiProvider
import org.modg.bookshelf.data.remote.PbAuthInterceptor import org.modg.bookshelf.data.remote.PbAuthInterceptor
@@ -41,6 +45,9 @@ class AppContainer(private val context: Context) {
val settingsStore = SettingsStore(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) } private val database by lazy { BookshelfDatabase.build(context) }
val bookRepository by lazy { BookRepository(database.bookDao(), 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) } 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 { val syncEngine by lazy {
SyncEngine( SyncEngine(
apiProvider = apiProvider, apiProvider = apiProvider,
@@ -13,5 +13,12 @@ class BookshelfApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
appContainer = AppContainer(this) 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 package org.modg.bookshelf.data.metadata
import java.io.IOException import java.io.IOException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
@@ -17,8 +18,17 @@ import okhttp3.Request
* key (the default) sends the request keyless, unchanged from before — this * 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 * matters for a fresh clone with no key configured, which must still build a
* working app rather than a broken one. * 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( class GoogleBooksClient(
private val httpClient: OkHttpClient, private val httpClient: OkHttpClient,
@@ -48,8 +58,20 @@ class GoogleBooksClient(
httpClient.newCall(request).execute().use { response -> httpClient.newCall(request).execute().use { response ->
classify(response.code, response.body.string(), response.header("Retry-After")) 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) { } catch (e: IOException) {
SourceResult.fromException(e) 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. * will. Package-visible so the scrub itself is unit-testable without a socket.
*/ */
internal fun redact(result: SourceResult): SourceResult = internal fun redact(result: SourceResult): SourceResult =
if (result is SourceResult.Failed && apiKey.isNotBlank() && result.reason.contains(apiKey)) { if (result is SourceResult.Failed) result.copy(reason = redactApiKey(result.reason, apiKey)) else result
result.copy(reason = result.reason.replace(apiKey, "[REDACTED]"))
} else {
result
}
} }
@@ -38,7 +38,7 @@ data class GoogleBooksIndustryIdentifierDto(
val identifier: String? = null, 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( fun GoogleBooksVolumeInfoDto.toBookMetadata(): BookMetadata = BookMetadata(
isbn13 = industryIdentifiers.firstOrNull { it.type == "ISBN_13" }?.identifier, isbn13 = industryIdentifiers.firstOrNull { it.type == "ISBN_13" }?.identifier,
isbn10 = industryIdentifiers.firstOrNull { it.type == "ISBN_10" }?.identifier, isbn10 = industryIdentifiers.firstOrNull { it.type == "ISBN_10" }?.identifier,
@@ -52,8 +52,81 @@ fun GoogleBooksVolumeInfoDto.toBookMetadata(): BookMetadata = BookMetadata(
coverUrl = normalizeCoverUrl(imageLinks?.thumbnail ?: imageLinks?.smallThumbnail), 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 if (raw.isNullOrBlank()) return null
val https = raw.replaceFirst("http://", "https://") val https = raw.replaceFirst("http://", "https://").replace("&edge=curl", "")
return if ("zoom=" in https) https.replace(Regex("zoom=\\d+"), "zoom=2") else "$https&zoom=2" 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 package org.modg.bookshelf.data.metadata
import java.io.IOException import java.io.IOException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
/** /**
* Open Library lookup — SPEC.md "Book metadata lookup" primary source. * Open Library lookup — SPEC.md "Book metadata lookup" primary source.
* Never throws: 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( class OpenLibraryClient(
private val httpClient: OkHttpClient, private val httpClient: OkHttpClient,
json: Json, json: Json,
private val authorNames: AuthorNameCache = AuthorNameCache(),
) { ) {
// Real responses carry fields this DTO doesn't model; never let an unknown key throw. // Real responses carry fields this DTO doesn't model; never let an unknown key throw.
private val json = Json(from = json) { ignoreUnknownKeys = true } private val json = Json(from = json) { ignoreUnknownKeys = true }
@@ -26,6 +39,11 @@ class OpenLibraryClient(
* Retries transient failures per [RetryPolicy]. Measured against the live API, * 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 * 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%. * 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 = suspend fun lookup(isbn13: String): SourceResult =
withContext(Dispatchers.IO) { withRetry { fetch(isbn13) } } withContext(Dispatchers.IO) { withRetry { fetch(isbn13) } }
@@ -35,37 +53,136 @@ class OpenLibraryClient(
withContext(Dispatchers.IO) { fetch(isbn13) } withContext(Dispatchers.IO) { fetch(isbn13) }
private fun fetch(isbn13: String): SourceResult = try { private fun fetch(isbn13: String): SourceResult = try {
val request = Request.Builder() val (code, body) = get("https://openlibrary.org/isbn/$isbn13.json")
.url("https://openlibrary.org/api/books?bibkeys=ISBN:$isbn13&format=json&jscmd=data") when (val outcome = classifyEdition(code, body)) {
.build() is EditionOutcome.Terminal -> outcome.result
httpClient.newCall(request).execute().use { response -> is EditionOutcome.Parsed -> SourceResult.Found(enrich(outcome.dto, isbn13))
classify(response.code, response.body.string(), 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) { } catch (e: IOException) {
SourceResult.fromException(e) 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 * Turns a parsed edition into a complete [BookMetadata], resolving author
* unit-testable offline (2xx-with-record, 2xx-without-record, 404, 429, 500, * references to names.
* malformed body). [parseResponse] is defined in terms of this so the two *
* can never disagree about what a body means. * 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 { private fun enrich(dto: OpenLibraryEditionDto, isbn13: String): BookMetadata {
if (httpCode !in 200..299) return SourceResult.fromHttpCode(httpCode) val workKey = dto.works.firstOrNull()?.key?.takeIf { it.isNotBlank() }
if (body.isNullOrBlank()) return SourceResult.Failed("empty body", FailureKind.MALFORMED) // 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 { return try {
val root = json.parseToJsonElement(body).jsonObject EditionOutcome.Parsed(json.decodeFromString<OpenLibraryEditionDto>(body))
val entry = root["ISBN:$isbn13"]?.jsonObject ?: return SourceResult.NotFound
SourceResult.Found(json.decodeFromJsonElement<OpenLibraryBookDto>(entry).toBookMetadata(isbn13))
} catch (e: SerializationException) { } catch (e: SerializationException) {
SourceResult.Failed("malformed json", FailureKind.MALFORMED) EditionOutcome.Terminal(SourceResult.Failed("malformed json", FailureKind.MALFORMED))
} catch (e: IllegalArgumentException) { } 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? = internal fun parseResponse(body: String, isbn13: String): BookMetadata? =
(classify(200, body, isbn13) as? SourceResult.Found)?.metadata (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.SerialName
import kotlinx.serialization.Serializable 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 * https://openlibrary.org/isbn/{isbn}.json — Open Library's **Editions API**
* Response is a JSON object keyed by "ISBN:{isbn}"; this DTO models one value. * (documented under "Work & Edition APIs"), which answers 302 -> 200 with a single
* Real responses carry many more fields (excerpts, ebooks, key, url, ...) that * edition record.
* we don't need — the caller's Json must have ignoreUnknownKeys = true. *
* 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 @Serializable
data class OpenLibraryBookDto( data class OpenLibraryEditionDto(
val title: String? = null, val title: String? = null,
val subtitle: 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("publish_date") val publishDate: String? = null,
@SerialName("number_of_pages") val numberOfPages: Int? = null, @SerialName("number_of_pages") val numberOfPages: Int? = null,
val identifiers: OpenLibraryIdentifiersDto? = null, @SerialName("isbn_10") val isbn10: List<String> = emptyList(),
val cover: OpenLibraryCoverDto? = null, @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 @Serializable
data class OpenLibraryAuthorDto(val name: String? = null) 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 * https://openlibrary.org/works/{id}.json — fetched only when the edition record
* the only trustworthy "has a cover" signal from this API. A synthesized by-ISBN * carries no authors of its own. Work authors nest one level deeper than edition
* covers.openlibrary.org URL is NOT evidence of one: for an edition with no art it * authors: `[{"author": {"key": "/authors/OL335577A"}}]`.
* answers 200 with a 43-byte 1x1 transparent GIF (verified 2026-09-09), which any
* image loader reports as a successful load — the cover then renders as nothing at
* all and no error placeholder ever fires. Only `?default=false` turns that into a
* 404; see [MetadataRepository] for the last-resort URL that uses it.
*/ */
@Serializable @Serializable
data class OpenLibraryCoverDto( data class OpenLibraryWorkDto(
val small: String? = null, val authors: List<OpenLibraryWorkAuthorDto> = emptyList(),
val medium: String? = null, val description: JsonElement? = null,
val large: String? = null,
) )
@Serializable @Serializable
data class OpenLibraryIdentifiersDto( data class OpenLibraryWorkAuthorDto(val author: OpenLibraryKeyRefDto? = null)
@SerialName("isbn_10") val isbn10: List<String> = emptyList(),
@SerialName("isbn_13") val isbn13: List<String> = emptyList(),
)
/** /**
* Maps the OL DTO to the source-agnostic [BookMetadata]. [coverUrl] stays null unless * Open Library writes a description either as a bare string or as a typed object
* OL reports real cover art, so that the SPEC merge rule can fall through to Google * `{"type": "/type/text", "value": "..."}`, and both shapes occur in the wild — of
* Books' thumbnail instead of pinning a URL that resolves to a blank image. * 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( internal fun JsonElement?.text(): String? = when (this) {
isbn13 = identifiers?.isbn13?.firstOrNull() ?: lookupIsbn13, null -> null
isbn10 = identifiers?.isbn10?.firstOrNull(), 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, title = title,
subtitle = subtitle, subtitle = subtitle,
authors = authors.mapNotNull { it.name }, authors = authors,
publisher = publishers.firstOrNull()?.name, publisher = publishers.firstOrNull()?.takeIf { it.isNotBlank() },
publishedDate = publishDate, publishedDate = publishDate,
pageCount = numberOfPages, pageCount = numberOfPages,
description = null, description = this.description.text() ?: description,
coverUrl = cover?.large?.takeIf { it.isNotBlank() } ?: cover?.medium?.takeIf { it.isNotBlank() }, 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. * changing this blanket answer.
* - CLIENT_ERROR — an identical request gets an identical answer. * - CLIENT_ERROR — an identical request gets an identical answer.
* - MALFORMED — same bytes, same parse failure. * - 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 = fun isRetryable(kind: FailureKind): Boolean =
kind == FailureKind.TRANSPORT || kind == FailureKind.SERVER_ERROR kind == FailureKind.TRANSPORT || kind == FailureKind.SERVER_ERROR
@@ -128,22 +130,82 @@ suspend fun withRetry(
nowMillis: () -> Long = { System.currentTimeMillis() }, nowMillis: () -> Long = { System.currentTimeMillis() },
sleep: suspend (Long) -> Unit = { delay(it) }, sleep: suspend (Long) -> Unit = { delay(it) },
attempt: suspend () -> SourceResult, 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() val started = nowMillis()
var last: SourceResult = attempt() var last: T = attempt()
var attemptsMade = 1 var attemptsMade = 1
var rateLimitedRetryUsed = false var rateLimitedRetryUsed = false
while (attemptsMade < maxAttempts) { while (attemptsMade < maxAttempts) {
val failure = last as? SourceResult.Failed ?: return last val failure = failureOf(last) ?: return last
val rateLimitedRetry = failure.kind == FailureKind.RATE_LIMITED && val (kind, retryAfterMillis) = failure
retryRateLimitedOnce && !rateLimitedRetryUsed val rateLimitedRetry = kind == FailureKind.RATE_LIMITED && retryRateLimitedOnce && !rateLimitedRetryUsed
if (!RetryPolicy.isRetryable(failure.kind) && !rateLimitedRetry) break if (!RetryPolicy.isRetryable(kind) && !rateLimitedRetry) break
if (nowMillis() - started >= budgetMillis) break if (nowMillis() - started >= budgetMillis) break
if (rateLimitedRetry) { if (rateLimitedRetry) {
rateLimitedRetryUsed = true rateLimitedRetryUsed = true
sleep(failure.retryAfterMillis ?: RetryPolicy.RATE_LIMIT_RETRY_CAP_MILLIS) sleep(retryAfterMillis ?: RetryPolicy.RATE_LIMIT_RETRY_CAP_MILLIS)
} else { } else {
sleep(RetryPolicy.backoffMillis(attemptsMade + 1, random)) sleep(RetryPolicy.backoffMillis(attemptsMade + 1, random))
} }
@@ -151,6 +213,6 @@ suspend fun withRetry(
attemptsMade++ attemptsMade++
} }
val failure = last as? SourceResult.Failed ?: return last if (failureOf(last) == null) return last
return if (attemptsMade > 1) failure.copy(reason = "${failure.reason}, $attemptsMade attempts") else failure 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. */ /** 2xx whose body we could not parse. Deterministic: the same bytes will fail again. */
MALFORMED, 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 android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
@@ -27,6 +28,10 @@ class SettingsStore(private val context: Context) {
val USER_EMAIL = stringPreferencesKey("user_email") val USER_EMAIL = stringPreferencesKey("user_email")
val LAST_SYNC_TIME = longPreferencesKey("last_sync_time") val LAST_SYNC_TIME = longPreferencesKey("last_sync_time")
val LAST_SHELF_ID = stringPreferencesKey("last_shelf_id") 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") 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 userEmail: Flow<String?> = context.dataStore.data.map { it[Keys.USER_EMAIL] }
val lastSyncTime: Flow<Long?> = context.dataStore.data.map { it[Keys.LAST_SYNC_TIME] } 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 * 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). * 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 } 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) { suspend fun setLastSyncTime(epochMillis: Long) {
context.dataStore.edit { it[Keys.LAST_SYNC_TIME] = epochMillis } 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) { suspend fun setLastShelfId(shelfId: String) {
context.dataStore.edit { it[Keys.LAST_SHELF_ID] = shelfId } 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 { companion object {
const val COLLECTION_BOOKS = "books" const val COLLECTION_BOOKS = "books"
const val COLLECTION_SHELVES = "shelves" const val COLLECTION_SHELVES = "shelves"
@@ -20,6 +20,19 @@ interface PocketBaseApi {
@POST("api/collections/users/auth-with-password") @POST("api/collections/users/auth-with-password")
suspend fun authWithPassword(@Body body: AuthWithPasswordRequest): AuthResponse 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 ---- // ---- books ----
@GET("api/collections/books/records") @GET("api/collections/books/records")
@@ -18,6 +18,9 @@ class AuthRepository(
val isLoggedIn: Flow<Boolean> = settingsStore.authToken.map { !it.isNullOrBlank() } val isLoggedIn: Flow<Boolean> = settingsStore.authToken.map { !it.isNullOrBlank() }
val serverUrl: Flow<String?> = settingsStore.serverUrl 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. */ /** Normalizes per SPEC: requires https, strips a trailing slash. */
suspend fun setServerUrl(rawUrl: String): Result<Unit> { suspend fun setServerUrl(rawUrl: String): Result<Unit> {
val trimmed = rawUrl.trim().trimEnd('/') val trimmed = rawUrl.trim().trimEnd('/')
@@ -36,6 +39,12 @@ class AuthRepository(
settingsStore.setAuthToken(response.token) settingsStore.setAuthToken(response.token)
settingsStore.setUserId(response.record.id) settingsStore.setUserId(response.record.id)
settingsStore.setUserEmail(email) 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) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
@@ -7,14 +7,11 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import okhttp3.OkHttpClient
import okhttp3.Request
import org.modg.bookshelf.data.local.BookDao import org.modg.bookshelf.data.local.BookDao
import org.modg.bookshelf.data.local.BookEntity import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.IdGenerator import org.modg.bookshelf.data.local.IdGenerator
import org.modg.bookshelf.data.local.SyncState import org.modg.bookshelf.data.local.SyncState
import java.io.File import java.io.File
import java.io.IOException
private val authorsJson = Json { ignoreUnknownKeys = true } private val authorsJson = Json { ignoreUnknownKeys = true }
@@ -31,7 +28,6 @@ fun decodeAuthors(json: String): List<String> =
class BookRepository( class BookRepository(
private val bookDao: BookDao, private val bookDao: BookDao,
private val context: Context, private val context: Context,
private val downloadClient: OkHttpClient = OkHttpClient(),
) { ) {
fun observeAll(): Flow<List<BookEntity>> = bookDao.observeAll() fun observeAll(): Flow<List<BookEntity>> = bookDao.observeAll()
fun observeAllByRecentlyAdded(): Flow<List<BookEntity>> = bookDao.observeAllByRecentlyAdded() fun observeAllByRecentlyAdded(): Flow<List<BookEntity>> = bookDao.observeAllByRecentlyAdded()
@@ -44,10 +40,13 @@ class BookRepository(
suspend fun countByShelf(shelfId: String): Int = bookDao.countByShelf(shelfId) suspend fun countByShelf(shelfId: String): Int = bookDao.countByShelf(shelfId)
/** /**
* Creates a new book. If [coverSourceUrl] is set, attempts to download the * Creates a new book. [coverFile] is a cover the caller already downloaded (see
* cover to app-private storage now (best-effort; failures are swallowed — * [CoverPreload]); it is COPIED into app-private storage as [BookEntity.localCoverPath]
* the SyncEngine will simply have nothing to upload, and the UI still has * for SyncEngine to upload. This used to download [coverSourceUrl] itself before
* [coverSourceUrl] to fall back on for display). * 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( suspend fun createBook(
title: String, title: String,
@@ -60,35 +59,42 @@ class BookRepository(
pageCount: Int? = null, pageCount: Int? = null,
description: String? = null, description: String? = null,
coverSourceUrl: String? = null, coverSourceUrl: String? = null,
coverFile: File? = null,
shelfId: String? = null, shelfId: String? = null,
notes: String? = null, notes: String? = null,
addedBy: String? = null, addedBy: String? = null,
): String { ): String {
val id = IdGenerator.newId() val id = IdGenerator.newId()
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val localCoverPath = coverSourceUrl?.let { downloadCoverBestEffort(id, it) } val localCover = coverFile?.let { copyCover(id, it) }
bookDao.upsert( try {
BookEntity( bookDao.upsert(
id = id, BookEntity(
title = title, id = id,
subtitle = subtitle, title = title,
authorsJson = encodeAuthors(authors), subtitle = subtitle,
isbn13 = isbn13, authorsJson = encodeAuthors(authors),
isbn10 = isbn10, isbn13 = isbn13,
publisher = publisher, isbn10 = isbn10,
publishedDate = publishedDate, publisher = publisher,
pageCount = pageCount, publishedDate = publishedDate,
description = description, pageCount = pageCount,
coverSourceUrl = coverSourceUrl, description = description,
shelfId = shelfId, coverSourceUrl = coverSourceUrl,
notes = notes, shelfId = shelfId,
addedBy = addedBy, notes = notes,
createdAt = now, addedBy = addedBy,
updatedAt = now, createdAt = now,
syncState = SyncState.PENDING_CREATE, updatedAt = now,
localCoverPath = localCoverPath, 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 return id
} }
@@ -131,19 +137,8 @@ class BookRepository(
) )
} }
private suspend fun downloadCoverBestEffort(id: String, url: String): String? = withContext(Dispatchers.IO) { private suspend fun copyCover(id: String, source: File): File = withContext(Dispatchers.IO) {
try { val dir = File(context.filesDir, "covers").apply { mkdirs() }
val request = Request.Builder().url(url).build() source.copyTo(File(dir, "$id.jpg"), overwrite = true)
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
}
} }
} }
@@ -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 package org.modg.bookshelf.data.repo
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
@@ -26,6 +27,13 @@ sealed class SyncResult {
object Success : SyncResult() object Success : SyncResult()
object Skipped : SyncResult() object Skipped : SyncResult()
data class Failure(val error: Throwable) : 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 * is bookcases -> shelves -> books both ways, so relations resolve cleanly
* even though Room does not enforce foreign keys between them (sync would * even though Room does not enforce foreign keys between them (sync would
* otherwise have to special-case out-of-order pages). * 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( class SyncEngine(
private val apiProvider: ApiProvider, private val apiProvider: ApiProvider,
@@ -46,6 +66,25 @@ class SyncEngine(
suspend fun sync(): SyncResult { suspend fun sync(): SyncResult {
val api = apiProvider.api() ?: return SyncResult.Skipped val api = apiProvider.api() ?: return SyncResult.Skipped
return try { 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) pushBookcases(api)
pushShelves(api) pushShelves(api)
pushBooks(api) pushBooks(api)
@@ -53,7 +92,24 @@ class SyncEngine(
pullShelves(api) pullShelves(api)
pullBooks(api) pullBooks(api)
settingsStore.setLastSyncTime(System.currentTimeMillis()) 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 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) { } catch (e: IOException) {
SyncResult.Failure(e) SyncResult.Failure(e)
} catch (e: Exception) { } 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) { private suspend fun updateBookcase(api: PocketBaseApi, entity: BookcaseEntity) {
try { try {
val response = api.updateBookcase(entity.id, entity.toDto()) 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) { private suspend fun updateShelf(api: PocketBaseApi, entity: ShelfEntity) {
try { try {
val response = api.updateShelf(entity.id, entity.toDto()) 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) { private suspend fun updateBook(api: PocketBaseApi, entity: BookEntity) {
try { try {
val response = api.updateBook(entity.id, entity.toDto()) 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.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -36,6 +37,9 @@ enum class SyncStatus {
Syncing, Syncing,
Offline, Offline,
Error, 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, status: SyncStatus,
label: String, label: String,
modifier: Modifier = Modifier, 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( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerLow) .background(MaterialTheme.colorScheme.surfaceContainerLow)
.let { if (onClick != null) it.clickable(onClick = onClick) else it }
.navigationBarsPadding() .navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 8.dp), .padding(horizontal = 28.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -77,7 +86,7 @@ private fun StatusDot(status: SyncStatus) {
SyncStatus.Synced -> MaterialTheme.colorScheme.secondary SyncStatus.Synced -> MaterialTheme.colorScheme.secondary
SyncStatus.Syncing -> MaterialTheme.colorScheme.secondary SyncStatus.Syncing -> MaterialTheme.colorScheme.secondary
SyncStatus.Offline -> MaterialTheme.colorScheme.onSurfaceVariant SyncStatus.Offline -> MaterialTheme.colorScheme.onSurfaceVariant
SyncStatus.Error -> MaterialTheme.colorScheme.error SyncStatus.Error, SyncStatus.AuthExpired -> MaterialTheme.colorScheme.error
} }
if (status == SyncStatus.Syncing) { if (status == SyncStatus.Syncing) {
val transition = rememberInfiniteTransition(label = "sync-pulse") val transition = rememberInfiniteTransition(label = "sync-pulse")
@@ -1,6 +1,8 @@
package org.modg.bookshelf.ui.library package org.modg.bookshelf.ui.library
import org.modg.bookshelf.data.local.BookEntity import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.data.metadata.OnlineBook
import org.modg.bookshelf.data.repo.decodeAuthors import org.modg.bookshelf.data.repo.decodeAuthors
/** SPEC.md library screen: "sort title/author/added". */ /** 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 package org.modg.bookshelf.ui.library
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items 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.Icons
import androidx.compose.material.icons.outlined.Clear 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.FilterList
import androidx.compose.material.icons.outlined.QrCodeScanner import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Sort import androidx.compose.material.icons.outlined.Sort
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -25,9 +37,13 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -37,6 +53,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.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.BookEntity
import org.modg.bookshelf.data.local.BookcaseEntity import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.data.metadata.OnlineBook
import org.modg.bookshelf.data.repo.decodeAuthors 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.BookCover
import org.modg.bookshelf.ui.components.CoverPreloadNotice
import org.modg.bookshelf.ui.components.EmptyState import org.modg.bookshelf.ui.components.EmptyState
import org.modg.bookshelf.ui.components.BookshelfScaffold 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.PaperSurface
import org.modg.bookshelf.ui.components.PrimaryButton 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.SyncStatus
import org.modg.bookshelf.ui.components.SyncStatusBar 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 * SPEC.md "library" screen: adaptive cover grid, search, filter, sort, empty
* state, FAB to scan, sync status line. Covers carry the color; card chrome * state, FAB to scan, sync status line. Covers carry the color; card chrome
* stays minimal (cover + two lines of text) on purpose. * 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -68,9 +99,16 @@ fun LibraryScreen(
shelfIdFilter: String?, shelfIdFilter: String?,
onBookClick: (String) -> Unit, onBookClick: (String) -> Unit,
onScanClick: () -> Unit, onScanClick: () -> Unit,
onAddByHandClick: () -> Unit,
onLocationsClick: () -> Unit, onLocationsClick: () -> Unit,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
container: AppContainer, 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( val viewModel: LibraryViewModel = viewModel(
// A fresh VM per distinct shelf filter, so tapping a different shelf // A fresh VM per distinct shelf filter, so tapping a different shelf
@@ -83,6 +121,9 @@ fun LibraryScreen(
locationRepository = container.locationRepository, locationRepository = container.locationRepository,
syncEngine = container.syncEngine, syncEngine = container.syncEngine,
settingsStore = container.settingsStore, settingsStore = container.settingsStore,
bookSearchRepository = container.bookSearchRepository,
metadataRepository = container.metadataRepository,
coverDownloader = container.coverDownloader,
initialShelfId = shelfIdFilter, initialShelfId = shelfIdFilter,
) )
} }
@@ -93,6 +134,10 @@ fun LibraryScreen(
val query by viewModel.query.collectAsState() val query by viewModel.query.collectAsState()
val sortOption by viewModel.sortOption.collectAsState() val sortOption by viewModel.sortOption.collectAsState()
val filter by viewModel.filter.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( BookshelfScaffold(
title = "Bookshelf", title = "Bookshelf",
@@ -105,12 +150,26 @@ fun LibraryScreen(
} }
}, },
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = onScanClick) { // Scan stays the one primary action (it's the SPEC use case — a box of
Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book") // 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 = {
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 -> ) { innerPadding ->
PullToRefreshBox( PullToRefreshBox(
@@ -125,6 +184,7 @@ fun LibraryScreen(
LibraryToolbar( LibraryToolbar(
query = query, query = query,
onQueryChange = viewModel::onQueryChange, onQueryChange = viewModel::onQueryChange,
onSearchSubmit = viewModel::submitOnlineSearch,
sortOption = sortOption, sortOption = sortOption,
onSortOptionChange = viewModel::onSortOptionChange, onSortOptionChange = viewModel::onSortOptionChange,
filter = filter, filter = filter,
@@ -134,10 +194,29 @@ fun LibraryScreen(
) )
when { 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( !uiState.hasAnyBooks -> EmptyState(
title = "Your shelves are empty", title = "Your shelves are empty",
message = "Scan a barcode to add your first book.", 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( uiState.books.isEmpty() -> EmptyState(
title = "No books match", 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 @Composable
@@ -171,6 +271,7 @@ internal fun LibraryToolbar(
onFilterChange: (LibraryFilter) -> Unit, onFilterChange: (LibraryFilter) -> Unit,
bookcases: List<BookcaseEntity>, bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>, shelves: List<ShelfEntity>,
onSearchSubmit: () -> Unit = {},
) { ) {
var filterMenuExpanded by remember { mutableStateOf(false) } var filterMenuExpanded by remember { mutableStateOf(false) }
var sortMenuExpanded 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 { Column {
@@ -267,8 +372,8 @@ internal fun LibraryToolbar(
} }
@Composable @Composable
internal fun LibraryBookCard(book: BookEntity, onClick: () -> Unit) { internal fun LibraryBookCard(book: BookEntity, onClick: () -> Unit, modifier: Modifier = Modifier) {
Column(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) { Column(modifier = modifier.fillMaxWidth().clickable(onClick = onClick)) {
BookCover( BookCover(
coverUrl = book.coverUrl ?: book.coverSourceUrl, coverUrl = book.coverUrl ?: book.coverSourceUrl,
contentDescription = book.title, 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, SUCCESS,
FAILURE, FAILURE,
NO_SERVER, 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) data class SyncBarState(val status: SyncStatus, val label: String)
@@ -29,6 +32,7 @@ object LibrarySyncPresenter {
return when (lastOutcome) { return when (lastOutcome) {
SyncOutcome.FAILURE -> SyncBarState(SyncStatus.Error, "Sync failed — showing local library") SyncOutcome.FAILURE -> SyncBarState(SyncStatus.Error, "Sync failed — showing local library")
SyncOutcome.NO_SERVER -> SyncBarState(SyncStatus.Offline, "No server configured") 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) { SyncOutcome.SUCCESS, SyncOutcome.NONE -> if (lastSyncMillis != null) {
SyncBarState(SyncStatus.Synced, "Synced ${elapsedPhrase(nowMillis - lastSyncMillis)}") SyncBarState(SyncStatus.Synced, "Synced ${elapsedPhrase(nowMillis - lastSyncMillis)}")
} else { } else {
@@ -2,21 +2,38 @@ package org.modg.bookshelf.ui.library
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.modg.bookshelf.data.local.BookEntity import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.BookcaseEntity import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.data.metadata.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.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository 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.LocationRepository
import org.modg.bookshelf.data.repo.SyncEngine import org.modg.bookshelf.data.repo.SyncEngine
import org.modg.bookshelf.data.repo.SyncResult import org.modg.bookshelf.data.repo.SyncResult
@@ -37,7 +54,12 @@ class LibraryViewModel(
private val locationRepository: LocationRepository, private val locationRepository: LocationRepository,
private val syncEngine: SyncEngine, private val syncEngine: SyncEngine,
private val settingsStore: SettingsStore, private val settingsStore: SettingsStore,
private val bookSearchRepository: BookSearchRepository,
private val metadataRepository: MetadataRepository,
coverDownloader: CoverDownloader,
initialShelfId: String?, 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() { ) : ViewModel() {
private val _query = MutableStateFlow("") private val _query = MutableStateFlow("")
@@ -65,12 +87,32 @@ class LibraryViewModel(
.map { shelves -> shelves.groupBy({ it.bookcaseId }, { it.id }).mapValues { it.value.toSet() } } .map { shelves -> shelves.groupBy({ it.bookcaseId }, { it.id }).mapValues { it.value.toSet() } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) .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() } .map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true) .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( private val filteredSortedBooks: StateFlow<List<BookEntity>> = combine(
_query.flatMapLatest { bookRepository.search(it) }, localMatches,
_filter, _filter,
_sortOption, _sortOption,
shelfIdsByBookcase, shelfIdsByBookcase,
@@ -78,6 +120,92 @@ class LibraryViewModel(
LibrarySort.sort(LibraryFilterLogic.apply(books, filterValue, shelfMap), sort) LibrarySort.sort(LibraryFilterLogic.apply(books, filterValue, shelfMap), sort)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) }.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( private val syncBar: StateFlow<SyncBarState> = combine(
settingsStore.lastSyncTime, settingsStore.lastSyncTime,
_isSyncing, _isSyncing,
@@ -104,8 +232,16 @@ class LibraryViewModel(
refreshSync() 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) { fun onQueryChange(value: String) {
_query.value = value _query.value = value
if (_onlineSearchPhase.value !is OnlineSearchPhase.NotSearched) {
onlineSearchJob?.cancel()
_onlineSearchPhase.value = OnlineSearchPhase.NotSearched
}
} }
fun onSortOptionChange(option: LibrarySortOption) { fun onSortOptionChange(option: LibrarySortOption) {
@@ -116,6 +252,172 @@ class LibraryViewModel(
_filter.value = filter _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". */ /** Manual trigger — app start (see init) and pull-to-refresh, per SPEC "Sync design". */
fun refreshSync() { fun refreshSync() {
if (_isSyncing.value) return if (_isSyncing.value) return
@@ -125,6 +427,7 @@ class LibraryViewModel(
is SyncResult.Success -> _lastSyncOutcome.value = SyncOutcome.SUCCESS is SyncResult.Success -> _lastSyncOutcome.value = SyncOutcome.SUCCESS
is SyncResult.Failure -> _lastSyncOutcome.value = SyncOutcome.FAILURE is SyncResult.Failure -> _lastSyncOutcome.value = SyncOutcome.FAILURE
is SyncResult.Skipped -> _lastSyncOutcome.value = SyncOutcome.NO_SERVER is SyncResult.Skipped -> _lastSyncOutcome.value = SyncOutcome.NO_SERVER
is SyncResult.AuthExpired -> _lastSyncOutcome.value = SyncOutcome.AUTH_EXPIRED
} }
_isSyncing.value = false _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.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import org.modg.bookshelf.AppContainer import org.modg.bookshelf.AppContainer
import org.modg.bookshelf.ui.add.AddBookScreen
import org.modg.bookshelf.ui.detail.DetailScreen import org.modg.bookshelf.ui.detail.DetailScreen
import org.modg.bookshelf.ui.library.LibraryScreen 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.locations.LocationsScreen
import org.modg.bookshelf.ui.scan.ScanScreen import org.modg.bookshelf.ui.scan.ScanScreen
import org.modg.bookshelf.ui.settings.SettingsScreen import org.modg.bookshelf.ui.settings.SettingsScreen
@@ -52,9 +54,37 @@ fun BookshelfNavHost(
shelfIdFilter = backStackEntry.arguments?.getString(Routes.LIBRARY_SHELF_ARG), shelfIdFilter = backStackEntry.arguments?.getString(Routes.LIBRARY_SHELF_ARG),
onBookClick = { bookId -> navController.navigate(Routes.detail(bookId)) }, onBookClick = { bookId -> navController.navigate(Routes.detail(bookId)) },
onScanClick = { navController.navigate(Routes.SCAN) }, onScanClick = { navController.navigate(Routes.SCAN) },
onAddByHandClick = { navController.navigate(Routes.addBook()) },
onLocationsClick = { navController.navigate(Routes.LOCATIONS) }, onLocationsClick = { navController.navigate(Routes.LOCATIONS) },
onSettingsClick = { navController.navigate(Routes.SETTINGS) }, onSettingsClick = { navController.navigate(Routes.SETTINGS) },
onAuthExpired = {
navController.navigate(Routes.SETUP) {
popUpTo(navController.graph.id) { inclusive = true }
}
},
container = container, 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( composable(
@@ -70,6 +100,50 @@ fun BookshelfNavHost(
composable(Routes.SCAN) { composable(Routes.SCAN) {
ScanScreen( ScanScreen(
onBack = { navController.popBackStack() }, 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, container = container,
) )
} }
@@ -1,5 +1,7 @@
package org.modg.bookshelf.ui.nav package org.modg.bookshelf.ui.nav
import android.net.Uri
/** /**
* Route string constants for [BookshelfNavHost]. Fixed by the wave-3 screen * Route string constants for [BookshelfNavHost]. Fixed by the wave-3 screen
* contract shared with Worker E2 — do not rename or restructure these; E2's * 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 LOCATIONS = "locations"
const val SETTINGS = "settings" 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 libraryFilteredByShelf(shelfId: String) = "library?shelfId=$shelfId"
fun detail(bookId: String) = "detail/$bookId" 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." */ /** SPEC.md "Continuous mode: ... a running 'added this session' count." */
data class ScanSessionState(val savedCount: Int = 0) { data class ScanSessionState(val savedCount: Int = 0) {
fun withSave(): ScanSessionState = copy(savedCount = savedCount + 1) 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.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect 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.PrimaryButton
import org.modg.bookshelf.ui.components.SecondaryButton import org.modg.bookshelf.ui.components.SecondaryButton
import org.modg.bookshelf.ui.components.ShelfPickerSheet 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 * SPEC.md "scan" screen: camera + reticle, on-hit bottom sheet, continuous
@@ -80,6 +84,7 @@ import org.modg.bookshelf.ui.components.ShelfPickerSheet
@Composable @Composable
fun ScanScreen( fun ScanScreen(
onBack: () -> Unit, onBack: () -> Unit,
onEnterByHand: () -> Unit,
container: AppContainer, container: AppContainer,
) { ) {
val viewModel: ScanViewModel = viewModel( val viewModel: ScanViewModel = viewModel(
@@ -90,12 +95,15 @@ fun ScanScreen(
locationRepository = container.locationRepository, locationRepository = container.locationRepository,
metadataRepository = container.metadataRepository, metadataRepository = container.metadataRepository,
settingsStore = container.settingsStore, settingsStore = container.settingsStore,
coverDownloader = container.coverDownloader,
) )
} }
}, },
) )
val sheetState by viewModel.sheetState.collectAsState() 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 sessionState by viewModel.sessionState.collectAsState()
val rejectedMessage by viewModel.rejectedMessage.collectAsState() val rejectedMessage by viewModel.rejectedMessage.collectAsState()
val torchEnabled by viewModel.scannerController.torchEnabled.collectAsState() val torchEnabled by viewModel.scannerController.torchEnabled.collectAsState()
@@ -169,6 +177,10 @@ fun ScanScreen(
onShelfSelected = viewModel::selectShelf, onShelfSelected = viewModel::selectShelf,
onSave = { viewModel.save(state.isbn13, state.metadata) }, onSave = { viewModel.save(state.isbn13, state.metadata) },
onSkip = { viewModel.skip() }, onSkip = { viewModel.skip() },
cover = cover,
onRetryCover = viewModel::retryCover,
isSaving = saveStatus.isSaving,
saveError = saveStatus.error,
) )
} }
is ScanSheetState.NotFound -> ModalBottomSheet( is ScanSheetState.NotFound -> ModalBottomSheet(
@@ -205,6 +217,7 @@ fun ScanScreen(
ManualIsbnDialog( ManualIsbnDialog(
onDismiss = { showManualEntry = false }, onDismiss = { showManualEntry = false },
onSubmit = { isbn -> showManualEntry = false; viewModel.manualIsbnEntered(isbn) }, onSubmit = { isbn -> showManualEntry = false; viewModel.manualIsbnEntered(isbn) },
onEnterByHand = { showManualEntry = false; onEnterByHand() },
) )
} }
} }
@@ -327,6 +340,10 @@ internal fun FoundBookSheet(
onShelfSelected: (String?) -> Unit, onShelfSelected: (String?) -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
onSkip: () -> Unit, onSkip: () -> Unit,
cover: CoverPreloadState = CoverPreloadState.None,
onRetryCover: () -> Unit = {},
isSaving: Boolean = false,
saveError: String? = null,
) { ) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Box( Box(
@@ -359,9 +376,23 @@ internal fun FoundBookSheet(
recentShelfId = recentShelfId, recentShelfId = recentShelfId,
onShelfSelected = onShelfSelected, 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)) { Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
SecondaryButton(text = "Skip", onClick = onSkip, modifier = Modifier.weight(1f)) 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 @Composable
private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit) { private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit, onEnterByHand: () -> Unit) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
// Validate here rather than letting ScanViewModel.manualIsbnEntered drop an // Validate here rather than letting ScanViewModel.manualIsbnEntered drop an
// unparseable ISBN on the floor. A typed-in check digit is easy to get wrong, // 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), 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 = { confirmButton = {
@@ -2,6 +2,7 @@ package org.modg.bookshelf.ui.scan
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow 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.metadata.MetadataRepository
import org.modg.bookshelf.data.prefs.SettingsStore import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository 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.LocationRepository
/** /**
@@ -30,12 +34,21 @@ class ScanViewModel(
locationRepository: LocationRepository, locationRepository: LocationRepository,
private val metadataRepository: MetadataRepository, private val metadataRepository: MetadataRepository,
private val settingsStore: SettingsStore, private val settingsStore: SettingsStore,
coverDownloader: CoverDownloader,
val scannerController: ScannerController = ScannerController(), val scannerController: ScannerController = ScannerController(),
) : ViewModel() { ) : ViewModel() {
private val _sheetState = MutableStateFlow<ScanSheetState>(ScanSheetState.Hidden) private val _sheetState = MutableStateFlow<ScanSheetState>(ScanSheetState.Hidden)
val sheetState: StateFlow<ScanSheetState> = _sheetState.asStateFlow() 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()) private val _sessionState = MutableStateFlow(ScanSessionState())
val sessionState: StateFlow<ScanSessionState> = _sessionState.asStateFlow() val sessionState: StateFlow<ScanSessionState> = _sessionState.asStateFlow()
@@ -71,11 +84,30 @@ class ScanViewModel(
runLookup(isbn13) 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) _sheetState.value = ScanSheetState.Loading(isbn13)
val duplicate = DuplicateCheck.check(bookRepository.findByIsbn13(isbn13)) try {
val result = metadataRepository.lookup(isbn13) val duplicate = DuplicateCheck.check(bookRepository.findByIsbn13(isbn13))
_sheetState.value = ScanMetadataOutcome.from(isbn13, result, duplicate) 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. */ /** [ScanSheetState.LookupFailed]'s Retry — re-enters loading and re-runs the same lookup. */
@@ -113,27 +145,57 @@ class ScanViewModel(
viewModelScope.launch { performSave(isbn13, metadata) } viewModelScope.launch { performSave(isbn13, metadata) }
} }
fun retryCover() = coverPreload.retry()
/** /**
* The actual save-from-metadata logic, split out from [save] so tests can await it * 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. * 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 val shelfId = _selectedShelfId.value
bookRepository.createBook( try {
title = metadata.title ?: "Untitled", val cover = coverPreload.awaitSettled()
subtitle = metadata.subtitle, if (cover is CoverPreloadState.Failed && coverAtTap !is CoverPreloadState.Failed) {
authors = metadata.authors, _saveStatus.value = ScanSaveStatus()
isbn13 = metadata.isbn13 ?: isbn13, return false
isbn10 = metadata.isbn10, }
publisher = metadata.publisher, bookRepository.createBook(
publishedDate = metadata.publishedDate, title = metadata.title ?: "Untitled",
pageCount = metadata.pageCount, subtitle = metadata.subtitle,
description = metadata.description, authors = metadata.authors,
coverSourceUrl = metadata.coverUrl, isbn13 = metadata.isbn13 ?: isbn13,
shelfId = shelfId, 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) rememberShelf(shelfId)
recordSave() recordSave()
return true
} }
/** Save from the manual-entry form shown when metadata lookup misses (SPEC: pre-filled with the scanned ISBN). */ /** 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() 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?) { 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() { private fun recordSave() {
@@ -170,9 +243,15 @@ class ScanViewModel(
fun dismissSheet() { fun dismissSheet() {
_sheetState.value = ScanSheetState.Hidden _sheetState.value = ScanSheetState.Hidden
coverPreload.discard() // saved (createBook copied it) or skipped — either way, not needed
_saveStatus.value = ScanSaveStatus()
scannerController.resetDebounce() scannerController.resetDebounce()
} }
override fun onCleared() {
coverPreload.discard()
}
private companion object { private companion object {
/** How long a rejected-barcode message stays on screen before it auto-clears. */ /** How long a rejected-barcode message stays on screen before it auto-clears. */
const val REJECTED_MESSAGE_MILLIS = 3000L const val REJECTED_MESSAGE_MILLIS = 3000L
@@ -1,10 +1,16 @@
package org.modg.bookshelf.ui.settings 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -18,13 +24,19 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.launch
import org.modg.bookshelf.AppContainer import org.modg.bookshelf.AppContainer
import org.modg.bookshelf.diagnostics.CrashReport
import org.modg.bookshelf.ui.components.BookshelfScaffold import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.GoldDivider import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface import org.modg.bookshelf.ui.components.PaperSurface
@@ -50,12 +62,17 @@ fun SettingsScreen(
settingsStore = container.settingsStore, settingsStore = container.settingsStore,
bookRepository = container.bookRepository, bookRepository = container.bookRepository,
syncEngine = container.syncEngine, syncEngine = container.syncEngine,
crashReporter = container.crashReporter,
) )
} }
}, },
) )
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
var confirmSignOut by remember { mutableStateOf(false) } var confirmSignOut by remember { mutableStateOf(false) }
var showFullTrace by remember { mutableStateOf(false) }
val context = LocalContext.current
val clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
BookshelfScaffold( BookshelfScaffold(
title = "Settings", title = "Settings",
@@ -87,18 +104,45 @@ fun SettingsScreen(
SectionHeading("Sync") SectionHeading("Sync")
InfoRow(label = "Last synced", value = formatLastSync(state.lastSyncTime)) InfoRow(label = "Last synced", value = formatLastSync(state.lastSyncTime))
PrimaryButton( if (state.syncAuthExpired) {
text = if (state.isSyncing) "Syncing…" else "Sync now", // Not a "Sync failed" dead end: the fix is signing in again, so the
onClick = viewModel::syncNow, // button goes straight to setup instead of retrying the same sync.
enabled = !state.isSyncing, // Goes through onSignedOut directly, NOT viewModel.signOut(onSignedOut)
modifier = Modifier.padding(top = 8.dp), // — 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)) GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeading("Library") SectionHeading("Library")
InfoRow(label = "Books", value = state.bookCount.toString()) InfoRow(label = "Books", value = state.bookCount.toString())
InfoRow(label = "Covers", value = state.coverCount.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") } }, 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 @Composable
@@ -141,6 +257,7 @@ internal fun InfoRow(label: String, value: String) {
private fun syncLabel(state: SettingsUiState): String = when { private fun syncLabel(state: SettingsUiState): String = when {
state.isSyncing -> "Syncing…" state.isSyncing -> "Syncing…"
state.syncAuthExpired -> "Signed out — sign in again to sync"
state.syncError != null -> "Sync failed: ${state.syncError}" state.syncError != null -> "Sync failed: ${state.syncError}"
state.lastSyncTime != null -> "Synced • ${formatLastSync(state.lastSyncTime)}" state.lastSyncTime != null -> "Synced • ${formatLastSync(state.lastSyncTime)}"
else -> "Not synced yet" else -> "Not synced yet"
@@ -2,6 +2,7 @@ package org.modg.bookshelf.ui.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -13,6 +14,8 @@ import org.modg.bookshelf.data.repo.AuthRepository
import org.modg.bookshelf.data.repo.BookRepository import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.SyncEngine import org.modg.bookshelf.data.repo.SyncEngine
import org.modg.bookshelf.data.repo.SyncResult 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 import org.modg.bookshelf.ui.components.SyncStatus
data class SettingsUiState( data class SettingsUiState(
@@ -23,10 +26,13 @@ data class SettingsUiState(
val lastSyncTime: Long? = null, val lastSyncTime: Long? = null,
val isSyncing: Boolean = false, val isSyncing: Boolean = false,
val syncError: String? = null, val syncError: String? = null,
val syncAuthExpired: Boolean = false,
val latestCrash: CrashReport? = null,
) { ) {
val syncStatus: SyncStatus val syncStatus: SyncStatus
get() = when { get() = when {
isSyncing -> SyncStatus.Syncing isSyncing -> SyncStatus.Syncing
syncAuthExpired -> SyncStatus.AuthExpired
syncError != null -> SyncStatus.Error syncError != null -> SyncStatus.Error
lastSyncTime != null -> SyncStatus.Synced lastSyncTime != null -> SyncStatus.Synced
else -> SyncStatus.Offline else -> SyncStatus.Offline
@@ -47,10 +53,22 @@ class SettingsViewModel(
private val settingsStore: SettingsStore, private val settingsStore: SettingsStore,
private val bookRepository: BookRepository, private val bookRepository: BookRepository,
private val syncEngine: SyncEngine, private val syncEngine: SyncEngine,
private val crashReporter: CrashReporter,
) : ViewModel() { ) : ViewModel() {
private val isSyncing = MutableStateFlow(false) private val isSyncing = MutableStateFlow(false)
private val syncError = MutableStateFlow<String?>(null) 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( private val baseInfo = combine(
authRepository.serverUrl, authRepository.serverUrl,
@@ -67,23 +85,27 @@ class SettingsViewModel(
) )
} }
val uiState: StateFlow<SettingsUiState> = combine(baseInfo, isSyncing, syncError) { base, syncing, error -> val uiState: StateFlow<SettingsUiState> =
SettingsUiState( combine(baseInfo, isSyncing, syncError, syncAuthExpired, latestCrash) { base, syncing, error, authExpired, crash ->
serverUrl = base.serverUrl, SettingsUiState(
userEmail = base.userEmail, serverUrl = base.serverUrl,
bookCount = base.bookCount, userEmail = base.userEmail,
coverCount = base.coverCount, bookCount = base.bookCount,
lastSyncTime = base.lastSyncTime, coverCount = base.coverCount,
isSyncing = syncing, lastSyncTime = base.lastSyncTime,
syncError = error, isSyncing = syncing,
) syncError = error,
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) syncAuthExpired = authExpired,
latestCrash = crash,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
fun syncNow() { fun syncNow() {
if (isSyncing.value) return if (isSyncing.value) return
viewModelScope.launch { viewModelScope.launch {
isSyncing.value = true isSyncing.value = true
syncError.value = null syncError.value = null
syncAuthExpired.value = false
// SyncEngine never throws (SPEC: sync failure is a quiet status // SyncEngine never throws (SPEC: sync failure is a quiet status
// line, never a crash or a blocking dialog) — surface its result // line, never a crash or a blocking dialog) — surface its result
// as that quiet line, nothing more. // as that quiet line, nothing more.
@@ -91,6 +113,9 @@ class SettingsViewModel(
is SyncResult.Success -> Unit is SyncResult.Success -> Unit
is SyncResult.Skipped -> syncError.value = "No server configured yet." is SyncResult.Skipped -> syncError.value = "No server configured yet."
is SyncResult.Failure -> syncError.value = result.error.message ?: "Sync failed." 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 isSyncing.value = false
} }
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -46,6 +47,16 @@ class SetupViewModel(private val authRepository: AuthRepository) : ViewModel() {
.readTimeout(6, TimeUnit.SECONDS) .readTimeout(6, TimeUnit.SECONDS)
.build() .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) { fun onServerUrlChanged(value: String) {
_uiState.update { it.copy(serverUrl = value, urlError = null) } _uiState.update { it.copy(serverUrl = value, urlError = null) }
} }
@@ -1,11 +1,15 @@
package org.modg.bookshelf.data.metadata package org.modg.bookshelf.data.metadata
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test import org.junit.Test
/** /**
@@ -18,7 +22,7 @@ class GoogleBooksClientTest {
private val client = GoogleBooksClient(OkHttpClient(), Json) private val client = GoogleBooksClient(OkHttpClient(), Json)
@Test @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 body = fixture("googlebooks_success.json")
val result = client.parseResponse(body) val result = client.parseResponse(body)
@@ -31,11 +35,92 @@ class GoogleBooksClientTest {
assertEquals("9780134685991", result.isbn13) assertEquals("9780134685991", result.isbn13)
assertEquals("0134685997", result.isbn10) assertEquals("0134685997", result.isbn10)
assertEquals( 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, 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 @Test
fun `returns null when there are no items`() { fun `returns null when there are no items`() {
val body = fixture("googlebooks_no_items.json") val body = fixture("googlebooks_no_items.json")
@@ -157,6 +242,56 @@ class GoogleBooksClientTest {
assertEquals(clean, client.redact(clean)) 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 = private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" } checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
.bufferedReader() .bufferedReader()
@@ -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 package org.modg.bookshelf.data.metadata
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test import org.junit.Test
/** /**
* Parses checked-in sample JSON fixtures (src/test/resources/fixtures) — no network * Parses checked-in sample JSON fixtures (src/test/resources/fixtures) captured from
* involved, offline-safe. [OpenLibraryClient.parseResponse] is the unit under test; * the live Editions API on 2026-09-20 — no network involved, offline-safe.
* [OpenLibraryClient.lookup] itself is untested here because it requires a live socket. *
* 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 { class OpenLibraryClientTest {
private val client = OpenLibraryClient(OkHttpClient(), Json) private val client = OpenLibraryClient(OkHttpClient(), Json)
@Test @Test
fun `parses a successful response into BookMetadata`() { fun `parses an edition response into BookMetadata`() {
val body = fixture("openlibrary_success.json") val result = client.parseResponse(fixture("openlibrary_edition_gondolin.json"), "9781328613042")
val result = client.parseResponse(body, "9780201558029")
checkNotNull(result) checkNotNull(result)
assertEquals("Concrete Mathematics", result.title) assertEquals("The fall of Gondolin", result.title)
assertEquals("A Foundation for Computer Science", result.subtitle) assertEquals("Houghton Mifflin Harcourt", result.publisher)
assertEquals(listOf("Ronald L. Graham", "Donald E. Knuth"), result.authors) assertEquals("2018", result.publishedDate)
assertEquals("Addison-Wesley Professional", result.publisher) assertEquals(302, result.pageCount)
assertEquals("1994", result.publishedDate) assertEquals("9781328613042", result.isbn13)
assertEquals(672, result.pageCount) assertEquals("1328613046", result.isbn10)
assertEquals("9780201558029", result.isbn13) assertEquals("https://covers.openlibrary.org/b/id/12451486-L.jpg", result.coverUrl)
assertEquals("0201558025", result.isbn10)
assertEquals("https://covers.openlibrary.org/b/id/675832-L.jpg", result.coverUrl)
} }
/** /**
* The by-ISBN cover endpoint answers 200 with a 1x1 transparent GIF for editions * The legacy endpoint never returned a description, so this is new information —
* with no art, so synthesizing that URL here would hand the UI a cover that loads * on this record it arrives as a bare string.
* "successfully" and paints nothing. No `cover` object means no cover URL, which
* is what lets [MetadataMerger] fall through to Google Books' thumbnail.
*/ */
@Test @Test
fun `leaves coverUrl null when the response reports no cover art`() { fun `reads a description given as a bare string`() {
val body = """ val result = checkNotNull(
{"ISBN:9780201558029": {"title": "Concrete Mathematics", "publishers": [{"name": "AW"}]}} client.parseResponse(fixture("openlibrary_edition_gondolin.json"), "9781328613042"),
""".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"),
) )
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("Hittite warrior", hittite.title)
assertEquals(listOf("Joanne S. Williamson"), hittite.authors) assertEquals(listOf("Joanne S. Williamson"), hittite.authors)
assertEquals("Bethlehem Books", hittite.publisher) assertEquals("Bethlehem Books", hittite.publisher)
assertEquals(237, hittite.pageCount) assertEquals(237, hittite.pageCount)
assertEquals("1883937388", hittite.isbn10) assertEquals("1883937388", hittite.isbn10)
// No isbn_13 in the record — the ISBN we looked up has to carry through.
assertEquals("9781883937386", hittite.isbn13) assertEquals("9781883937386", hittite.isbn13)
assertEquals("https://covers.openlibrary.org/b/id/930599-L.jpg", hittite.coverUrl) assertEquals("https://covers.openlibrary.org/b/id/930599-L.jpg", hittite.coverUrl)
val shadowHawk = checkNotNull( val shadowHawk = (client.lookupOnce("9781883937676") as? SourceResult.Found)?.metadata
client.parseResponse(fixture("openlibrary_shadow_hawk.json"), "9781883937676"), checkNotNull(shadowHawk) { "expected Found for Shadow Hawk" }
)
assertEquals("Shadow hawk", shadowHawk.title) assertEquals("Shadow hawk", shadowHawk.title)
assertEquals(listOf("Andre Norton"), shadowHawk.authors) assertEquals(listOf("Andre Norton"), shadowHawk.authors)
assertEquals("9781883937676", shadowHawk.isbn13) assertEquals("9781883937676", shadowHawk.isbn13)
checkNotNull(shadowHawk.coverUrl) assertEquals("https://covers.openlibrary.org/b/id/930617-L.jpg", 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"))
} }
// --- classify(): the three-way per-source outcome (SPEC.md "Book metadata lookup"). --- // --- classify(): the three-way per-source outcome (SPEC.md "Book metadata lookup"). ---
@Test @Test
fun `classify reports Found for a 2xx response with a record`() { 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 val found = result as? SourceResult.Found
checkNotNull(found) { "expected Found, got $result" } 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 @Test
fun `classify reports NotFound for a 2xx response with no record for that isbn`() { fun `classify reports NotFound for a 404, because on this endpoint 404 is authoritative`() {
val result = client.classify(200, fixture("openlibrary_not_found.json"), "9780201558029") assertEquals(SourceResult.NotFound, client.classify(404, "<!DOCTYPE html><html>…", "9780201558029"))
assertEquals(SourceResult.NotFound, result)
} }
/**
* ...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 @Test
fun `classify reports Failed for a 404`() { fun `classify reports Failed for a 403, distinctly from NotFound`() {
val result = client.classify(404, null, "9780201558029") assertEquals(
assertEquals(SourceResult.Failed("http 404", FailureKind.CLIENT_ERROR), result) SourceResult.Failed("http 403", FailureKind.CLIENT_ERROR),
client.classify(403, null, "9780201558029"),
)
} }
@Test @Test
fun `classify reports Failed for a 429, distinctly from NotFound`() { fun `classify reports Failed for a 429, distinctly from NotFound`() {
val result = client.classify(429, null, "9780201558029") assertEquals(
assertEquals(SourceResult.Failed("http 429 (rate limited)", FailureKind.RATE_LIMITED), result) SourceResult.Failed("http 429 (rate limited)", FailureKind.RATE_LIMITED),
client.classify(429, null, "9780201558029"),
)
} }
@Test @Test
fun `classify reports Failed for a 500`() { fun `classify reports Failed for a 500`() {
val result = client.classify(500, "Internal Server Error", "9780201558029") assertEquals(
assertEquals(SourceResult.Failed("http 500 (server error)", FailureKind.SERVER_ERROR), result) SourceResult.Failed("http 500 (server error)", FailureKind.SERVER_ERROR),
client.classify(500, "Internal Server Error", "9780201558029"),
)
} }
@Test @Test
fun `classify reports Failed for a malformed body even on a 2xx status`() { fun `classify reports Failed for a malformed body even on a 2xx status`() {
val result = client.classify(200, fixture("malformed.json"), "9780201558029") assertEquals(
assertEquals(SourceResult.Failed("malformed json", FailureKind.MALFORMED), result) 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 = 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)) 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 --- // --- the loop ---
@Test @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()) 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 package org.modg.bookshelf.data.repo
import kotlinx.coroutines.CancellationException
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.ResponseBody.Companion.toResponseBody 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.PbListResponse
import org.modg.bookshelf.data.remote.PocketBaseApi import org.modg.bookshelf.data.remote.PocketBaseApi
import org.modg.bookshelf.data.remote.ShelfDto import org.modg.bookshelf.data.remote.ShelfDto
import org.modg.bookshelf.data.remote.UserRecordDto
import retrofit2.HttpException import retrofit2.HttpException
import retrofit2.Response import retrofit2.Response
import java.io.IOException
fun httpError(code: Int): HttpException { fun httpError(code: Int): HttpException {
val body = "{}".toResponseBody("application/json".toMediaType()) val body = "{}".toResponseBody("application/json".toMediaType())
@@ -37,6 +40,16 @@ class FakePocketBaseApi : PocketBaseApi {
val updateShelfErrors = mutableMapOf<String, HttpException>() val updateShelfErrors = mutableMapOf<String, HttpException>()
val updateBookErrors = 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) { fun seedBook(dto: BookDto) {
books[dto.id] = dto books[dto.id] = dto
} }
@@ -44,9 +57,19 @@ class FakePocketBaseApi : PocketBaseApi {
override suspend fun authWithPassword(body: AuthWithPasswordRequest): AuthResponse = override suspend fun authWithPassword(body: AuthWithPasswordRequest): AuthResponse =
AuthResponse(token = "fake-token") 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 ---- // ---- books ----
override suspend fun listBooks(filter: String?, sort: String, perPage: Int, page: Int): PbListResponse<BookDto> { 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 cursor = filter?.substringAfter("'")?.substringBefore("'")
val items = books.values.filter { cursor == null || it.updated > cursor }.sortedBy { it.updated } val items = books.values.filter { cursor == null || it.updated > cursor }.sortedBy { it.updated }
return PbListResponse(page = 1, totalPages = 1, totalItems = items.size, items = items) 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.room.Room
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -36,6 +41,11 @@ class SyncEngineTest {
.build() .build()
settingsStore = SettingsStore(ApplicationProvider.getApplicationContext()) settingsStore = SettingsStore(ApplicationProvider.getApplicationContext())
settingsStore.setServerUrl("https://example.test") 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() api = FakePocketBaseApi()
engine = SyncEngine( engine = SyncEngine(
apiProvider = { api }, apiProvider = { api },
@@ -198,4 +208,124 @@ class SyncEngineTest {
assertEquals("My local edit", db.bookDao().getByIdIncludingDeleted("book1")?.title) assertEquals("My local edit", db.bookDao().getByIdIncludingDeleted("book1")?.title)
assertEquals(SyncState.PENDING_UPDATE, db.bookDao().getByIdIncludingDeleted("book1")?.syncState) 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) 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 @Test
fun `no server configured is offline, not an error`() { fun `no server configured is offline, not an error`() {
val state = LibrarySyncPresenter.present(nowMillis = 1_000L, lastSyncMillis = null, isSyncing = false, lastOutcome = SyncOutcome.NO_SERVER) 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.room.Room
import androidx.test.core.app.ApplicationProvider 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.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith 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.local.BookshelfDatabase
import org.modg.bookshelf.data.metadata.BookMetadata import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.data.metadata.MetadataRepository import org.modg.bookshelf.data.metadata.MetadataRepository
import org.modg.bookshelf.data.prefs.SettingsStore import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository 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.modg.bookshelf.data.repo.LocationRepository
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config 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), * so it survives to the next scanning session — except "Not shelved" (null),
* which must never overwrite what's already remembered. * which must never overwrite what's already remembered.
*/ */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [34]) @Config(sdk = [34])
class ScanViewModelTest { class ScanViewModelTest {
@@ -35,10 +50,15 @@ class ScanViewModelTest {
private lateinit var viewModel: ScanViewModel private lateinit var viewModel: ScanViewModel
private lateinit var locationRepository: LocationRepository private lateinit var locationRepository: LocationRepository
private lateinit var shelfId: String private lateinit var shelfId: String
private lateinit var context: android.content.Context
private val coverHost = FakeCoverHost()
@Before @Before
fun setUp() = runTest { 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) db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
.allowMainThreadQueries() .allowMainThreadQueries()
.build() .build()
@@ -48,6 +68,7 @@ class ScanViewModelTest {
shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf") shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf")
viewModel = ScanViewModel( viewModel = ScanViewModel(
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
bookRepository = BookRepository(db.bookDao(), context), bookRepository = BookRepository(db.bookDao(), context),
locationRepository = locationRepository, locationRepository = locationRepository,
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }), metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
@@ -58,6 +79,7 @@ class ScanViewModelTest {
@After @After
fun tearDown() { fun tearDown() {
db.close() db.close()
Dispatchers.resetMain()
} }
@Test @Test
@@ -97,4 +119,165 @@ class ScanViewModelTest {
assertEquals(shelfId, settingsStore.lastShelfId.first()) 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons 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.QrCodeScanner
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Warehouse 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.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig import app.cash.paparazzi.DeviceConfig
@@ -24,17 +31,25 @@ import app.cash.paparazzi.Paparazzi
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.modg.bookshelf.data.local.BookEntity import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.metadata.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.BookshelfScaffold
import org.modg.bookshelf.ui.components.EmptyState import org.modg.bookshelf.ui.components.EmptyState
import org.modg.bookshelf.ui.components.PaperSurface import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.components.PrimaryButton 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.SyncStatus
import org.modg.bookshelf.ui.components.SyncStatusBar import org.modg.bookshelf.ui.components.SyncStatusBar
import org.modg.bookshelf.ui.library.LibraryBookCard import org.modg.bookshelf.ui.library.LibraryBookCard
import org.modg.bookshelf.ui.library.LibraryFilter import org.modg.bookshelf.ui.library.LibraryFilter
import org.modg.bookshelf.ui.library.LibraryScreen 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.LibrarySortOption
import org.modg.bookshelf.ui.library.LibraryToolbar 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 import org.modg.bookshelf.ui.theme.BookshelfTheme
/** /**
@@ -56,12 +71,162 @@ class LibraryScreenPaparazziTest {
@Test @Test
fun libraryEmptyLight() = snapshotBoth("library-empty") { Empty() } 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 @Composable
private fun Populated() = Shell(books = ScreenFixtures.books, syncStatus = SyncStatus.Synced, syncLabel = "Synced • 2m ago") private fun Populated() = Shell(books = ScreenFixtures.books, syncStatus = SyncStatus.Synced, syncLabel = "Synced • 2m ago")
@Composable @Composable
private fun Empty() = Shell(books = emptyList(), syncStatus = SyncStatus.Offline, syncLabel = "Not synced yet") 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 @Composable
private fun Shell(books: List<BookEntity>, syncStatus: SyncStatus, syncLabel: String) { private fun Shell(books: List<BookEntity>, syncStatus: SyncStatus, syncLabel: String) {
BookshelfScaffold( BookshelfScaffold(
@@ -71,7 +236,11 @@ class LibraryScreenPaparazziTest {
IconButton(onClick = {}) { Icon(Icons.Outlined.Settings, contentDescription = "Settings") } IconButton(onClick = {}) { Icon(Icons.Outlined.Settings, contentDescription = "Settings") }
}, },
floatingActionButton = { 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) }, syncStatusBar = { SyncStatusBar(status = syncStatus, label = syncLabel) },
) { innerPadding -> ) { innerPadding ->
@@ -91,7 +260,12 @@ class LibraryScreenPaparazziTest {
EmptyState( EmptyState(
title = "Your shelves are empty", title = "Your shelves are empty",
message = "Scan a barcode to add your first book.", 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 { } else {
LazyVerticalGrid( LazyVerticalGrid(
@@ -7,13 +7,17 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi import app.cash.paparazzi.Paparazzi
import java.io.File
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.modg.bookshelf.diagnostics.CrashReport
import org.modg.bookshelf.ui.components.BookshelfScaffold import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.GoldDivider import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface 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.SecondaryButton
import org.modg.bookshelf.ui.components.SyncStatus import org.modg.bookshelf.ui.components.SyncStatus
import org.modg.bookshelf.ui.components.SyncStatusBar 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.InfoRow
import org.modg.bookshelf.ui.settings.SectionHeading import org.modg.bookshelf.ui.settings.SectionHeading
import org.modg.bookshelf.ui.settings.formatLastSync 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 * SPEC.md "settings" screen — server, account (now showing the signed-in
* email per the wave-4 fix), sync status, book/cover counts. Rebuilds * email per the wave-4 fix), sync status, book/cover counts, and — added
* [org.modg.bookshelf.ui.settings.SettingsScreen]'s shell around its own real * 2026-09-12 alongside [org.modg.bookshelf.diagnostics.CrashReporter] — a
* [SectionHeading]/[InfoRow]/[formatLastSync], per the pattern in [ScreenFixtures]. * 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 { class SettingsScreenPaparazziTest {
@@ -40,11 +47,25 @@ class SettingsScreenPaparazziTest {
@Test @Test
fun settingsPopulatedLight() = snapshotBoth("settings-populated") { Populated() } fun settingsPopulatedLight() = snapshotBoth("settings-populated") { Populated() }
@Composable /** SPEC "an empty 'Diagnostics' section that looks broken is worse than none" — the no-crashes state. */
private fun Populated() = Shell() @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 @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 val lastSync = System.currentTimeMillis() - 2 * 60 * 1000L
BookshelfScaffold( BookshelfScaffold(
title = "Settings", title = "Settings",
@@ -52,7 +73,11 @@ class SettingsScreenPaparazziTest {
IconButton(onClick = {}) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") } IconButton(onClick = {}) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") }
}, },
syncStatusBar = { 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 -> ) { innerPadding ->
PaperSurface(modifier = Modifier.fillMaxWidth()) { PaperSurface(modifier = Modifier.fillMaxWidth()) {
@@ -70,18 +95,41 @@ class SettingsScreenPaparazziTest {
SectionHeading("Sync") SectionHeading("Sync")
InfoRow(label = "Last synced", value = formatLastSync(lastSync)) 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)) GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeading("Library") SectionHeading("Library")
InfoRow(label = "Books", value = ScreenFixtures.books.size.toString()) InfoRow(label = "Books", value = ScreenFixtures.books.size.toString())
InfoRow(label = "Covers", value = "0") 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) { private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } } paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { 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}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff. Show more