From d77a79859ce0ed8882b2b42a61744bbf24387665 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 20 Aug 2026 20:11:32 -0400 Subject: [PATCH] server: hand out the Android client this server syncs with (2726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .forgejo/workflows/android.yml | 35 +++++++ desktop/packaging/publish-release.sh | 12 ++- docs/android-distribution.md | 82 ++++++++++++++++ frontend/src/stores/config.ts | 28 +++++- frontend/src/views/AccountView.vue | 39 ++++++++ src/thoughtsync/app.py | 6 ++ src/thoughtsync/client_dist.py | 135 +++++++++++++++++++++++++++ src/thoughtsync/config.py | 11 +++ tests/test_client_dist.py | 112 ++++++++++++++++++++++ 9 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 docs/android-distribution.md create mode 100644 src/thoughtsync/client_dist.py create mode 100644 tests/test_client_dist.py diff --git a/.forgejo/workflows/android.yml b/.forgejo/workflows/android.yml index ab86392..dfe0f68 100644 --- a/.forgejo/workflows/android.yml +++ b/.forgejo/workflows/android.yml @@ -173,6 +173,41 @@ jobs: apksigner="$(ls /opt/android-sdk/build-tools/*/apksigner | head -1)" "$apksigner" verify --print-certs "app/build/outputs/apk/release/app-release.apk" + # Staged with a STABLE name plus the sidecar the server reads its version + # out of — an APK keeps that in a binary manifest Python cannot parse, and + # `aapt` is not on a Quart server. Computed here, where the real values are + # already known. + - name: Stage the client for distribution + if: steps.build.outputs.keystore != '' + run: | + mkdir -p dist + cp "app/build/outputs/apk/release/app-release.apk" dist/thoughtsync.apk + size="$(wc -c < dist/thoughtsync.apk | tr -d ' ')" + sha="$(sha256sum dist/thoughtsync.apk | cut -d' ' -f1)" + cat > dist/thoughtsync-android.json <&2 + echo "ERROR: nothing to publish — no desktop bundles under $BUNDLE_ROOT and no APK under $REPO_ROOT/android/dist." >&2 exit 1 fi echo "==> Publishing release $TAG with ${#ASSETS[@]} asset(s):" @@ -113,7 +121,7 @@ fi echo "==> Creating release for $TAG" BODY=$(cat <