# 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 ./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.