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