Files
bookshelf/README.md
T
Spriteandclaude 5455df2d61 Wave 4 (F3): settings email, five screens' screenshots, release signing, README
Completes wave 4. Verified by the orchestrator, not self-reported:
assembleDebug / testDebugUnitTest / assembleRelease all exit 0;
102 tests, 1 skipped, 0 failures, 0 errors.

- Settings showed the PocketBase user id instead of the signed-in email,
  because login never persisted the email. AuthRepository now writes it to
  SettingsStore on success and sign-out clears it; SettingsUiState carries
  userEmail in place of userId. AuthRepositoryTest asserts both directions.
- Paparazzi coverage for the five screens library was missing: setup, detail,
  scan, locations, settings, each light + dark, populated rather than empty.
  Scan cannot show a live camera under Paparazzi, so its tests render the
  reticle overlay and the result bottom sheet over a static backdrop.
- Release signing via an optional gitignored app/keystore.properties. Without
  it assembleRelease still works and comes out debug-signed, so the build is
  not owner-only. R8 deliberately left off; nothing has proven Room, Retrofit,
  kotlinx-serialization and ML Kit survive it.
- Top-level README: shared-library model, offline-first architecture, the
  push-then-pull last-write-wins conflict rule SPEC requires be documented
  here, build/deploy/install steps, and honest current limitations.

The signed APK and the keystore are intentionally not committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TyzeWmdTqi7U85iYNGy7P
2026-09-08 21:28:11 +00:00

180 lines
8.3 KiB
Markdown

# 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](https://pocketbase.io/)
server on your own hardware (a NUC, an old laptop, a Raspberry Pi — see
[`server/deploy/`](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](https://developer.android.com/training/data-storage/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](https://openlibrary.org/dev/docs/api/books), falling back to
[Google Books](https://developers.google.com/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.
```sh
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:
```properties
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`](server/README.md) for the schema and provisioning
scripts, and [`server/deploy/`](server/deploy/) for running PocketBase
long-term (systemd or Docker), reaching it from outside your home network
(Tailscale is the recommended option — no ports opened on your router, no
TLS cert management), and backups. In short:
```sh
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 `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.