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.
83 lines
3.5 KiB
Markdown
83 lines
3.5 KiB
Markdown
# 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.
|