server: hand out the Android client this server syncs with (2726)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 5m32s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 8m8s

A self-hoster should not need an account on someone else's forge to get the app
for their own notes. The Fabled-Git instance is private — which is why
`install.sh` already cannot fetch for anyone but the operator — so a release page
is no use as a distribution point. The server holding the notes is something the
person already trusts and already reaches.

It also keeps the pair in step by construction. Client and server negotiate a
sync protocol version before linking, so a server that also serves the client
cannot hand out a phone it is unable to talk to.

**Two files, and both must be present**: `thoughtsync.apk` and a
`thoughtsync-android.json` sidecar carrying `{version_name, version_code, size,
sha256}`. The sidecar exists because an APK keeps its version in a binary AXML
manifest, which Python cannot read and which is not worth putting `aapt` on a
Quart server to reach. CI writes it beside the APK, where the values are already
known — including the digest, computed over the same bytes it uploads, so a
phone can tell a truncated download from a complete one before handing it to the
installer. Not a trust anchor; the signature is that.

**Under DATA_DIR, not baked into the image.** Baking charges ~55 MiB to every
self-hoster including everyone who never touches Android. `/var/thoughtsync` is
already the mounted volume that holds attachments, so a build dropped there
survives container recreation.

**Absence is an ordinary state, not an error.** No APK means the key is absent
from `/api/config` — absent rather than null, so a client testing for it cannot
confuse "this server has no client" with "this server predates the field" — the
web UI hides the card instead of offering a button that 404s, and the metadata
route answers 404. A server whose owner does not use Android is not misconfigured.

**A mismatched pair also counts as no client.** If the sidecar's recorded size
does not match the file on disk, the two did not arrive together; serving one
build while advertising another is worse than serving none, because the phone
would compare versions against a promise the bytes do not keep. That makes the
copy order in docs/android-distribution.md load-bearing, and it is written down
there: APK first, sidecar last.

**The version is public, the bytes are not.** An updater has to be able to ask
"is there something newer?" cheaply and before it has done anything; 55 MiB is
not for anyone who can reach the port. `login_required` already accepts either a
session cookie or a device bearer token, so the browser and a linked phone both
work with no second auth path.

The Android lane now publishes both files to the same rolling `dev` release the
desktop bundles use, reusing `publish-release.sh` — its nullglob asset list was
already built for several jobs in separate workspaces publishing to one release,
which is exactly this. Signed builds only: publishing an unsigned APK would offer
people something they cannot install over what they already have.

Nine tests, DB-free like the rest of the suite — this lane runs no Postgres, so
the advertisement is asserted through `advertisement()` rather than through
`/api/config`, whose other half needs a database. Both routes ARE exercised,
because neither opens a session.
This commit is contained in:
2026-08-20 20:11:32 -04:00
parent 6589be2b0f
commit d77a79859c
9 changed files with 457 additions and 3 deletions
+82
View File
@@ -0,0 +1,82 @@
# Getting the Android app onto your server
ThoughtSync's server hands out the Android client it syncs with. Once an APK is in
place, anyone with an account on that server can download it from **Account →
Linked devices**, and linked phones can update themselves from it.
This is deliberate rather than incidental. The build is not on an app store and the
Fabled-Git instance is private, so a release page is no use to a self-hoster — but
the server holding their notes is something they already trust and already reach.
It also keeps the two in step: client and server negotiate a sync protocol version
before linking, so a server that serves the client cannot hand out a phone it
cannot talk to.
## Where it goes
Two files, both required, in `/var/thoughtsync/client/`:
| File | What it is |
| --- | --- |
| `thoughtsync.apk` | the client |
| `thoughtsync-android.json` | `{version_name, version_code, size, sha256}` |
The sidecar exists because an APK keeps its version in a binary manifest that needs
the Android build tools to read. CI writes it beside the APK, where the real values
are already known.
`/var/thoughtsync` is the same volume that holds attachments (`Config.DATA_DIR`),
so a build dropped there survives container recreation. Nothing is baked into the
image: the APK is ~55 MiB and an install that never touches Android should not
carry it.
## Putting a build there
Both files are published to the rolling `dev` release on every green Android
build. From the machine running the server:
```sh
REPO=https://git.fabledsword.com/bvandeusen/thoughtsync
TOKEN=... # a Fabled-Git token with read access; the instance is private
for f in thoughtsync.apk thoughtsync-android.json; do
curl -fsSL -H "Authorization: token $TOKEN" \
-o "/tmp/$f" "$REPO/releases/download/dev/$f"
done
# Into the app container's volume. Copy the sidecar LAST: the server treats a
# sidecar that does not match the APK beside it as "no client at all", so a
# half-finished copy advertises nothing rather than advertising a lie.
docker compose cp /tmp/thoughtsync.apk app:/var/thoughtsync/client/
docker compose cp /tmp/thoughtsync-android.json app:/var/thoughtsync/client/
```
`docker compose cp` creates `/var/thoughtsync/client/` if it does not exist.
## Checking it took
```sh
curl -s http://localhost:5000/api/client/android
```
A server with a client answers with the version, size and digest. A server without
one answers `404` — and the download card in the web UI is hidden rather than
offering a button that fails.
## What happens if you get it wrong
- **Only the APK, no sidecar** — the server reports no client. It cannot state a
version it has no way to read.
- **Mismatched pair** (new APK, old sidecar) — the server reports no client,
because the recorded size does not match the file. It will not serve one build
while describing another.
- **Neither** — the server reports no client, the UI hides the card, and
`/api/client/android` returns 404. This is the ordinary state of a server whose
owner does not use Android, and nothing about it is an error.
## Signing, and why replacing the APK is safe
Every release build is signed with the same key, so a phone can install a newer one
straight over the old one and keep its notes. That was not true before August 2026
— builds until then were signed with a throwaway key per CI run, and each install
required uninstalling the last (Scribe #2803). If you are carrying a build from
before that, expect to uninstall once more and sync anything you care about first.