Files
thoughtsync/docs/android-distribution.md
T
Bryan Van Deusen ef8aa9340f
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 30s
clients: the server hands out five platforms, not "the Android client"
`client_dist.py` was written for one platform and everything structural in it was
already right — drop-in beats baked, the pair must describe one build, absence is
an ordinary answer, metadata public and bytes authenticated. This widens it to a
table rather than building beside it. Its own docstring made the argument years
before there was a second platform: a self-hoster should not need an account on
someone else's forge to get the app for their own notes.

Server side only. CI bakes nothing new until step 3 and the UI reads nothing new
until step 4, so this lands green and inert.

Five rows — android, linux-deb, linux-pacman, linux-appimage, windows — each
naming its artifact, sidecar and mimetype. Fixed filenames, version only in the
sidecar: a version-stamped name would force a glob, and a glob over a directory an
operator drops files into is how you serve the older of two builds, which is the
failure write-manifest.sh already carries a comment about.

THE ANDROID NAMES AND ROUTE DO NOT MOVE. The lane publishes those exact filenames,
clients in the field poll /api/client/android, and `android_client` stays on
/api/config beside the new `clients` map. Renaming them to match the pattern would
buy tidiness and strand every installed phone; retiring the key belongs to a later
change made when nothing polls it, not to the change introducing its replacement.
Fields were added, not moved — `ClientRelease` in core is a plain serde struct and
ignores what it does not know.

PRECEDENCE IS PER PLATFORM, which is the trap the table introduces. "First
directory holding anything wins" would mean dropping in an APK silently retracts
the four desktop downloads. Pinned by a test.

The AppImage needs a third file. It is the only bundle that replaces itself in
place, so the updater verifies a minisign signature before it does — and a bundle
that cannot be verified cannot be offered. A missing or empty `.sig` therefore
makes it absent rather than merely unsigned, and the signature travels WITH the
version so an updater can never pair one build's version with another's signature.

The tests parametrize over the table instead of testing Android and trusting the
rest. The bugs this module can have are not platform-specific, and a suite that
only exercised one platform is how the other four would ship untested.
2026-08-30 12:52:40 -04:00

4.7 KiB

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 comes from

Normally: nowhere. It is already in the image.

CI fetches the published Android build into every server image it builds, so :dev and :latest both ship a client. docker compose pull && docker compose up -d delivers a new server and a new client together, and there is nothing to copy.

The channel is a property of the image you run. A :dev image bakes in the dev-channel APK, :latest the stable one — so pointing a phone at a stable server gets it a stable client, with no second place holding that decision. (Until M314 step 3 the fetch was hard-wired to the dev release on every branch, so a stable server served a dev client.)

An image therefore carries the newest client on its channel rather than one pinned to a version. That is deliberate: the two negotiate a sync protocol version before they link, so a mismatch is caught by the handshake rather than by pinning.

Overriding it

If you want a specific build — testing something, or holding back — drop it in /var/thoughtsync/client/ and it wins over the image's copy.

That directory is shared with the desktop clients the server hands out, and precedence is decided per platform: dropping in an APK overrides the baked APK and leaves every other client alone. It is one directory, not one choice.

Two files, both required:

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 — and survives an image upgrade, which is the point of an override.

Both files are published to the rolling dev release on every green Android build:

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

# Copy the sidecar LAST. A sidecar that does not match the APK beside it is not a
# client, so a half-finished copy falls back to the image's build 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/

To go back to whatever the image ships, delete both files.

Checking it took

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

All of these describe an override in /var/thoughtsync/client/. A broken override does not take the feature away — it falls through to the build the image shipped with, which is the whole reason precedence runs in that direction.

  • Only the APK, no sidecar — not a client. The server cannot state a version it has no way to read.
  • Mismatched pair (new APK, old sidecar) — not a client. It will not serve one build while describing another.
  • Neither, and no baked copy either — the server reports no client, the UI hides the card, and /api/client/android returns 404. That is the state of any image built before the first Android build ever published, 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.