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
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:
@@ -0,0 +1,112 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from thoughtsync.app import create_app
|
||||
from thoughtsync.client_dist import APK_NAME, MANIFEST_NAME, advertisement, android_release
|
||||
from thoughtsync.config import Config
|
||||
|
||||
# DB-free, like the rest of this suite — the test lane runs no Postgres. That is
|
||||
# why the advertisement is asserted through `advertisement()` rather than through
|
||||
# `/api/config`: the route is a one-line merge of this dict into a payload whose
|
||||
# other half needs a database, and testing it here tests the part that can be wrong.
|
||||
#
|
||||
# The two routes below ARE exercised, because neither opens a session: the metadata
|
||||
# route only stats files, and the download's 401 is returned before any token
|
||||
# lookup.
|
||||
|
||||
PAYLOAD = b"not really an apk, but the server only ever stats it"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_app()
|
||||
|
||||
|
||||
def place_client(payload: bytes = PAYLOAD, **overrides) -> dict:
|
||||
"""Put a client + sidecar where the server looks. Overrides corrupt the pair."""
|
||||
root = Config.client_root()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / APK_NAME).write_bytes(payload)
|
||||
meta = {
|
||||
"version_name": "0.1.216",
|
||||
"version_code": 216,
|
||||
"size": len(payload),
|
||||
"sha256": "ab" * 32,
|
||||
}
|
||||
meta.update(overrides)
|
||||
(root / MANIFEST_NAME).write_text(json.dumps(meta), encoding="utf-8")
|
||||
return meta
|
||||
|
||||
|
||||
def test_absent_client_is_advertised_as_nothing_at_all():
|
||||
"""The KEY is missing, not null.
|
||||
|
||||
A client testing for it then gets one unambiguous answer rather than having to
|
||||
tell "this server has no APK" apart from "this server predates the feature".
|
||||
"""
|
||||
assert android_release() is None
|
||||
assert advertisement() == {}
|
||||
|
||||
|
||||
def test_a_present_client_is_advertised_with_what_android_compares():
|
||||
place_client()
|
||||
advertised = advertisement()["android_client"]
|
||||
assert advertised["version"] == "0.1.216"
|
||||
# The integer is what decides "is this newer", not the name — a name is a
|
||||
# string and sorts like one.
|
||||
assert advertised["version_code"] == 216
|
||||
assert advertised["size"] == len(PAYLOAD)
|
||||
assert advertised["url"].endswith("/download")
|
||||
|
||||
|
||||
def test_a_sidecar_describing_a_different_build_counts_as_no_client():
|
||||
"""The likeliest real corruption: a new APK copied over an old sidecar.
|
||||
|
||||
Serving one build while advertising another is worse than serving none — the
|
||||
phone would compare versions against a promise the bytes do not keep.
|
||||
"""
|
||||
place_client(size=999_999)
|
||||
assert android_release() is None
|
||||
assert advertisement() == {}
|
||||
|
||||
|
||||
def test_an_unreadable_sidecar_counts_as_no_client():
|
||||
place_client()
|
||||
(Config.client_root() / MANIFEST_NAME).write_text("{ this is not json", encoding="utf-8")
|
||||
assert android_release() is None
|
||||
|
||||
|
||||
def test_a_sidecar_missing_a_field_counts_as_no_client():
|
||||
root = Config.client_root()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / APK_NAME).write_bytes(PAYLOAD)
|
||||
(root / MANIFEST_NAME).write_text(json.dumps({"version_name": "0.1.216"}), encoding="utf-8")
|
||||
assert android_release() is None
|
||||
|
||||
|
||||
def test_a_sidecar_with_no_apk_beside_it_counts_as_no_client():
|
||||
root = Config.client_root()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / MANIFEST_NAME).write_text(json.dumps({"version_name": "x", "version_code": 1, "size": 1, "sha256": ""}))
|
||||
assert android_release() is None
|
||||
|
||||
|
||||
async def test_metadata_endpoint_is_public_so_an_updater_can_ask_cheaply(app):
|
||||
place_client()
|
||||
resp = await app.test_client().get("/api/client/android")
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["version_code"] == 216
|
||||
|
||||
|
||||
async def test_metadata_404s_rather_than_describing_a_client_that_is_not_there(app):
|
||||
resp = await app.test_client().get("/api/client/android")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_the_bytes_need_authentication_even_though_the_version_does_not(app):
|
||||
"""Anyone who can reach the port may ask what version exists; only an account
|
||||
or a linked device may pull the 55 MiB."""
|
||||
place_client()
|
||||
resp = await app.test_client().get("/api/client/android/download")
|
||||
assert resp.status_code == 401
|
||||
Reference in New Issue
Block a user