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
+6
View File
@@ -12,6 +12,7 @@ from quart.sessions import SecureCookieSessionInterface
from . import __version__
from .auth import bp as auth_bp
from .client_dist import advertisement as client_advertisement, bp as client_bp
from .config import Config
from .db import session_scope
from .graph import bp as graph_bp
@@ -71,6 +72,7 @@ def create_app() -> Quart:
app.register_blueprint(settings_bp)
app.register_blueprint(sync_bp)
app.register_blueprint(saved_filters_bp)
app.register_blueprint(client_bp)
@app.before_serving
async def _bootstrap() -> None:
@@ -113,6 +115,10 @@ def create_app() -> Quart:
# linking — while it still has no token and possibly no account — to decide
# whether it can talk to this server, and which optional features to offer.
data.update(protocol_advertisement())
# Which Android client this server can hand out, if any. Absent rather than
# null when it has none, so the web UI hides the download instead of
# offering a button that 404s.
data.update(client_advertisement())
return jsonify(data)
@app.get("/", defaults={"path": ""})
+135
View File
@@ -0,0 +1,135 @@
"""The server hands out the Android client it is in step with.
## Why the server, and not a release page
The Fabled-Git instance is private (issue 2091), so `install.sh` already cannot
fetch for anyone but the operator — and a self-hoster should not need an account
on someone else's forge to get the app for their own notes. The server they
already trust with the notes is the obvious place to get the client from.
It also keeps the two in step by construction. Client and server already
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.
## Where the file comes from
`DATA_DIR/client/` — the same volume that already holds attachments, so an
operator drops a build there once and container recreation does not lose it.
Deliberately NOT baked into the image: that would charge ~55 MiB to every
self-hoster, including everyone who never touches Android.
Two files, and both must be present:
- `thoughtsync.apk` — the client
- `thoughtsync-android.json` — `{version_name, version_code, size, sha256}`
The sidecar exists because an APK's version lives in a binary AXML manifest that
Python cannot read without the Android build tools. CI writes it beside the APK
at publish time, where the real values are already known.
## Absence is normal
A server with no APK advertises nothing, and the web UI hides the download
rather than offering a button that 404s. Same for a mismatched pair: if the
sidecar's recorded size does not match the file on disk, the two did not arrive
together and the server says it has nothing rather than serving one build while
describing another.
"""
from __future__ import annotations
import json
from pathlib import Path
from quart import Blueprint, jsonify, send_from_directory
from .auth import login_required
from .config import Config
APK_NAME = "thoughtsync.apk"
MANIFEST_NAME = "thoughtsync-android.json"
DOWNLOAD_PATH = "/api/client/android/download"
APK_MIMETYPE = "application/vnd.android.package-archive"
bp = Blueprint("client_dist", __name__)
def android_release() -> dict | None:
"""What Android build this server holds, or None if it holds none.
Never raises. A missing directory, an unreadable sidecar, malformed JSON and a
sidecar that describes a different file are all the same answer to the only
question being asked — "is there a client here I can honestly offer?" — and
that answer is no.
"""
root = Path(Config.client_root())
apk = root / APK_NAME
try:
size = apk.stat().st_size
meta = json.loads((root / MANIFEST_NAME).read_text(encoding="utf-8"))
version = str(meta["version_name"])
code = int(meta["version_code"])
recorded = int(meta["size"])
digest = str(meta["sha256"])
except (OSError, ValueError, TypeError, KeyError):
return None
# The pair has to describe one build. A sidecar left behind by a previous
# release would otherwise advertise a version this server cannot serve, and the
# phone would download something other than what it was promised.
if recorded != size:
return None
return {
"version": version,
# What Android actually compares. `version` is for people; a name is a
# string and sorts like one, which is not how "is this newer" works.
"version_code": code,
"size": size,
# Computed by CI over the same bytes it uploaded, so a client can tell a
# truncated download from a complete one BEFORE handing it to the
# installer. Not a trust anchor — the signature is that.
"sha256": digest,
"url": DOWNLOAD_PATH,
}
def advertisement() -> dict:
"""The `/api/config` fragment describing this server's Android client.
An empty dict when there is none, so the key is ABSENT rather than null — a
client testing for the key gets one unambiguous answer instead of having to
distinguish "no client" from "old server that never had this field".
"""
release = android_release()
return {"android_client": release} if release else {}
@bp.get("/api/client/android")
async def android_metadata():
"""Version and digest without the 55 MiB. What an updater polls."""
release = android_release()
if release is None:
return jsonify({"error": "this server has no Android client"}), 404
return jsonify(release)
@bp.get(DOWNLOAD_PATH)
@login_required
async def android_download():
"""The APK itself.
Authenticated — by session cookie from a browser, or by device bearer token
from a client updating itself; `login_required` accepts either. The metadata
above is public because a client has to be able to ask "is there something
newer?" cheaply, but the bytes are not for anyone who can reach the port.
"""
if android_release() is None:
return jsonify({"error": "this server has no Android client"}), 404
response = await send_from_directory(
Path(Config.client_root()), APK_NAME, mimetype=APK_MIMETYPE
)
# Without this some browsers try to render it, and Android's download handler
# wants a filename to hand to the package installer.
response.headers["Content-Disposition"] = f'attachment; filename="{APK_NAME}"'
return response
+11
View File
@@ -33,6 +33,17 @@ class Config:
def media_root(cls) -> Path:
return Path(cls.DATA_DIR) / "media"
@classmethod
def client_root(cls) -> Path:
"""Where the Android APK this server hands out lives.
Under DATA_DIR rather than baked into the image: the APK is ~55 MiB and an
install that never touches Android should not carry it. Being on the same
mounted volume as uploads also means an operator drops a build there once
and container recreation does not lose it. See client_dist.py.
"""
return Path(cls.DATA_DIR) / "client"
@classmethod
def secret_key_env(cls) -> str | None:
"""Optional break-glass override for the cookie-signing secret."""