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

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 = null on the users collection in PocketBase — only a superuser can create an account, via server/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:

  1. Push every locally-changed record (tracked via a syncState column: 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. A PENDING_DELETE is pushed as a PATCH {deleted: true}, never an actual record delete.
  2. 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

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 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:

  • No automated testing runs on a device. The app itself has been installed and used on a real phone since 2026-09-09, and several rounds of fixes came out of that; but this environment has no KVM, so there is no emulator, and every automated check is a JVM one. Verification is ./gradlew assembleDebug, testDebugUnitTest (JVM/Robolectric unit tests), and Paparazzi screenshot rendering (src/test/snapshots/images/) — real logic paths (ISBN checksums, metadata merging, sync conflict resolution, DAO queries) are unit-tested, and every screen is rendered to a static PNG in both light and dark theme. Camera/barcode scanning is covered only through unit tests of the pure logic (IsbnBarcodeAnalyzer/ScanCodeFilter); the live camera path is exercised by hand on a phone or not at all.
  • Sync has been round-tripped against a real PocketBase exactly once (a live-server test covering auth, push with client-generated ids, pull, last-write-wins, tombstones, and a byte-for-byte cover round-trip — see 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 shelfId on 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.
S
Description
Android app for keeping track of your books.
Readme
4 MiB
0 Stars 1 Watchers 0 Forks
Languages
Kotlin 92%
Shell 5.9%
JavaScript 2%
Dockerfile 0.1%