Add ADB APK sideload tool

This commit is contained in:
2026-07-28 23:23:51 +02:00
commit 16e9eebc3d
7 changed files with 1901 additions and 0 deletions

730
src/sideload.py Normal file
View File

@@ -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 <ip>: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 <ip>: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())