commit 16e9eebc3d3b7309290adfc8681a78e4f136d080 Author: Antoine Van Elstraete Date: Tue Jul 28 23:23:51 2026 +0200 Add ADB APK sideload tool diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4dae94b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.pytest_cache/ +*.tmp +config.toml \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c62a08e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# Agent Guide + +## Project Shape + +- This is a small Python project: the runtime entrypoint is `src/sideload.py`; tests are in `tests/test_sideload.py`. +- The package requires Python 3.11+ because configuration is parsed with the standard-library `tomllib`. +- The `sideload` console command is declared in `pyproject.toml`; install the project editable before using it from a checkout. + +## Setup And Commands + +- Recommended setup: `python -m venv .venv && .venv/bin/pip install -e .`. +- Run the full test suite with `python -m pytest tests -q` or `python -m pytest tests/ -v`. +- Run the CLI help after editable installation with `.venv/bin/sideload --help`. +- Use `config.example.toml` as the template and copy it to the Git-ignored `config.toml`; do not add a real configuration containing local devices or app choices. +- No linter, formatter, type checker, code generator, or CI workflow is configured; tests and `python -m compileall -q src tests` are the available local checks. + +## Runtime Constraints + +- Real installations require both `adb` and `apkanalyzer` in `PATH`; tests must continue to mock them rather than require a phone or Android SDK. +- `apkanalyzer manifest application-id` is a mandatory pre-install validation; never add an installation path that bypasses package-ID validation. +- ADB commands must remain argument lists passed with `shell=False`; do not construct shell command strings or use `shell=True`. +- Multiple connected ADB devices require explicit interactive selection or `--device/-s`; never silently choose the first device. +- Wi-Fi ADB uses an explicit `HOST:PORT` from `[adb]`; do not add network scanning or implicit device discovery. +- Downloads are HTTPS-only and cached under the configured cache directory; preserve SHA-256 checks and atomic temporary-file downloads. + +## Source And Test Details + +- Application entries use `name`, `package`, `source`, and `url`; GitHub/GitLab entries may need `asset_pattern` when a release has multiple APKs. +- F-Droid resolution reads `index-v2.json` and versions are nested under `packages[package].versions`; keep numeric `versionCode` ordering and correct URL joining. +- GitHub and GitLab release parsing must reject ambiguous APK assets instead of selecting arbitrarily; do not treat GitLab source archives as APKs. +- Network, ADB, and `apkanalyzer` behavior is mocked in the unit tests; add response fixtures or mocks for new integrations instead of live API tests. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f703169 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# adb-sideload-free-apps + +Install free Android apps via ADB from F-Droid, GitHub Releases, or GitLab Releases. + +## Requirements + +- Python 3.11+ +- `adb` (Android Debug Bridge) in PATH +- `apkanalyzer` (Android SDK tools) in PATH + +## Quick start + +```bash +python -m pip install -e . +cp config.example.toml config.toml +# Edit config.toml to add your apps +python -m sideload +``` + +## Usage + +``` +sideload [APP] [--list] [--device SERIAL] [--dry-run] [--config PATH] +``` + +- `APP` — app name from config (omit for interactive selection) +- `--list` — list configured apps +- `--device`, `-s` — ADB device serial +- `--dry-run` — resolve, download, validate without installing +- `--config`, `-c` — path to config.toml (default: ./config.toml) + +## Configuration + +See `config.example.toml`. Supported sources: + +| Source | Required fields | +| -------- | ---------------------------- | +| `fdroid` | `url` (page F-Droid) | +| `github` | `url` (repository) | +| `gitlab` | `url` (projet) | + +Every application also requires `name` and `package`. For GitHub and GitLab, +set `asset_pattern` when a release contains more than one suitable APK. Debug, +test and unsigned APKs are rejected by default. + +`apkanalyzer` is mandatory and must be available in `PATH`. It is provided by +the Android SDK command-line tools. + +For Wi-Fi ADB, configure an explicit address after enabling wireless debugging: + +```toml +[adb] +mode = "wifi" +address = "192.168.1.42:5555" +``` + +## Testing + +```bash +pip install pytest +python -m pytest tests/ -v +``` diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..c8b8998 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,40 @@ +# Cache directory (default: ~/.cache/adb-sideload) +[cache] +directory = "~/.cache/adb-sideload-free-apps" + +# Optional: custom ADB binary path +[adb] +# binary = "adb" +# mode = "auto" # auto, usb, or wifi +# address = "192.168.1.42:5555" # required for mode = "wifi" + +# ── Apps ────────────────────────────────────────────────────────────────────── +# Each app needs: name, package, source and url. + +# --- F-Droid --- +# Required extra fields: repo_url + +[[apps]] +name = "Fennec F-Droid" +source = "fdroid" +package = "org.mozilla.fennec_fdroid" +url = "https://f-droid.org/packages/org.mozilla.fennec_fdroid/" + +# --- GitHub Releases --- +# The URL points to the GitHub repository. + +# [[apps]] +# name = "NewPipe" +# source = "github" +# package = "org.schabi.newpipe" +# url = "https://github.com/TeamNewPipe/NewPipe" +# asset_pattern = ".*universal.*\\.apk$" + +# --- GitLab Releases --- +# The URL points to the GitLab project. + +# [[apps]] +# name = "Aurora Store" +# source = "gitlab" +# package = "com.aurora.store" +# url = "https://gitlab.com/AuroraOSS/AuroraStore" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0947071 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "adb-sideload-free-apps" +version = "0.1.0" +description = "Install free Android apps via ADB from F-Droid, GitHub, or GitLab" +requires-python = ">=3.11" +license = { text = "MIT" } +readme = "README.md" + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project.scripts] +sideload = "sideload:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" diff --git a/src/sideload.py b/src/sideload.py new file mode 100644 index 0000000..eca440a --- /dev/null +++ b/src/sideload.py @@ -0,0 +1,730 @@ +"""ADB Sideload Free Apps — install free Android apps from F-Droid, GitHub, GitLab via ADB.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: + sys.exit("Python 3.11+ is required") + +__version__ = "0.1.0" + +# ── data types ──────────────────────────────────────────────────────────────── + + +@dataclass +class AppEntry: + name: str + source: str + app_id: str + config: dict # source-specific parameters + + +@dataclass +class Config: + apps: list[AppEntry] + cache_dir: Path + adb_binary: str + adb_mode: str = "auto" + adb_address: str | None = None + + @classmethod + def from_file(cls, path: Path) -> Config: + with path.open("rb") as fh: + raw = tomllib.load(fh) + + cache_dir = Path( + raw.get("cache", {}).get("directory", "~/.cache/adb-sideload") + ).expanduser().resolve() + adb_config = raw.get("adb", {}) + adb_binary = adb_config.get("binary", "adb") + adb_mode = adb_config.get("mode", "auto") + adb_address = adb_config.get("address") or adb_config.get("wifi_address") + + apps = [] + for entry in raw.get("apps", []): + apps.append( + AppEntry( + name=entry["name"], + source=entry["source"], + app_id=entry.get("package", entry.get("app_id", "")), + config={ + k: v + for k, v in entry.items() + if k not in ("name", "source", "package", "app_id") + }, + ) + ) + + if not apps: + raise ValueError( + "no apps configured; at least one [[apps]] entry is required" + ) + + for app in apps: + if not app.name or not app.app_id: + raise ValueError("each app requires name and package") + if app.source not in SOURCE_REGISTRY: + raise ValueError(f"unknown source type '{app.source}'") + pattern = app.config.get("asset_pattern") + if pattern: + try: + re.compile(pattern) + except re.error as exc: + raise ValueError(f"invalid asset_pattern for {app.name}: {exc}") from exc + if adb_mode not in ("auto", "usb", "wifi"): + raise ValueError("adb.mode must be auto, usb, or wifi") + if adb_mode == "wifi" and not adb_address: + raise ValueError("adb.address is required when adb.mode is wifi") + return cls( + apps=apps, + cache_dir=cache_dir, + adb_binary=adb_binary, + adb_mode=adb_mode, + adb_address=adb_address, + ) + + +@dataclass +class ReleaseInfo: + app_entry: AppEntry + version: str + download_url: str + expected_sha256: str | None # only F-Droid provides this pre-download + file_size: int | None = None + + +@dataclass +class AdbDevice: + serial: str + model: str + transport: str # "usb" or "wifi" + + +# ── utilities ───────────────────────────────────────────────────────────────── + + +def _sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def _make_apk_stem(release: ReleaseInfo) -> str: + return f"{release.app_entry.app_id}_{release.version}".replace("/", "_") + + +def _atomic_download(url: str, dest: Path) -> Path: + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https": + raise ValueError(f"only HTTPS downloads are allowed: {url}") + dest.parent.mkdir(parents=True, exist_ok=True) + tmp = dest.with_suffix(dest.suffix + ".tmp") + try: + request = urllib.request.Request(url, headers={"User-Agent": "adb-sideload/0.1"}) + with urllib.request.urlopen(request, timeout=30) as resp: + with tmp.open("wb") as fh: + shutil.copyfileobj(resp, fh) + tmp.rename(dest) + except BaseException: + tmp.unlink(missing_ok=True) + raise + return dest + + +def _validate_adb_address(address: str) -> bool: + host, separator, port = address.rpartition(":") + return bool(separator and host and port.isdigit() and 1 <= int(port) <= 65535) + + +def _run( + *args: str, check: bool = True, timeout: int = 30, **kwargs +) -> subprocess.CompletedProcess: + return subprocess.run( + list(args), check=check, timeout=timeout, capture_output=True, text=True, **kwargs + ) + + +def _http_get(url: str, extra_headers: dict | None = None) -> bytes: + headers = {"User-Agent": "adb-sideload/0.1"} + if extra_headers: + headers.update(extra_headers) + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.read() + except urllib.error.HTTPError as exc: + raise RuntimeError(f"HTTP {exc.code} fetching {url}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"network error fetching {url}: {exc.reason}") from exc + + +# ── download cache ──────────────────────────────────────────────────────────── + + +class DownloadCache: + def __init__(self, root: Path): + self.root = root + + def cached_path(self, app_id: str, sha256: str) -> Path: + return self.root / app_id / f"{sha256}.apk" + + def find_cached(self, app_id: str, sha256: str) -> Path | None: + if sha256: + p = self.cached_path(app_id, sha256) + if p.is_file() and _sha256_file(p) == sha256: + return p + return None + + def store(self, app_id: str, src: Path, sha256: str | None = None) -> Path: + if sha256 is None: + sha256 = _sha256_file(src) + dest = self.cached_path(app_id, sha256) + if not dest.is_file(): + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dest) + return dest + + +# ── F-Droid source ──────────────────────────────────────────────────────────── + + +class FdroidSource: + def __init__(self, app_entry: AppEntry): + self.app_entry = app_entry + self.repo_url = app_entry.config.get("repo_url", "").rstrip("/") + if not self.repo_url: + source_url = app_entry.config.get("url", "") + if source_url.startswith("https://f-droid.org/"): + self.repo_url = "https://f-droid.org/repo" + if not self.repo_url: + raise ValueError("url or repo_url is required for fdroid source") + + def get_latest_release(self) -> ReleaseInfo: + index_url = f"{self.repo_url}/index-v2.json" + data = _http_get(index_url) + index = json.loads(data) + pkg_name = self.app_entry.app_id + packages = index.get("packages", {}) + if pkg_name not in packages: + raise ValueError( + f"app '{pkg_name}' not found in F-Droid index at {self.repo_url}" + ) + pkg = packages[pkg_name] + # index-v2 stores versions below a dedicated key. Keep accepting the + # flat shape used by older repositories and test fixtures. + pkg = pkg.get("versions", pkg) + try: + versions = sorted(pkg.keys(), key=int, reverse=True) + except (TypeError, ValueError) as exc: + raise ValueError(f"invalid versionCode in F-Droid index for '{pkg_name}'") from exc + if not versions: + raise ValueError(f"no versions for '{pkg_name}' in F-Droid index") + latest = versions[0] + entry = pkg[latest] + file_info = entry.get("file", {}) + apk_name = file_info.get("name", "") + if not apk_name: + raise ValueError( + f"no file name in F-Droid index for '{pkg_name}' v{latest}" + ) + if apk_name.startswith("/"): + base = urllib.parse.urlsplit(self.repo_url) + download_url = urllib.parse.urlunsplit( + (base.scheme, base.netloc, apk_name, "", "") + ) + else: + download_url = urllib.parse.urljoin(self.repo_url + "/", apk_name) + return ReleaseInfo( + app_entry=self.app_entry, + version=latest, + download_url=download_url, + expected_sha256=self.app_entry.config.get("sha256") or file_info.get("sha256"), + file_size=file_info.get("size"), + ) + + def download_apk(self, release: ReleaseInfo, cache: DownloadCache) -> Path: + if release.expected_sha256: + cached = cache.find_cached( + release.app_entry.app_id, release.expected_sha256 + ) + if cached: + return cached + dest = cache.cached_path( + release.app_entry.app_id, release.expected_sha256 or "downloading" + ) + dest = _atomic_download(release.download_url, dest) + actual = _sha256_file(dest) + if release.expected_sha256 and actual != release.expected_sha256: + dest.unlink() + raise ValueError( + f"SHA-256 mismatch for {release.app_entry.name}: " + f"expected {release.expected_sha256[:16]}…, got {actual[:16]}…" + ) + return cache.store(release.app_entry.app_id, dest, actual) + + +# ── GitHub source ───────────────────────────────────────────────────────────── + + +class GitHubSource: + API = "https://api.github.com" + + def __init__(self, app_entry: AppEntry): + self.app_entry = app_entry + self.owner = app_entry.config.get("owner", "") + self.repo = app_entry.config.get("repo", "") + if not self.owner or not self.repo: + parsed = urllib.parse.urlsplit(app_entry.config.get("url", "")) + parts = parsed.path.strip("/").removesuffix(".git").split("/") + if parsed.netloc == "github.com" and len(parts) == 2: + self.owner, self.repo = parts + if not self.owner or not self.repo: + raise ValueError("owner and repo are required for github source") + + def get_latest_release(self) -> ReleaseInfo: + allow_prerelease = self.app_entry.config.get("allow_prerelease", False) + endpoint = "releases?per_page=20" if allow_prerelease else "releases/latest" + url = f"{self.API}/repos/{self.owner}/{self.repo}/{endpoint}" + data = _http_get(url, extra_headers={"Accept": "application/vnd.github+json"}) + release = json.loads(data) + if isinstance(release, list): + release = next((r for r in release if not r.get("draft")), None) + if not release: + raise ValueError(f"no suitable release for {self.owner}/{self.repo}") + version = release.get("tag_name", "unknown").lstrip("v") + pattern = self.app_entry.config.get("asset_pattern", r"(?i).*\.apk$") + regex = re.compile(pattern) + apk_assets = [ + a + for a in release.get("assets", []) + if regex.fullmatch(a.get("name", "")) + and not re.search(r"(?i)(debug|test|unsigned)", a.get("name", "")) + ] + if not apk_assets: + raise ValueError( + f"no APK asset in latest release of {self.owner}/{self.repo}" + ) + if len(apk_assets) > 1: + raise ValueError( + "multiple APK assets match; set asset_pattern more precisely" + ) + asset = apk_assets[0] + return ReleaseInfo( + app_entry=self.app_entry, + version=version, + download_url=asset["browser_download_url"], + expected_sha256=self.app_entry.config.get("sha256"), + file_size=apk_assets[0].get("size"), + ) + + def download_apk(self, release: ReleaseInfo, cache: DownloadCache) -> Path: + stem = _make_apk_stem(release) + dest = cache.root / release.app_entry.app_id / f"{stem}.apk" + if not dest.is_file(): + dest = _atomic_download(release.download_url, dest) + sha = _sha256_file(dest) + if release.expected_sha256 and sha.lower() != release.expected_sha256.lower(): + dest.unlink(missing_ok=True) + raise ValueError(f"SHA-256 mismatch for {release.app_entry.name}") + return cache.store(release.app_entry.app_id, dest, sha) + + +# ── GitLab source ───────────────────────────────────────────────────────────── + + +class GitLabSource: + API = "https://gitlab.com/api/v4" + + def __init__(self, app_entry: AppEntry): + self.app_entry = app_entry + self.project_url = app_entry.config.get("project_url", "") or app_entry.config.get("url", "") + if not self.project_url: + raise ValueError("project_url is required for gitlab source") + parsed = urllib.parse.urlsplit(self.project_url) + if parsed.scheme != "https" or not parsed.netloc or not parsed.path.strip("/"): + raise ValueError("project_url must be an HTTPS GitLab project URL") + self.api = f"https://{parsed.netloc}/api/v4" + self.project_id = urllib.parse.quote(parsed.path.strip("/"), safe="") + + def get_latest_release(self) -> ReleaseInfo: + url = f"{self.api}/projects/{self.project_id}/releases/permalink/latest" + data = _http_get(url) + releases = json.loads(data) + latest = releases[0] if isinstance(releases, list) and releases else releases + if not latest: + raise ValueError(f"no releases found for {self.project_url}") + version = (latest.get("tag_name") or latest.get("name") or "unknown").lstrip("v") + pattern = self.app_entry.config.get("asset_pattern", r"(?i).*\.apk$") + regex = re.compile(pattern) + apk_links = [] + for link in latest.get("assets", {}).get("links", []): + if regex.fullmatch(link.get("name", "")) and not re.search( + r"(?i)(debug|test|unsigned)", link.get("name", "") + ): + apk_links.append(link) + if not apk_links: + raise ValueError(f"no APK link in latest release of {self.project_url}") + if len(apk_links) > 1: + raise ValueError( + "multiple APK links match; set asset_pattern more precisely" + ) + link = apk_links[0] + return ReleaseInfo( + app_entry=self.app_entry, + version=version, + download_url=link.get("direct_asset_url") or link.get("url", ""), + expected_sha256=self.app_entry.config.get("sha256"), + file_size=None, + ) + + def download_apk(self, release: ReleaseInfo, cache: DownloadCache) -> Path: + stem = _make_apk_stem(release) + dest = cache.root / release.app_entry.app_id / f"{stem}.apk" + if not dest.is_file(): + dest = _atomic_download(release.download_url, dest) + sha = _sha256_file(dest) + if release.expected_sha256 and sha.lower() != release.expected_sha256.lower(): + dest.unlink(missing_ok=True) + raise ValueError(f"SHA-256 mismatch for {release.app_entry.name}") + return cache.store(release.app_entry.app_id, dest, sha) + + +# ── APK validation ──────────────────────────────────────────────────────────── + + +class ApkValidator: + def __init__(self, apkanalyzer_bin: str = "apkanalyzer"): + self.bin = apkanalyzer_bin + + def validate(self, apk_path: Path, expected_app_id: str) -> str | None: + """Return error message string or None on success.""" + try: + result = _run( + self.bin, "manifest", "application-id", str(apk_path), + check=False, timeout=15, + ) + except FileNotFoundError: + return f"apkanalyzer not found: {self.bin}" + if result.returncode != 0: + return f"apkanalyzer failed: {result.stderr.strip()}" + actual = result.stdout.strip() + if actual != expected_app_id: + return ( + f"application-id mismatch: expected '{expected_app_id}', " + f"got '{actual}'" + ) + return None + + +# ── ADB management ──────────────────────────────────────────────────────────── + + +class AdbManager: + def __init__(self, binary: str = "adb"): + self.binary = binary + + def list_devices(self) -> list[AdbDevice]: + result = _run(self.binary, "devices", "-l", check=True, timeout=10) + devices: list[AdbDevice] = [] + for line in result.stdout.strip().splitlines()[1:]: + if not line.strip(): + continue + parts = line.split() + if len(parts) < 2 or parts[1] != "device": + continue + serial = parts[0] + props = dict(p.split(":", 1) for p in parts[2:] if ":" in p) + model = props.get("model", "unknown") + transport = "wifi" if ":" in serial else "usb" + devices.append( + AdbDevice(serial=serial, model=model, transport=transport) + ) + return devices + + def connect_wifi(self, address: str) -> str | None: + result = _run(self.binary, "connect", address, check=False, timeout=10) + out = result.stdout.strip() + if "connected" not in out.lower() and "already connected" not in out.lower(): + return f"failed to connect: {out}" + return None + + def install(self, apk_path: Path, serial: str | None = None) -> str | None: + cmd = [self.binary] + if serial: + cmd += ["-s", serial] + cmd += ["install", "-r", str(apk_path)] + result = _run(*cmd, check=False, timeout=120) + if result.returncode != 0 or "Success" not in result.stdout: + err = result.stderr.strip() or result.stdout.strip() + return f"install failed: {err}" + return None + + +# ── interactive helpers ─────────────────────────────────────────────────────── + + +def _choose_app(apps: list[AppEntry]) -> AppEntry: + if not apps: + raise ValueError("no apps configured") + if len(apps) == 1: + return apps[0] + print("Available apps:") + for i, app in enumerate(apps, 1): + print(f" {i}. {app.name} ({app.source})") + while True: + try: + choice = input(f"Select app [1-{len(apps)}]: ").strip() + idx = int(choice) - 1 + if 0 <= idx < len(apps): + return apps[idx] + except (ValueError, EOFError, KeyboardInterrupt): + raise SystemExit(1) + print(f"Invalid choice, enter 1-{len(apps)}") + + +def _choose_device(adb: AdbManager, devices: list[AdbDevice]) -> AdbDevice: + if not devices: + raise ValueError( + "no ADB devices found. Connect a device via USB or use:\n" + f" {adb.binary} connect :5555" + ) + if len(devices) == 1: + print(f"Using device: {devices[0].model} ({devices[0].serial})") + return devices[0] + print("Multiple devices found:") + for i, d in enumerate(devices, 1): + trans = "Wi-Fi" if d.transport == "wifi" else "USB" + print(f" {i}. {d.model} [{trans}] ({d.serial})") + while True: + try: + choice = input(f"Select device [1-{len(devices)}]: ").strip() + idx = int(choice) - 1 + if 0 <= idx < len(devices): + return devices[idx] + except (ValueError, EOFError, KeyboardInterrupt): + raise SystemExit(1) + print(f"Invalid choice, enter 1-{len(devices)}") + + +# ── source factory ──────────────────────────────────────────────────────────── + +SOURCE_REGISTRY = { + "fdroid": FdroidSource, + "github": GitHubSource, + "gitlab": GitLabSource, +} + + +def _make_source(app: AppEntry) -> FdroidSource | GitHubSource | GitLabSource: + cls = SOURCE_REGISTRY.get(app.source) + if cls is None: + raise ValueError( + f"unknown source type '{app.source}'. Use fdroid, github, or gitlab" + ) + return cls(app) + + +# ── orchestration ───────────────────────────────────────────────────────────── + + +def sideload( + app: AppEntry, + config: Config, + *, + device_serial: str | None = None, + dry_run: bool = False, + adb_manager: AdbManager | None = None, + validator: ApkValidator | None = None, +) -> int: + """Main sideload routine. Returns 0 on success, 1 on failure.""" + source = _make_source(app) + adb = adb_manager or AdbManager(config.adb_binary) + apk_validator = validator or ApkValidator() + cache = DownloadCache(config.cache_dir) + + # 1 — resolve release + print(f"Resolving latest {app.name} from {app.source}…") + try: + release = source.get_latest_release() + except Exception as exc: + print(f"Error resolving release: {exc}", file=sys.stderr) + return 1 + print(f" -> {app.name} v{release.version}") + + # 2 — download + print("Downloading APK…") + try: + apk_path = source.download_apk(release, cache) + except Exception as exc: + print(f"Error downloading: {exc}", file=sys.stderr) + return 1 + print(f" -> cached at {apk_path}") + sha = _sha256_file(apk_path) + print(f" -> sha256: {sha}") + + # 3 — validate app-id + print("Validating application-id…") + err = apk_validator.validate(apk_path, app.app_id) + if err: + print(f"Validation FAILED: {err}", file=sys.stderr) + return 1 + print(f" -> application-id matches: {app.app_id}") + + if dry_run: + print("\n[dry-run] Would install to device (skipping ADB install).") + return 0 + + # 4 — detect device + print("Detecting ADB devices…") + try: + if config.adb_mode == "wifi": + if not config.adb_address or not _validate_adb_address(config.adb_address): + print("Invalid adb.address; expected HOST:PORT", file=sys.stderr) + return 1 + error = adb.connect_wifi(config.adb_address) + if error: + print(error, file=sys.stderr) + return 1 + devices = adb.list_devices() + except Exception as exc: + print(f"Error detecting devices: {exc}", file=sys.stderr) + return 1 + + if not devices: + print("No devices found. You can connect via Wi-Fi with:") + print(f" {adb.binary} connect :5555") + return 1 + + device = None + if device_serial: + for d in devices: + if d.serial == device_serial: + device = d + break + if device is None: + serials = ", ".join(d.serial for d in devices) + print( + f"Device '{device_serial}' not found. Available: {serials}", + file=sys.stderr, + ) + return 1 + else: + device = _choose_device(adb, devices) + + # 5 — install + print(f"Installing to {device.model} ({device.serial})…") + try: + err = adb.install(apk_path, device.serial) + except Exception as exc: + print(f"Install error: {exc}", file=sys.stderr) + return 1 + if err: + print(f"Install failed: {err}", file=sys.stderr) + return 1 + + print(f"Successfully installed {app.name} v{release.version}") + return 0 + + +# ── CLI ─────────────────────────────────────────────────────────────────────── + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="sideload", + description="Install free Android apps via ADB from F-Droid, GitHub or GitLab.", + ) + p.add_argument( + "app", nargs="?", help="app name to install (omit for interactive selection)" + ) + p.add_argument("--list", action="store_true", help="list configured apps and exit") + p.add_argument( + "--device", "-s", help="ADB device serial (omit for interactive selection)" + ) + p.add_argument( + "--dry-run", + action="store_true", + help="resolve/download/validate without installing", + ) + p.add_argument( + "--config", + "-c", + type=Path, + default=Path("config.toml"), + help="path to config.toml (default: ./config.toml)", + ) + p.add_argument( + "--version", action="version", version=f"%(prog)s {__version__}" + ) + return p + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if not args.config.is_file(): + print(f"Config not found: {args.config}", file=sys.stderr) + print( + "Copy config.example.toml to config.toml and edit it.", + file=sys.stderr, + ) + return 1 + + try: + config = Config.from_file(args.config) + except Exception as exc: + print(f"Error loading config: {exc}", file=sys.stderr) + return 1 + + if args.list: + for a in config.apps: + print(f" {a.name} ({a.source}) -> {a.app_id}") + return 0 + + if args.app: + matches = [a for a in config.apps if a.name.lower() == args.app.lower()] + if not matches: + names = ", ".join(a.name for a in config.apps) + print( + f"App '{args.app}' not found. Available: {names}", + file=sys.stderr, + ) + return 1 + if len(matches) > 1: + names = ", ".join(a.name for a in matches) + print( + f"Ambiguous name '{args.app}' matches: {names}", + file=sys.stderr, + ) + return 1 + app = matches[0] + else: + app = _choose_app(config.apps) + + return sideload( + app, + config, + device_serial=args.device, + dry_run=args.dry_run, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_sideload.py b/tests/test_sideload.py new file mode 100644 index 0000000..3fef44b --- /dev/null +++ b/tests/test_sideload.py @@ -0,0 +1,1009 @@ +"""Unit tests for sideload module — no network, ADB, or apkanalyzer required.""" + +from __future__ import annotations + +import hashlib +import io +import json +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path +from unittest import mock + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) +import sideload # noqa: E402 + + +# ── helpers ─────────────────────────────────────────────────────────────────── + + +def _make_apk_bytes(content: bytes = b"fake-apk-content") -> bytes: + return content + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _write_config(path: Path, content: str) -> Path: + path.write_text(content) + return path + + +def _mock_urlopen_response(data: bytes): + """Return a MagicMock that acts as a urlopen context manager returning *data*.""" + m = mock.MagicMock() + m.__enter__ = mock.MagicMock(return_value=m) + m.__exit__ = mock.MagicMock(return_value=False) + m.read.side_effect = [data, b""] + return m + + +# ── fixtures ────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def tmp_config(tmp_path: Path) -> Path: + return _write_config( + tmp_path / "config.toml", + """\ +[cache] +directory = "/tmp/test-cache" + +[[apps]] +name = "TestApp" +source = "fdroid" +app_id = "org.example.test" +repo_url = "https://f-droid.org/repo" +""", + ) + + +@pytest.fixture +def tmp_config_multi(tmp_path: Path) -> Path: + return _write_config( + tmp_path / "config.toml", + """\ +[[apps]] +name = "Fennec" +source = "fdroid" +app_id = "org.mozilla.fennec_fdroid" +repo_url = "https://f-droid.org/repo" + +[[apps]] +name = "NewPipe" +source = "github" +app_id = "org.schabi.newpipe" +owner = "TeamNewPipe" +repo = "NewPipe" + +[[apps]] +name = "Aurora" +source = "gitlab" +app_id = "com.aurora.store" +project_url = "https://gitlab.com/AuroraOSS/AuroraStore" +""", + ) + + +@pytest.fixture +def app_entry() -> sideload.AppEntry: + return sideload.AppEntry( + name="TestApp", + source="fdroid", + app_id="org.example.test", + config={"repo_url": "https://f-droid.org/repo"}, + ) + + +@pytest.fixture +def github_entry() -> sideload.AppEntry: + return sideload.AppEntry( + name="NewPipe", + source="github", + app_id="org.schabi.newpipe", + config={"owner": "TeamNewPipe", "repo": "NewPipe"}, + ) + + +@pytest.fixture +def gitlab_entry() -> sideload.AppEntry: + return sideload.AppEntry( + name="Aurora", + source="gitlab", + app_id="com.aurora.store", + config={"project_url": "https://gitlab.com/AuroraOSS/AuroraStore"}, + ) + + +@pytest.fixture +def config(tmp_config: Path) -> sideload.Config: + return sideload.Config.from_file(tmp_config) + + +@pytest.fixture +def cache(tmp_path: Path) -> sideload.DownloadCache: + return sideload.DownloadCache(tmp_path / "cache") + + +@pytest.fixture +def apk_bytes() -> bytes: + return _make_apk_bytes() + + +@pytest.fixture +def apk_file(tmp_path: Path, apk_bytes: bytes) -> Path: + p = tmp_path / "test.apk" + p.write_bytes(apk_bytes) + return p + + +# ── Config tests ────────────────────────────────────────────────────────────── + + +class TestConfig: + def test_from_file_basic(self, tmp_config: Path): + cfg = sideload.Config.from_file(tmp_config) + assert len(cfg.apps) == 1 + assert cfg.apps[0].name == "TestApp" + assert cfg.apps[0].source == "fdroid" + assert cfg.apps[0].app_id == "org.example.test" + assert cfg.apps[0].config["repo_url"] == "https://f-droid.org/repo" + assert cfg.cache_dir == Path("/tmp/test-cache") + assert cfg.adb_binary == "adb" + + def test_from_file_multi(self, tmp_config_multi: Path): + cfg = sideload.Config.from_file(tmp_config_multi) + assert len(cfg.apps) == 3 + sources = {a.name: a.source for a in cfg.apps} + assert sources == {"Fennec": "fdroid", "NewPipe": "github", "Aurora": "gitlab"} + + def test_from_file_default_cache(self, tmp_path: Path): + p = _write_config( + tmp_path / "cfg.toml", + '[[apps]]\nname="X"\nsource="fdroid"\napp_id="x"\nrepo_url="https://r"', + ) + cfg = sideload.Config.from_file(p) + assert cfg.cache_dir == Path("~/.cache/adb-sideload").expanduser().resolve() + + def test_from_file_no_apps_raises(self, tmp_path: Path): + p = _write_config(tmp_path / "cfg.toml", "") + with pytest.raises(ValueError, match="no apps configured"): + sideload.Config.from_file(p) + + def test_from_file_missing_required_field(self, tmp_path: Path): + p = _write_config( + tmp_path / "cfg.toml", + '[[apps]]\nname="X"\nsource="fdroid"\napp_id="x"', + ) + cfg = sideload.Config.from_file(p) + assert cfg.apps[0].config.get("repo_url") is None + + def test_from_file_package_and_url_format(self, tmp_path: Path): + p = _write_config( + tmp_path / "cfg.toml", + """\ +[[apps]] +name = "NewPipe" +source = "github" +package = "org.schabi.newpipe" +url = "https://github.com/TeamNewPipe/NewPipe" +asset_pattern = ".*\\\\.apk$" +""", + ) + cfg = sideload.Config.from_file(p) + assert cfg.apps[0].app_id == "org.schabi.newpipe" + assert cfg.apps[0].config["url"].startswith("https://github.com/") + + +# ── DownloadCache tests ─────────────────────────────────────────────────────── + + +class TestDownloadCache: + def test_cached_path(self, cache: sideload.DownloadCache): + p = cache.cached_path("org.example", "abc123") + assert p == cache.root / "org.example" / "abc123.apk" + + def test_find_cached_missing(self, cache: sideload.DownloadCache): + assert cache.find_cached("org.example", "abc123") is None + + def test_find_cached_present(self, cache: sideload.DownloadCache, apk_file: Path): + sha = sideload._sha256_file(apk_file) + cache.store("org.example", apk_file, sha) + found = cache.find_cached("org.example", sha) + assert found is not None + assert found.is_file() + + def test_find_cached_wrong_sha(self, cache: sideload.DownloadCache, apk_file: Path): + cache.store("org.example", apk_file, "abc123") + assert cache.find_cached("org.example", "abc123") is None + + def test_store_computes_sha(self, cache: sideload.DownloadCache, apk_file: Path): + dest = cache.store("org.example", apk_file) + assert dest.is_file() + assert dest.read_bytes() == apk_file.read_bytes() + + def test_store_idempotent(self, cache: sideload.DownloadCache, apk_file: Path): + sha = sideload._sha256_file(apk_file) + d1 = cache.store("org.example", apk_file, sha) + d2 = cache.store("org.example", apk_file, sha) + assert d1 == d2 + + +# ── FdroidSource tests ──────────────────────────────────────────────────────── + + +class TestFdroidSource: + FDROID_INDEX = { + "packages": { + "org.example.test": { + "12345": { + "file": { + "name": "/repo/org.example.test_12345.apk", + "sha256": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "size": 1234567, + } + }, + "12344": { + "file": { + "name": "/repo/org.example.test_12344.apk", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + } + }, + } + } + } + + def test_get_latest_release(self, app_entry: sideload.AppEntry): + source = sideload.FdroidSource(app_entry) + with mock.patch("sideload._http_get", return_value=json.dumps(self.FDROID_INDEX).encode()): + release = source.get_latest_release() + assert release.version == "12345" + assert release.download_url == "https://f-droid.org/repo/org.example.test_12345.apk" + assert release.expected_sha256 == self.FDROID_INDEX["packages"]["org.example.test"]["12345"]["file"]["sha256"] + assert release.file_size == 1234567 + + def test_get_latest_release_app_not_found(self, app_entry: sideload.AppEntry): + source = sideload.FdroidSource(app_entry) + index = {"packages": {}} + with mock.patch("sideload._http_get", return_value=json.dumps(index).encode()): + with pytest.raises(ValueError, match="not found in F-Droid index"): + source.get_latest_release() + + def test_get_latest_release_no_versions(self, app_entry: sideload.AppEntry): + source = sideload.FdroidSource(app_entry) + index = {"packages": {"org.example.test": {}}} + with mock.patch("sideload._http_get", return_value=json.dumps(index).encode()): + with pytest.raises(ValueError, match="no versions"): + source.get_latest_release() + + def test_get_latest_release_no_file_name(self, app_entry: sideload.AppEntry): + source = sideload.FdroidSource(app_entry) + index = {"packages": {"org.example.test": {"1": {"file": {}}}}} + with mock.patch("sideload._http_get", return_value=json.dumps(index).encode()): + with pytest.raises(ValueError, match="no file name"): + source.get_latest_release() + + def test_get_latest_release_index_v2_versions_key(self, app_entry: sideload.AppEntry): + index = { + "packages": { + "org.example.test": { + "metadata": {"name": "Test"}, + "versions": { + "12345": { + "file": { + "name": "org.example.test_12345.apk", + "sha256": "a" * 64, + } + } + }, + } + } + } + source = sideload.FdroidSource(app_entry) + with mock.patch("sideload._http_get", return_value=json.dumps(index).encode()): + release = source.get_latest_release() + assert release.version == "12345" + assert release.download_url.endswith("/repo/org.example.test_12345.apk") + + def test_missing_repo_url_raises(self): + entry = sideload.AppEntry(name="X", source="fdroid", app_id="x", config={}) + with pytest.raises(ValueError, match="repo_url is required"): + sideload.FdroidSource(entry) + + def test_download_apk_cached(self, app_entry: sideload.AppEntry, cache: sideload.DownloadCache, apk_file: Path): + sha = sideload._sha256_file(apk_file) + cache.store(app_entry.app_id, apk_file, sha) + source = sideload.FdroidSource(app_entry) + release = sideload.ReleaseInfo( + app_entry=app_entry, + version="1", + download_url="https://example.com/x.apk", + expected_sha256=sha, + ) + result = source.download_apk(release, cache) + assert result == cache.cached_path(app_entry.app_id, sha) + + def test_download_apk_sha256_mismatch(self, app_entry: sideload.AppEntry, cache: sideload.DownloadCache): + apk_data = b"real-content" + wrong_sha = "0" * 64 + source = sideload.FdroidSource(app_entry) + release = sideload.ReleaseInfo( + app_entry=app_entry, + version="1", + download_url="https://example.com/x.apk", + expected_sha256=wrong_sha, + ) + mock_resp = _mock_urlopen_response(apk_data) + with mock.patch("urllib.request.urlopen", return_value=mock_resp): + with pytest.raises(ValueError, match="SHA-256 mismatch"): + source.download_apk(release, cache) + + +# ── GitHubSource tests ──────────────────────────────────────────────────────── + + +class TestGitHubSource: + GITHUB_RELEASE = { + "tag_name": "v0.27.0", + "assets": [ + { + "name": "NewPipe_v0.27.0.apk", + "browser_download_url": "https://github.com/TeamNewPipe/NewPipe/releases/download/v0.27.0/NewPipe_v0.27.0.apk", + "size": 12345678, + }, + { + "name": "Source code (zip)", + "browser_download_url": "https://github.com/TeamNewPipe/NewPipe/archive/v0.27.0.zip", + }, + ], + } + + def test_get_latest_release(self, github_entry: sideload.AppEntry): + source = sideload.GitHubSource(github_entry) + with mock.patch("sideload._http_get", return_value=json.dumps(self.GITHUB_RELEASE).encode()): + release = source.get_latest_release() + assert release.version == "0.27.0" + assert "NewPipe_v0.27.0.apk" in release.download_url + assert release.expected_sha256 is None + assert release.file_size == 12345678 + + def test_get_latest_release_no_apk(self, github_entry: sideload.AppEntry): + source = sideload.GitHubSource(github_entry) + data = {"tag_name": "v1.0", "assets": [{"name": "source.zip"}]} + with mock.patch("sideload._http_get", return_value=json.dumps(data).encode()): + with pytest.raises(ValueError, match="no APK asset"): + source.get_latest_release() + + def test_missing_owner_raises(self): + entry = sideload.AppEntry(name="X", source="github", app_id="x", config={"repo": "r"}) + with pytest.raises(ValueError, match="owner and repo are required"): + sideload.GitHubSource(entry) + + def test_missing_repo_raises(self): + entry = sideload.AppEntry(name="X", source="github", app_id="x", config={"owner": "o"}) + with pytest.raises(ValueError, match="owner and repo are required"): + sideload.GitHubSource(entry) + + def test_download_apk(self, github_entry: sideload.AppEntry, cache: sideload.DownloadCache): + source = sideload.GitHubSource(github_entry) + release = sideload.ReleaseInfo( + app_entry=github_entry, + version="0.27.0", + download_url="https://example.com/x.apk", + expected_sha256=None, + ) + apk_data = b"github-apk-content" + mock_resp = _mock_urlopen_response(apk_data) + with mock.patch("urllib.request.urlopen", return_value=mock_resp): + result = source.download_apk(release, cache) + assert result.is_file() + assert result.read_bytes() == apk_data + + +# ── GitLabSource tests ──────────────────────────────────────────────────────── + + +class TestGitLabSource: + GITLAB_RELEASE = [ + { + "tag_name": "v4.4.1", + "name": "Aurora Store 4.4.1", + "assets": { + "links": [ + { + "name": "AuroraStore-4.4.1.apk", + "url": "https://gitlab.com/AuroraOSS/AuroraStore/-/releases/v4.4.1/downloads/AuroraStore-4.4.1.apk", + } + ] + }, + } + ] + + def test_get_latest_release(self, gitlab_entry: sideload.AppEntry): + source = sideload.GitLabSource(gitlab_entry) + with mock.patch("sideload._http_get", return_value=json.dumps(self.GITLAB_RELEASE).encode()): + release = source.get_latest_release() + assert release.version == "4.4.1" + assert "AuroraStore-4.4.1.apk" in release.download_url + assert release.expected_sha256 is None + + def test_get_latest_release_no_releases(self, gitlab_entry: sideload.AppEntry): + source = sideload.GitLabSource(gitlab_entry) + with mock.patch("sideload._http_get", return_value=b"[]"): + with pytest.raises(ValueError, match="no releases found"): + source.get_latest_release() + + def test_get_latest_release_no_apk(self, gitlab_entry: sideload.AppEntry): + source = sideload.GitLabSource(gitlab_entry) + data = [{"tag_name": "v1.0", "assets": {"links": [{"name": "source.zip"}]}}] + with mock.patch("sideload._http_get", return_value=json.dumps(data).encode()): + with pytest.raises(ValueError, match="no APK link"): + source.get_latest_release() + + def test_missing_project_url_raises(self): + entry = sideload.AppEntry(name="X", source="gitlab", app_id="x", config={}) + with pytest.raises(ValueError, match="project_url is required"): + sideload.GitLabSource(entry) + + def test_download_apk(self, gitlab_entry: sideload.AppEntry, cache: sideload.DownloadCache): + source = sideload.GitLabSource(gitlab_entry) + release = sideload.ReleaseInfo( + app_entry=gitlab_entry, + version="4.4.1", + download_url="https://example.com/x.apk", + expected_sha256=None, + ) + apk_data = b"gitlab-apk-content" + mock_resp = _mock_urlopen_response(apk_data) + with mock.patch("urllib.request.urlopen", return_value=mock_resp): + result = source.download_apk(release, cache) + assert result.is_file() + assert result.read_bytes() == apk_data + + +# ── ApkValidator tests ──────────────────────────────────────────────────────── + + +class TestApkValidator: + def test_validate_success(self, apk_file: Path): + validator = sideload.ApkValidator(apkanalyzer_bin="fake-apkanalyzer") + with mock.patch( + "sideload._run", + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout="org.example.test\n", stderr="" + ), + ): + err = validator.validate(apk_file, "org.example.test") + assert err is None + + def test_validate_mismatch(self, apk_file: Path): + validator = sideload.ApkValidator(apkanalyzer_bin="fake-apkanalyzer") + with mock.patch( + "sideload._run", + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout="com.other.app\n", stderr="" + ), + ): + err = validator.validate(apk_file, "org.example.test") + assert err is not None + assert "mismatch" in err + + def test_validate_apkanalyzer_fails(self, apk_file: Path): + validator = sideload.ApkValidator(apkanalyzer_bin="fake-apkanalyzer") + with mock.patch( + "sideload._run", + return_value=subprocess.CompletedProcess( + args=[], returncode=1, stdout="", stderr="command not found" + ), + ): + err = validator.validate(apk_file, "org.example.test") + assert err is not None + assert "failed" in err + + +# ── AdbManager tests ────────────────────────────────────────────────────────── + + +class TestAdbManager: + ADB_DEVICES_OUTPUT = ( + "List of devices attached\n" + "R5CT1234ABCD device usb:1-1 product:blueline model:Pixel_3 device:blueline transport_id:1\n" + "192.168.1.100:5555 device product:redfin model:Pixel_5 device:redfin transport_id:2\n" + ) + + def test_list_devices(self): + adb = sideload.AdbManager(binary="fake-adb") + with mock.patch( + "sideload._run", + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout=self.ADB_DEVICES_OUTPUT, stderr="" + ), + ): + devices = adb.list_devices() + assert len(devices) == 2 + assert devices[0].serial == "R5CT1234ABCD" + assert devices[0].model == "Pixel_3" + assert devices[0].transport == "usb" + assert devices[1].serial == "192.168.1.100:5555" + assert devices[1].model == "Pixel_5" + assert devices[1].transport == "wifi" + + def test_list_devices_empty(self): + adb = sideload.AdbManager(binary="fake-adb") + with mock.patch( + "sideload._run", + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout="List of devices attached\n", stderr="" + ), + ): + devices = adb.list_devices() + assert devices == [] + + def test_list_devices_skips_offline(self): + adb = sideload.AdbManager(binary="fake-adb") + output = "List of devices attached\nABCD1234 offline\n" + with mock.patch( + "sideload._run", + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout=output, stderr="" + ), + ): + devices = adb.list_devices() + assert devices == [] + + def test_connect_wifi_success(self): + adb = sideload.AdbManager(binary="fake-adb") + with mock.patch( + "sideload._run", + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout="connected to 192.168.1.100:5555\n", stderr="" + ), + ): + err = adb.connect_wifi("192.168.1.100:5555") + assert err is None + + def test_connect_wifi_failure(self): + adb = sideload.AdbManager(binary="fake-adb") + with mock.patch( + "sideload._run", + return_value=subprocess.CompletedProcess( + args=[], returncode=1, stdout="cannot connect\n", stderr="" + ), + ): + err = adb.connect_wifi("192.168.1.100:5555") + assert err is not None + assert "failed to connect" in err + + def test_install_success(self, apk_file: Path): + adb = sideload.AdbManager(binary="fake-adb") + with mock.patch( + "sideload._run", + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout="Performing Streamed Install\nSuccess\n", stderr="" + ), + ): + err = adb.install(apk_file, serial="R5CT1234ABCD") + assert err is None + + def test_install_failure(self, apk_file: Path): + adb = sideload.AdbManager(binary="fake-adb") + with mock.patch( + "sideload._run", + return_value=subprocess.CompletedProcess( + args=[], + returncode=1, + stdout="", + stderr="INSTALL_FAILED_INSUFFICIENT_STORAGE", + ), + ): + err = adb.install(apk_file) + assert err is not None + assert "INSTALL_FAILED" in err + + +# ── _make_source tests ──────────────────────────────────────────────────────── + + +class TestMakeSource: + def test_fdroid(self, app_entry: sideload.AppEntry): + s = sideload._make_source(app_entry) + assert isinstance(s, sideload.FdroidSource) + + def test_github(self, github_entry: sideload.AppEntry): + s = sideload._make_source(github_entry) + assert isinstance(s, sideload.GitHubSource) + + def test_gitlab(self, gitlab_entry: sideload.AppEntry): + s = sideload._make_source(gitlab_entry) + assert isinstance(s, sideload.GitLabSource) + + def test_unknown_source_raises(self): + entry = sideload.AppEntry(name="X", source="unknown", app_id="x", config={}) + with pytest.raises(ValueError, match="unknown source type"): + sideload._make_source(entry) + + +# ── sideload orchestration tests ────────────────────────────────────────────── + + +class TestSideload: + def test_dry_run_success( + self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path + ): + """Full dry-run flow: resolve, download, validate, skip install.""" + fdroid_index = { + "packages": { + "org.example.test": { + "1": { + "file": { + "name": "/repo/org.example.test_1.apk", + "sha256": _sha256(apk_file.read_bytes()), + } + } + } + } + } + mock_resp = _mock_urlopen_response(apk_file.read_bytes()) + + def mock_run(*args, **kwargs): + return subprocess.CompletedProcess( + args=list(args), returncode=0, stdout="org.example.test\n", stderr="" + ) + + with mock.patch( + "sideload._http_get", return_value=json.dumps(fdroid_index).encode() + ): + with mock.patch("urllib.request.urlopen", return_value=mock_resp): + with mock.patch("sideload._run", side_effect=mock_run): + result = sideload.sideload( + app_entry, config, dry_run=True, + validator=sideload.ApkValidator(apkanalyzer_bin="fake"), + ) + assert result == 0 + + def test_resolve_error(self, app_entry: sideload.AppEntry, config: sideload.Config): + with mock.patch("sideload._http_get", side_effect=RuntimeError("network down")): + result = sideload.sideload(app_entry, config, dry_run=True) + assert result == 1 + + def test_validation_fails( + self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path + ): + fdroid_index = { + "packages": { + "org.example.test": { + "1": { + "file": { + "name": "/repo/org.example.test_1.apk", + "sha256": _sha256(apk_file.read_bytes()), + } + } + } + } + } + mock_resp = _mock_urlopen_response(apk_file.read_bytes()) + + def mock_run(*args, **kwargs): + return subprocess.CompletedProcess( + args=list(args), returncode=0, stdout="com.wrong.id\n", stderr="" + ) + + with mock.patch( + "sideload._http_get", return_value=json.dumps(fdroid_index).encode() + ): + with mock.patch("urllib.request.urlopen", return_value=mock_resp): + with mock.patch("sideload._run", side_effect=mock_run): + result = sideload.sideload( + app_entry, config, dry_run=True, + validator=sideload.ApkValidator(apkanalyzer_bin="fake"), + ) + assert result == 1 + + def test_no_devices( + self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path + ): + fdroid_index = { + "packages": { + "org.example.test": { + "1": { + "file": { + "name": "/repo/org.example.test_1.apk", + "sha256": _sha256(apk_file.read_bytes()), + } + } + } + } + } + mock_resp = _mock_urlopen_response(apk_file.read_bytes()) + + call_count = 0 + + def mock_run(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return subprocess.CompletedProcess( + args=list(args), returncode=0, stdout="org.example.test\n", stderr="" + ) + return subprocess.CompletedProcess( + args=list(args), + returncode=0, + stdout="List of devices attached\n", + stderr="", + ) + + with mock.patch( + "sideload._http_get", return_value=json.dumps(fdroid_index).encode() + ): + with mock.patch("urllib.request.urlopen", return_value=mock_resp): + with mock.patch("sideload._run", side_effect=mock_run): + result = sideload.sideload( + app_entry, config, + validator=sideload.ApkValidator(apkanalyzer_bin="fake"), + adb_manager=sideload.AdbManager(binary="fake-adb"), + ) + assert result == 1 + + def test_device_not_found_by_serial( + self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path + ): + fdroid_index = { + "packages": { + "org.example.test": { + "1": { + "file": { + "name": "/repo/org.example.test_1.apk", + "sha256": _sha256(apk_file.read_bytes()), + } + } + } + } + } + mock_resp = _mock_urlopen_response(apk_file.read_bytes()) + + call_count = 0 + + def mock_run(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return subprocess.CompletedProcess( + args=list(args), returncode=0, stdout="org.example.test\n", stderr="" + ) + return subprocess.CompletedProcess( + args=list(args), + returncode=0, + stdout="List of devices attached\nABCD1234 device model:X\n", + stderr="", + ) + + with mock.patch( + "sideload._http_get", return_value=json.dumps(fdroid_index).encode() + ): + with mock.patch("urllib.request.urlopen", return_value=mock_resp): + with mock.patch("sideload._run", side_effect=mock_run): + result = sideload.sideload( + app_entry, config, + device_serial="NONEXISTENT", + validator=sideload.ApkValidator(apkanalyzer_bin="fake"), + adb_manager=sideload.AdbManager(binary="fake-adb"), + ) + assert result == 1 + + def test_full_install_success( + self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path + ): + fdroid_index = { + "packages": { + "org.example.test": { + "1": { + "file": { + "name": "/repo/org.example.test_1.apk", + "sha256": _sha256(apk_file.read_bytes()), + } + } + } + } + } + mock_resp = _mock_urlopen_response(apk_file.read_bytes()) + + call_count = 0 + + def mock_run(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return subprocess.CompletedProcess( + args=list(args), returncode=0, stdout="org.example.test\n", stderr="" + ) + if call_count == 2: + return subprocess.CompletedProcess( + args=list(args), + returncode=0, + stdout="List of devices attached\nABCD1234 device model:Pixel\n", + stderr="", + ) + return subprocess.CompletedProcess( + args=list(args), + returncode=0, + stdout="Performing Streamed Install\nSuccess\n", + stderr="", + ) + + with mock.patch( + "sideload._http_get", return_value=json.dumps(fdroid_index).encode() + ): + with mock.patch("urllib.request.urlopen", return_value=mock_resp): + with mock.patch("sideload._run", side_effect=mock_run): + result = sideload.sideload( + app_entry, config, + device_serial="ABCD1234", + validator=sideload.ApkValidator(apkanalyzer_bin="fake"), + adb_manager=sideload.AdbManager(binary="fake-adb"), + ) + assert result == 0 + + +# ── CLI tests ───────────────────────────────────────────────────────────────── + + +class TestCLI: + def test_list(self, tmp_config: Path, capsys): + result = sideload.main(["--config", str(tmp_config), "--list"]) + assert result == 0 + captured = capsys.readouterr() + assert "TestApp" in captured.out + + def test_config_not_found(self, capsys): + result = sideload.main(["--config", "/nonexistent/config.toml"]) + assert result == 1 + captured = capsys.readouterr() + assert "Config not found" in captured.err + + def test_app_not_found(self, tmp_config: Path, capsys): + result = sideload.main(["--config", str(tmp_config), "NonExistent"]) + assert result == 1 + captured = capsys.readouterr() + assert "not found" in captured.err + + def test_ambiguous_name(self, tmp_config_multi: Path, capsys): + apk_data = b"fake" + real_sha = _sha256(apk_data) + fdroid_index = { + "packages": { + "org.mozilla.fennec_fdroid": { + "1": {"file": {"name": "/repo/fennec.apk", "sha256": real_sha}} + } + } + } + mock_resp = _mock_urlopen_response(apk_data) + with mock.patch("sideload._http_get", return_value=json.dumps(fdroid_index).encode()): + with mock.patch("urllib.request.urlopen", return_value=mock_resp): + with mock.patch("sideload._run", return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout="org.mozilla.fennec_fdroid\n", stderr="" + )): + result = sideload.main( + ["--config", str(tmp_config_multi), "fennec", "--dry-run"] + ) + assert result == 0 + + def test_version(self, capsys): + with pytest.raises(SystemExit): + sideload.main(["--version"]) + + def test_build_parser(self): + parser = sideload.build_parser() + args = parser.parse_args(["--list"]) + assert args.list is True + args = parser.parse_args(["MyApp", "--dry-run"]) + assert args.app == "MyApp" + assert args.dry_run is True + + +# ── utility tests ───────────────────────────────────────────────────────────── + + +class TestUtilities: + def test_sha256_file(self, tmp_path: Path): + p = tmp_path / "test.bin" + p.write_bytes(b"hello world") + expected = hashlib.sha256(b"hello world").hexdigest() + assert sideload._sha256_file(p) == expected + + def test_make_apk_stem(self, app_entry: sideload.AppEntry): + release = sideload.ReleaseInfo( + app_entry=app_entry, + version="1.2.3", + download_url="https://example.com/x.apk", + expected_sha256=None, + ) + stem = sideload._make_apk_stem(release) + assert stem == "org.example.test_1.2.3" + + def test_make_apk_stem_sanitizes_slashes(self, app_entry: sideload.AppEntry): + release = sideload.ReleaseInfo( + app_entry=app_entry, + version="feature/branch", + download_url="https://example.com/x.apk", + expected_sha256=None, + ) + stem = sideload._make_apk_stem(release) + assert "/" not in stem + + def test_atomic_download(self, tmp_path: Path): + dest = tmp_path / "sub" / "file.apk" + data = b"downloaded-content" + mock_resp = _mock_urlopen_response(data) + with mock.patch("urllib.request.urlopen", return_value=mock_resp): + result = sideload._atomic_download("https://example.com/x.apk", dest) + assert result == dest + assert dest.read_bytes() == data + assert not dest.with_suffix(".apk.tmp").exists() + + def test_atomic_download_cleans_up_on_error(self, tmp_path: Path): + dest = tmp_path / "file.apk" + with mock.patch("urllib.request.urlopen", side_effect=RuntimeError("fail")): + with pytest.raises(RuntimeError): + sideload._atomic_download("https://example.com/x.apk", dest) + assert not dest.exists() + assert not dest.with_suffix(".apk.tmp").exists() + + def test_http_get_error(self): + with mock.patch( + "urllib.request.urlopen", + side_effect=urllib.error.HTTPError( + "https://example.com", 404, "Not Found", {}, None + ), + ): + with pytest.raises(RuntimeError, match="HTTP 404"): + sideload._http_get("https://example.com") + + def test_http_get_network_error(self): + with mock.patch( + "urllib.request.urlopen", + side_effect=urllib.error.URLError("connection refused"), + ): + with pytest.raises(RuntimeError, match="network error"): + sideload._http_get("https://example.com") + + def test_run_passes_args_correctly(self): + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=["echo", "hello"], returncode=0, stdout="hello\n", stderr="" + ) + sideload._run("echo", "hello") + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + assert call_args == ["echo", "hello"] + + +# ── AdbDevice dataclass ─────────────────────────────────────────────────────── + + +class TestAdbDevice: + def test_creation(self): + d = sideload.AdbDevice(serial="ABC123", model="Pixel", transport="usb") + assert d.serial == "ABC123" + assert d.model == "Pixel" + assert d.transport == "usb" + + +# ── ReleaseInfo dataclass ───────────────────────────────────────────────────── + + +class TestReleaseInfo: + def test_creation(self, app_entry: sideload.AppEntry): + r = sideload.ReleaseInfo( + app_entry=app_entry, + version="1.0", + download_url="https://example.com/x.apk", + expected_sha256="abc", + file_size=1000, + ) + assert r.version == "1.0" + assert r.file_size == 1000