M12 — the Android client, end to end #2
@@ -136,6 +136,43 @@ jobs:
|
||||
docker system prune -af || true
|
||||
docker builder prune --keep-storage 5g -f || true
|
||||
|
||||
# Bake the Android client in, on EVERY image build, so :dev, :latest and
|
||||
# :<version> all carry one and a `docker compose pull` delivers a new client
|
||||
# along with the new server.
|
||||
#
|
||||
# Always the rolling `dev` release — the newest build there is. A versioned
|
||||
# image therefore carries the newest client rather than one pinned to that
|
||||
# version; the two negotiate a sync protocol version before linking, so
|
||||
# "newest" is safe in a way "matching" would not buy anything over.
|
||||
#
|
||||
# Fetched by the JOB, not by the Dockerfile: the release is private, and a
|
||||
# token used inside a build lands in the context or a layer.
|
||||
#
|
||||
# NEVER fails the build. An image with no Android client advertises none and
|
||||
# hides the download — a supported state, and the only one available before
|
||||
# the first Android build has ever published.
|
||||
- name: Fetch the Android client to bake in
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
mkdir -p client
|
||||
base="${{ github.server_url }}/${{ github.repository }}/releases/download/dev"
|
||||
ok=1
|
||||
for f in thoughtsync.apk thoughtsync-android.json; do
|
||||
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
|
||||
done
|
||||
if [ "$ok" = 1 ]; then
|
||||
echo "Baking in:"
|
||||
cat client/thoughtsync-android.json
|
||||
ls -l client/thoughtsync.apk
|
||||
else
|
||||
# Both or neither. Half a pair is worse than none: the server would
|
||||
# read a sidecar describing an APK that isn't there, or an APK it
|
||||
# cannot state a version for.
|
||||
echo "::warning::No Android client on the dev release — this image ships without one."
|
||||
rm -f client/thoughtsync.apk client/thoughtsync-android.json
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
|
||||
@@ -205,3 +205,9 @@ android/local.properties
|
||||
*.keystore
|
||||
*.p12
|
||||
*.b64
|
||||
|
||||
# The Android client CI bakes into the server image. Fetched fresh on every image
|
||||
# build, so it is never worth 55 MiB of git history. client/.keep IS tracked, so
|
||||
# the Dockerfile's COPY always has a directory to copy.
|
||||
client/thoughtsync.apk
|
||||
client/thoughtsync-android.json
|
||||
|
||||
+13
@@ -24,6 +24,19 @@ COPY --from=build-frontend /build/dist/ src/thoughtsync/static/
|
||||
COPY alembic.ini .
|
||||
COPY alembic/ alembic/
|
||||
|
||||
# The Android client this server hands out. CI fetches the newest published build
|
||||
# into ./client immediately before this runs (ci.yml), so every image tag — :dev,
|
||||
# :latest and :<version> alike — ships a client, and a `docker compose pull`
|
||||
# delivers a new one with no file copying by hand.
|
||||
#
|
||||
# Fetched by the JOB rather than here on purpose: the release is private, and a
|
||||
# token used inside a build ends up in the build context or a layer.
|
||||
#
|
||||
# The directory is tracked (client/.keep) so this COPY cannot fail on a tree where
|
||||
# that step never ran. An image with no APK is a supported state — the server
|
||||
# advertises nothing and the web UI hides the download (client_dist.py).
|
||||
COPY client/ src/thoughtsync/client/
|
||||
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
ARG BUILD_VERSION=dev
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
CI drops the Android client here on every image build, and the Dockerfile copies
|
||||
the directory into the image (see ci.yml "Fetch the Android client to bake in").
|
||||
|
||||
This file exists so the directory does too. `COPY client/ ...` fails outright on a
|
||||
missing source, which would break every local `docker build` on a tree that has
|
||||
never run that CI step — and an image with no Android client is a supported
|
||||
state, not an error.
|
||||
|
||||
The artifacts themselves are gitignored: a 55 MiB binary does not belong in git
|
||||
history, and it is fetched fresh anyway.
|
||||
@@ -11,28 +11,42 @@ It also keeps the two in step: client and server negotiate a sync protocol versi
|
||||
before linking, so a server that serves the client cannot hand out a phone it
|
||||
cannot talk to.
|
||||
|
||||
## Where it goes
|
||||
## Where it comes from
|
||||
|
||||
Two files, both required, in `/var/thoughtsync/client/`:
|
||||
**Normally: nowhere. It is already in the image.**
|
||||
|
||||
CI fetches the newest published Android build into every server image it builds,
|
||||
so `:dev`, `:latest` and `:<version>` all 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.
|
||||
|
||||
A versioned image therefore carries the *newest* client rather than one pinned to
|
||||
that 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.
|
||||
|
||||
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.
|
||||
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
|
||||
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. From the machine running the server:
|
||||
build:
|
||||
|
||||
```sh
|
||||
REPO=https://git.fabledsword.com/bvandeusen/thoughtsync
|
||||
@@ -43,14 +57,14 @@ for f in thoughtsync.apk thoughtsync-android.json; do
|
||||
-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.
|
||||
# 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/
|
||||
```
|
||||
|
||||
`docker compose cp` creates `/var/thoughtsync/client/` if it does not exist.
|
||||
To go back to whatever the image ships, delete both files.
|
||||
|
||||
## Checking it took
|
||||
|
||||
@@ -64,14 +78,18 @@ 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.
|
||||
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
|
||||
|
||||
|
||||
@@ -13,10 +13,19 @@ 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 places, checked in that order:
|
||||
|
||||
1. `DATA_DIR/client/` — the mounted volume that already holds attachments. An
|
||||
operator who wants a SPECIFIC build drops it there and it wins.
|
||||
2. the copy baked into the image at build time — CI fetches the newest published
|
||||
Android build into every image, so `:dev`, `:latest` and `:<version>` all
|
||||
carry a client and `docker compose pull` delivers a new one with nothing
|
||||
copied by hand.
|
||||
|
||||
The precedence is the point: the image is the default, and a person who wants to
|
||||
override it should not have to fight it. The baked copy sits inside the package
|
||||
rather than under DATA_DIR because DATA_DIR is a volume mount, and anything the
|
||||
image wrote there would be hidden the moment one is attached.
|
||||
|
||||
Two files, and both must be present:
|
||||
|
||||
@@ -51,21 +60,23 @@ MANIFEST_NAME = "thoughtsync-android.json"
|
||||
DOWNLOAD_PATH = "/api/client/android/download"
|
||||
APK_MIMETYPE = "application/vnd.android.package-archive"
|
||||
|
||||
# The copy CI bakes into the image. Inside the package, NOT under DATA_DIR: that
|
||||
# is a volume mount, and a file the image wrote there would vanish behind it.
|
||||
BAKED_ROOT = Path(__file__).resolve().parent / "client"
|
||||
|
||||
bp = Blueprint("client_dist", __name__)
|
||||
|
||||
|
||||
def android_release() -> dict | None:
|
||||
"""What Android build this server holds, or None if it holds none.
|
||||
def _read(root: Path) -> dict | None:
|
||||
"""The build in one directory, or 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
|
||||
size = (root / APK_NAME).stat().st_size
|
||||
meta = json.loads((root / MANIFEST_NAME).read_text(encoding="utf-8"))
|
||||
version = str(meta["version_name"])
|
||||
code = int(meta["version_code"])
|
||||
@@ -94,6 +105,27 @@ def android_release() -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
def _resolve() -> tuple[Path, dict] | None:
|
||||
"""Which directory this server serves from, and what is in it.
|
||||
|
||||
The operator's drop-in beats the baked copy — someone who deliberately put a
|
||||
build on the volume wants that build, not whatever the image happened to ship
|
||||
with. A directory holding a broken or half-copied pair does NOT shadow the
|
||||
image: it simply is not a client, so the search moves on.
|
||||
"""
|
||||
for root in (Path(Config.client_root()), BAKED_ROOT):
|
||||
release = _read(root)
|
||||
if release is not None:
|
||||
return root, release
|
||||
return None
|
||||
|
||||
|
||||
def android_release() -> dict | None:
|
||||
"""What Android build this server holds, or None if it holds none."""
|
||||
resolved = _resolve()
|
||||
return resolved[1] if resolved else None
|
||||
|
||||
|
||||
def advertisement() -> dict:
|
||||
"""The `/api/config` fragment describing this server's Android client.
|
||||
|
||||
@@ -124,11 +156,13 @@ async def android_download():
|
||||
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:
|
||||
resolved = _resolve()
|
||||
if resolved 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
|
||||
)
|
||||
# From the SAME directory the advertisement came from, or a drop-in appearing
|
||||
# between the two calls would serve bytes the metadata does not describe.
|
||||
root, _ = resolved
|
||||
response = await send_from_directory(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}"'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from thoughtsync import client_dist
|
||||
from thoughtsync.app import create_app
|
||||
from thoughtsync.client_dist import APK_NAME, MANIFEST_NAME, advertisement, android_release
|
||||
from thoughtsync.config import Config
|
||||
@@ -18,14 +20,27 @@ from thoughtsync.config import Config
|
||||
PAYLOAD = b"not really an apk, but the server only ever stats it"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _empty_baked_client(tmp_path, monkeypatch):
|
||||
"""Point the baked-in copy at an empty directory.
|
||||
|
||||
In a source checkout `src/thoughtsync/client/` does not exist, so these tests
|
||||
would pass anyway — but only by accident of where they are run. A built image
|
||||
has a real APK there, and a test that silently depends on which tree it is in
|
||||
is one that will eventually lie.
|
||||
"""
|
||||
monkeypatch.setattr(client_dist, "BAKED_ROOT", tmp_path / "baked")
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_app()
|
||||
|
||||
|
||||
def place_client(payload: bytes = PAYLOAD, **overrides) -> dict:
|
||||
def place_client(payload: bytes = PAYLOAD, root: Path | None = None, **overrides) -> dict:
|
||||
"""Put a client + sidecar where the server looks. Overrides corrupt the pair."""
|
||||
root = Config.client_root()
|
||||
root = root if root is not None else Config.client_root()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / APK_NAME).write_bytes(payload)
|
||||
meta = {
|
||||
@@ -110,3 +125,30 @@ async def test_the_bytes_need_authentication_even_though_the_version_does_not(ap
|
||||
place_client()
|
||||
resp = await app.test_client().get("/api/client/android/download")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_the_baked_in_copy_is_used_when_nothing_was_dropped_in():
|
||||
"""The ordinary case for a self-hoster who just pulled the image."""
|
||||
place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300)
|
||||
assert android_release()["version_code"] == 300
|
||||
|
||||
|
||||
def test_a_dropped_in_build_beats_the_one_the_image_shipped():
|
||||
"""Someone who deliberately put a build on the volume wants that build."""
|
||||
place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300)
|
||||
place_client(version_name="0.1.99", version_code=99)
|
||||
advertised = android_release()
|
||||
# Lower version and all — precedence is about intent, not about newness. An
|
||||
# operator pinning an older client is doing it on purpose.
|
||||
assert advertised["version_code"] == 99
|
||||
|
||||
|
||||
def test_a_broken_drop_in_does_not_shadow_the_baked_copy():
|
||||
"""A half-finished copy onto the volume must not take the app offline.
|
||||
|
||||
This is the failure the copy-order advice in docs/android-distribution.md is
|
||||
about, and the server should ride it out rather than go dark.
|
||||
"""
|
||||
place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300)
|
||||
place_client(size=999_999) # sidecar describing a different build
|
||||
assert android_release()["version_code"] == 300
|
||||
|
||||
Reference in New Issue
Block a user