Add ADB APK sideload tool
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.pytest_cache/
|
||||||
|
*.tmp
|
||||||
|
config.toml
|
||||||
31
AGENTS.md
Normal file
31
AGENTS.md
Normal file
@@ -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.
|
||||||
62
README.md
Normal file
62
README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
40
config.example.toml
Normal file
40
config.example.toml
Normal file
@@ -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"
|
||||||
21
pyproject.toml
Normal file
21
pyproject.toml
Normal file
@@ -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"
|
||||||
730
src/sideload.py
Normal file
730
src/sideload.py
Normal 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())
|
||||||
1009
tests/test_sideload.py
Normal file
1009
tests/test_sideload.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user