"""The Android OTA feed, read from this repository's Gitea Actions artifacts. The Gitea repository is private, so the phone cannot read Actions artifacts itself. This module is the only place that touches the Gitea token: it finds the newest successful build from the configured branch, reads the small metadata artifact, and pulls the APK out of the matching artifact zip on demand. Nothing here trusts the metadata blindly. Every field falls back to what the Gitea run itself reports, so an older artifact that predates `update.json` still produces a usable answer instead of a 500. """ from __future__ import annotations import json import logging import shutil import tempfile import threading import time import zipfile from dataclasses import dataclass, replace from pathlib import Path from typing import Any import httpx from app.config import EnvSettings, env_settings log = logging.getLogger(__name__) METADATA_ARTIFACT = "kadence-update-metadata" APK_ARTIFACT = "kadence-release-apk" _METADATA_TTL_SECONDS = 60.0 _HTTP_TIMEOUT = 30.0 _DOWNLOAD_TIMEOUT = 120.0 _MAX_METADATA_BYTES = 1_000_000 _CACHE_DIR = Path(tempfile.gettempdir()) / "kadence-updates" class UpdateUnavailable(RuntimeError): """The feed cannot be built right now.""" class UpdateNotConfigured(UpdateUnavailable): """No Gitea token was supplied, so there is nothing to read.""" class UpdateNotFound(UpdateUnavailable): """There is no successful artifact for the configured branch.""" @dataclass(frozen=True, slots=True) class ResolvedUpdate: metadata: dict[str, Any] apk_artifact_id: int @dataclass(frozen=True, slots=True) class Artifact: id: int name: str created_at: str size_bytes: int expired: bool run_id: int run_number: int branch: str commit: str conclusion: str _lock = threading.Lock() _resolved_cache: tuple[float, ResolvedUpdate] | None = None _apk_cache: dict[int, Path] = {} def reset_cache() -> None: """Drop the in-process caches. Tests use this between Gitea responses.""" global _resolved_cache with _lock: _resolved_cache = None _apk_cache.clear() # ---- Gitea reads ----------------------------------------------------------- def _settings() -> EnvSettings: settings = env_settings() if not settings.gitea_token: raise UpdateNotConfigured( "Android updates are not configured: set GITEA_TOKEN on the backend." ) if not settings.gitea_repo or "/" not in settings.gitea_repo: raise UpdateNotConfigured("Android updates need GITEA_REPO as owner/repository.") return settings def _client(settings: EnvSettings, *, timeout: float = _HTTP_TIMEOUT) -> httpx.Client: return httpx.Client( base_url=settings.gitea_url.rstrip("/"), headers={ "Authorization": f"token {settings.gitea_token}", "Accept": "application/json", }, timeout=timeout, follow_redirects=True, ) def _api(settings: EnvSettings, path: str) -> str: return f"/api/v1/repos/{settings.gitea_repo}{path}" def _artifact(raw: Any) -> Artifact | None: if not isinstance(raw, dict): return None run = raw.get("workflow_run") run = run if isinstance(run, dict) else {} try: artifact_id = int(raw.get("id") or 0) except (TypeError, ValueError): return None if artifact_id <= 0: return None return Artifact( id=artifact_id, name=str(raw.get("name") or ""), created_at=str(raw.get("created_at") or ""), size_bytes=int(raw.get("size_in_bytes") or 0), expired=bool(raw.get("expired")), run_id=int(run.get("id") or 0), run_number=int(run.get("run_number") or 0), branch=str(run.get("head_branch") or ""), commit=str(run.get("head_sha") or ""), conclusion=str(run.get("conclusion") or ""), ) def _run(client: httpx.Client, settings: EnvSettings, run_id: int) -> dict[str, Any]: try: response = client.get(_api(settings, f"/actions/runs/{run_id}")) response.raise_for_status() payload = response.json() except (httpx.HTTPError, ValueError) as exc: raise UpdateUnavailable(f"Could not read Gitea run {run_id}: {exc}") from exc return payload if isinstance(payload, dict) else {} def _with_run( client: httpx.Client, settings: EnvSettings, artifact: Artifact, runs: dict[int, dict[str, Any]], ) -> Artifact: """Gitea's artifact list can omit the run's branch, number and conclusion.""" if artifact.run_number and artifact.branch and artifact.conclusion: return artifact if not artifact.run_id: return artifact run = runs.get(artifact.run_id) if run is None: run = _run(client, settings, artifact.run_id) runs[artifact.run_id] = run return replace( artifact, run_number=int(run.get("run_number") or artifact.run_number), branch=str(run.get("head_branch") or artifact.branch), commit=str(run.get("head_sha") or artifact.commit), conclusion=str(run.get("conclusion") or artifact.conclusion), ) def _list_artifacts( client: httpx.Client, settings: EnvSettings, name: str, runs: dict[int, dict[str, Any]] | None = None, ) -> list[Artifact]: try: response = client.get(_api(settings, "/actions/artifacts"), params={"name": name}) response.raise_for_status() payload = response.json() except (httpx.HTTPError, ValueError) as exc: raise UpdateUnavailable(f"Could not read Gitea artifacts: {exc}") from exc entries = payload.get("artifacts") if isinstance(payload, dict) else None if not isinstance(entries, list): return [] artifacts = [artifact for raw in entries if (artifact := _artifact(raw)) is not None] run_cache = runs if runs is not None else {} return [_with_run(client, settings, artifact, run_cache) for artifact in artifacts] def _successful(artifacts: list[Artifact], branch: str) -> list[Artifact]: """Newest first, and only successful builds from the requested branch.""" usable = [ artifact for artifact in artifacts if not artifact.expired and artifact.conclusion == "success" and artifact.branch == branch ] # `run_number` is the monotonic number Android uses as `versionCode`. The # created_at string is the tie-breaker for older Gitea runs that report 0. return sorted(usable, key=lambda item: (item.run_number, item.created_at), reverse=True) def _download_artifact(client: httpx.Client, settings: EnvSettings, artifact: Artifact) -> Path: """Download one artifact zip to a temporary file and return its path.""" _CACHE_DIR.mkdir(parents=True, exist_ok=True) target = _CACHE_DIR / f"artifact-{artifact.id}.zip" if target.is_file() and target.stat().st_size > 0: return target temporary = target.with_suffix(".zip.part") try: with client.stream( "GET", _api(settings, f"/actions/artifacts/{artifact.id}/zip"), timeout=_DOWNLOAD_TIMEOUT, ) as response: response.raise_for_status() with temporary.open("wb") as handle: for chunk in response.iter_bytes(): handle.write(chunk) if temporary.stat().st_size == 0: raise UpdateUnavailable("Gitea returned an empty artifact archive.") temporary.replace(target) except (httpx.HTTPError, OSError) as exc: temporary.unlink(missing_ok=True) raise UpdateUnavailable(f"Could not download the Gitea artifact: {exc}") from exc return target def _member(zf: zipfile.ZipFile, basename: str) -> zipfile.ZipInfo | None: for info in zf.infolist(): if not info.is_dir() and Path(info.filename).name == basename: return info return None def _read_metadata(client: httpx.Client, settings: EnvSettings, artifact: Artifact) -> dict[str, Any]: archive = _download_artifact(client, settings, artifact) try: with zipfile.ZipFile(archive) as zf: info = _member(zf, "update.json") if info is None: raise UpdateUnavailable("The update metadata artifact has no update.json.") if info.file_size > _MAX_METADATA_BYTES: raise UpdateUnavailable("The update metadata artifact is unexpectedly large.") raw = json.loads(zf.read(info).decode("utf-8")) except (zipfile.BadZipFile, OSError, UnicodeDecodeError, ValueError) as exc: raise UpdateUnavailable(f"Could not read the update metadata: {exc}") from exc return raw if isinstance(raw, dict) else {} def _int(value: Any, fallback: int = 0) -> int: try: return int(value) except (TypeError, ValueError): return fallback def _metadata_from_artifact(raw: dict[str, Any], artifact: Artifact) -> dict[str, Any]: run_number = artifact.run_number return { "version_code": _int(raw.get("version_code"), run_number), "version_name": str( raw.get("version_name") or f"0.1.0-master.{run_number or artifact.run_id}" ), "commit": str(raw.get("commit") or artifact.commit), "built_at": str(raw.get("built_at") or artifact.created_at), "size_bytes": _int(raw.get("size_bytes"), artifact.size_bytes), "sha256": str(raw.get("sha256") or ""), "signer_sha256": str(raw.get("signer_sha256") or ""), "run_number": run_number, } def _derive_metadata(artifact: Artifact) -> dict[str, Any]: """For artifacts built before the metadata artifact existed.""" return _metadata_from_artifact({}, artifact) def _apk_for_run(artifacts: list[Artifact], update: Artifact) -> Artifact | None: for artifact in artifacts: if artifact.run_id and artifact.run_id == update.run_id and not artifact.expired: return artifact # Defensive fallback for an instance that omits workflow_run.id on an # artifact but keeps the run number. if update.run_number: for artifact in artifacts: if artifact.run_number == update.run_number and not artifact.expired: return artifact return None def _resolve(settings: EnvSettings) -> ResolvedUpdate: global _resolved_cache now = time.monotonic() with _lock: cached = _resolved_cache if cached is not None and now - cached[0] < _METADATA_TTL_SECONDS: return cached[1] runs: dict[int, dict[str, Any]] = {} with _client(settings) as client: metadata_artifacts = _successful( _list_artifacts(client, settings, METADATA_ARTIFACT, runs), settings.gitea_update_branch, ) apk_artifacts = _list_artifacts(client, settings, APK_ARTIFACT, runs) usable_apk = _successful(apk_artifacts, settings.gitea_update_branch) resolved: ResolvedUpdate | None = None for artifact in metadata_artifacts: apk = _apk_for_run(usable_apk, artifact) if apk is None: continue resolved = ResolvedUpdate( metadata=_metadata_from_artifact( _read_metadata(client, settings, artifact), artifact ), apk_artifact_id=apk.id, ) break # Before the first build after this feature landed there is no metadata # artifact. The run number is the versionCode, so the newest APK # artifact is still enough to offer an update. if resolved is None and usable_apk: newest = usable_apk[0] resolved = ResolvedUpdate( metadata=_derive_metadata(newest), apk_artifact_id=newest.id, ) if resolved is None: raise UpdateNotFound( f"No successful {settings.gitea_update_branch} build with an APK was found." ) with _lock: _resolved_cache = (time.monotonic(), resolved) return resolved def _apk_file(client: httpx.Client, settings: EnvSettings, artifact_id: int) -> Path: with _lock: cached = _apk_cache.get(artifact_id) if cached is not None and cached.is_file() and cached.stat().st_size > 0: return cached # The artifact list does not carry the APK's own record here, so fetch it by # id. The id is enough for both the zip URL and the cache key. artifact = Artifact( id=artifact_id, name=APK_ARTIFACT, created_at="", size_bytes=0, expired=False, run_id=0, run_number=0, branch="", commit="", conclusion="success", ) archive = _download_artifact(client, settings, artifact) _CACHE_DIR.mkdir(parents=True, exist_ok=True) target = _CACHE_DIR / f"kadence-{artifact_id}.apk" temporary = target.with_suffix(".apk.part") try: with zipfile.ZipFile(archive) as zf: info = _member(zf, "app-release.apk") if info is None: raise UpdateUnavailable("The APK artifact has no app-release.apk.") with zf.open(info) as source, temporary.open("wb") as destination: shutil.copyfileobj(source, destination) if temporary.stat().st_size == 0: raise UpdateUnavailable("The APK inside the artifact is empty.") temporary.replace(target) except (zipfile.BadZipFile, OSError) as exc: temporary.unlink(missing_ok=True) raise UpdateUnavailable(f"Could not extract the APK: {exc}") from exc except UpdateUnavailable: temporary.unlink(missing_ok=True) raise with _lock: _apk_cache[artifact_id] = target return target # ---- the public feed ------------------------------------------------------- def latest_android() -> dict[str, Any]: """The newest successful build, as JSON-ready metadata.""" settings = _settings() return _resolve(settings).metadata def android_apk() -> tuple[Path, dict[str, Any]]: """The APK for the newest build, along with its metadata.""" settings = _settings() resolved = _resolve(settings) with _client(settings) as client: path = _apk_file(client, settings, resolved.apk_artifact_id) return path, resolved.metadata