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
209 lines
9.9 KiB
Markdown
209 lines
9.9 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
|
|
|
|
### 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
|
|
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:
|
|
|
|
- **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.
|