The cover one is the interesting bug. BookCover branched on
`painter.state`, but in coil3 that is a StateFlow<State>, not a State —
so every `is AsyncImagePainter.State.X` arm was always false. A `when`
used as a statement needs no else, so it compiled clean and drew
NOTHING: no cover, no placeholder, no error icon. That is why a
successfully looked-up book showed as a bare title floating in space.
Collect the flow before matching on it.
Two more cover defects sat behind that one:
- OpenLibraryDtos synthesized covers.openlibrary.org/b/isbn/{isbn}-L.jpg
unconditionally. For an edition with no art that URL answers 200 with a
43-byte 1x1 transparent GIF (verified against the live service), which
an image loader calls a successful load. So even with the state bug
fixed it would have painted an invisible cover and never fallen back.
Now the URL comes from OL's own `cover` object, which is present only
when art actually exists.
- Because that URL was never blank, MetadataMerger's "fill blanks from
the other source" rule could never reach Google Books' thumbnail. With
OL reporting null, the fallback works, and MetadataRepository adds the
by-ISBN URL as a genuine last resort — with `default=false`, so a miss
is a 404 the loader can report instead of a blank image.
The placeholder itself is now drawn in every non-success state (loading
included, where it previously drew an empty box) and reads as a book:
mahogany spine strip, gold hairline, letterpress panel.
Also:
- SyncStatusBar owns its navigation-bar inset and takes wider horizontal
padding, so it clears a phone's rounded display corners; it moves into
Scaffold's bottomBar slot so its height reaches the content padding.
- SetupScreen takes safeDrawingPadding outside verticalScroll, so the IME
shrinks the viewport instead of covering Password, plus Next/Next/Done
IME actions.
- Library card titles drop to a 20sp line height with the author given
its own 4dp gap: at titleSmall's 24sp leading a wrapped title left the
author closer to the last title line than the title lines were to each
other, so the byline read as part of the title.
- The scan sheet now names the ISBN it just read and says "Searching…",
so a decoded barcode is legible as decoded and the user can lower the
book instead of holding it to the camera at a bare spinner.
assembleDebug + assembleRelease exit 0; 106 unit tests, 0 failures;
verifyPaparazziDebug green against re-recorded snapshots.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDcPottghJXEvfYKqFM7zf
Bookshelf
A private, self-hosted home library app for two people. You scan the barcodes on your books; it looks up the metadata, stores it on your own server, and keeps both people's phones in sync. No cloud service, no public registration, no ads, no accounts you don't control.
The shared-library model
Bookshelf is built for exactly one household: two people who both want to know what's on the shelves and where. There's no concept of "my books" vs. "your books" — every book belongs to the one shared library, and either person can scan, edit, move, or delete anything in it.
- Self-hosted. You run a small PocketBase
server on your own hardware (a NUC, an old laptop, a Raspberry Pi — see
server/deploy/). Nobody else's data touches it, and it touches nobody else's. - Private by construction. The server has no public sign-up
(
createRule = nullon theuserscollection in PocketBase — only a superuser can create an account, viaserver/create-user.sh), and every read/write to books, shelves, and bookcases requires a logged-in user. - Two named accounts. Create one account per person with
server/create-user.sh. That's the whole user model — there is no admin UI, no roles, no invitations.
Architecture
Android app (Kotlin, Jetpack Compose, Material 3) talking to a PocketBase backend over HTTPS.
app/ Android Gradle project (org.modg.bookshelf)
server/ PocketBase schema, provisioning scripts, deploy docs
docs/ SPEC.md — the authoritative product/technical spec this was built from
Offline-first, Room-backed
Every screen reads from a local Room database, never directly from the network — the app has to be fully usable (browse, search, edit notes, move books between shelves) with the home server unreachable, which residential NAT/dynamic IP setups make a routine occurrence, not an edge case. Every write lands in Room first and is synced to the server later; nothing blocks on network I/O, and a sync failure surfaces as a quiet status line, never a crash or a blocking dialog.
Deletion is always a soft tombstone (deleted = true), never a hard delete,
on both the client and the server — so a delete on one phone propagates to
the other on next sync instead of just disappearing from one copy.
Sync: push-then-pull, last-write-wins
Each sync cycle (on app start, pull-to-refresh, and a ~6-hourly WorkManager job) does two passes, in this order:
- Push every locally-changed record (tracked via a
syncStatecolumn:PENDING_CREATE/PENDING_UPDATE/PENDING_DELETE) to PocketBase. New records use a client-generated 15-character id, sent as-is on create — PocketBase accepts client-supplied ids, so an id never has to be remapped after the fact. APENDING_DELETEis pushed as aPATCH {deleted: true}, never an actual record delete. - Pull everything changed on the server since the last-seen cursor
(
updated > cursor, paginated to exhaustion), so the two devices' changes reconcile in one direction after the local push.
Conflict rule: last-write-wins on the server's updated timestamp.
If both phones edit the same book while offline, whichever write reaches
the server later simply overwrites the earlier one — there is no merge, no
per-field reconciliation, and no conflict UI. This is a deliberate
simplification for a two-person household doing infrequent concurrent edits,
not a limitation either of you should expect to fight with day-to-day, but
it does mean a same-book edit race can silently lose one side's change.
Book covers get the same treatment as everything else: the app downloads
the cover from the metadata source and re-uploads it to PocketBase's own
cover file field, so the household's library doesn't rot when an external
cover URL eventually 404s. If offline, the cover is queued locally and
uploaded on the next sync.
Metadata lookup
Scanning a barcode looks the ISBN up against Open Library, falling back to Google Books if Open Library has nothing. The two results are merged (prefer whichever has a title; fill in blanks from the other); if both come up empty, the app offers manual entry pre-filled with the scanned ISBN instead of a dead end.
Building
Requirements: JDK 21, Android SDK (compileSdk/targetSdk 37, build-tools 37.0.0). No emulator is required or used in this project's own verification — see "Current limitations" below.
cd app
./gradlew assembleDebug # debug APK
./gradlew testDebugUnitTest # JVM unit tests (Robolectric + Paparazzi)
./gradlew recordPaparazziDebug # re-record screenshot goldens under src/test/snapshots
./gradlew assembleRelease # release APK — see "Signing" below
Signing a release build
app/app/build.gradle.kts reads signing credentials from
app/keystore.properties (gitignored, alongside the .jks keystore it
points at) if that file exists:
storeFile=release-keystore.jks
storePassword=...
keyAlias=bookshelf
keyPassword=...
Without that file, assembleRelease still succeeds — the release build type
simply comes out unsigned (debug-signed by AGP's defaults), so anyone who
clones this repo can build and run it without needing the household's actual
release key. Only the machine(s) that own keystore.properties produce an
APK you'd actually want to install permanently (Android treats a
signing-key change as a different app for update purposes, so hang on to
that keystore).
Deploying the server
See server/README.md for the schema and provisioning
scripts, and server/deploy/ for running PocketBase
long-term (systemd or Docker), reaching it from outside your home network
(Tailscale is the recommended option — no ports opened on your router, no
TLS cert management), and backups. In short:
cd server
./setup-schema.sh http://127.0.0.1:8090 <superuser-email> <superuser-password>
./create-user.sh you@example.com "a strong password" "Your Name"
./create-user.sh partner@example.com "a different strong password" "Partner Name"
Enter the server's URL (must be https://, unless it's Tailscale-only —
see the deploy README) on the app's first-run setup screen; it's never
hardcoded into the build.
Installing the APK
Build (or ask whoever holds the release keystore to build)
app/app/build/outputs/apk/release/app-release.apk, copy it to the phone,
and open it. Android will prompt to allow installs from that source the
first time. There's no Play Store listing — this app is not, and was never
meant to be, publicly distributed.
Current limitations
Read this before assuming more polish than exists:
- The app has never run on a physical device or emulator. This
environment has no KVM, so there is no Android emulator available.
Everything here was verified via
./gradlew assembleDebug,testDebugUnitTest(JVM/Robolectric unit tests), and Paparazzi screenshot rendering (src/test/snapshots/images/) — real logic paths (ISBN checksums, metadata merging, sync conflict resolution, DAO queries) are unit-tested, and every screen has been rendered to a static PNG in both light and dark theme, but nothing has been tap-tested on an actual screen. Camera/barcode scanning in particular has only been exercised through unit tests of the pure logic (IsbnBarcodeAnalyzer/ScanCodeFilter), never a live camera. - Sync has been round-tripped against a real PocketBase exactly once
(a live-server test covering auth, push with client-generated ids, pull,
last-write-wins, tombstones, and a byte-for-byte cover round-trip — see
server/live-sync-test.sh). It has not been exercised over an actual flaky residential connection, nor with two devices genuinely racing each other. - Conflict resolution is last-write-wins with no merge and no UI for it, as described above — acceptable for this app's scale, but worth knowing before relying on it under real concurrent edits.
- No Room foreign keys between books/shelves/bookcases (a deliberate
simplification) — an orphaned
shelfIdon a book is handled in queries, not prevented by the schema. - No automated instrumented/UI tests — only JVM unit tests and Paparazzi screenshots. There is no CI pipeline in this repo.