Compare commits
2 Commits
968da658aa
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
29f0684127
|
|||
|
692138d0dd
|
@@ -13,11 +13,14 @@
|
|||||||
- Run the CLI help after editable installation with `.venv/bin/sideload --help`.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
- `README.md` is the canonical English user documentation; `README.fr.md` is its French translation. Keep the language links and user-facing behavior synchronized when changing the CLI.
|
||||||
|
|
||||||
## Runtime Constraints
|
## Runtime Constraints
|
||||||
|
|
||||||
- Real installations require `adb` and either `aapt2` or `aapt` in `PATH`; tests must continue to mock them rather than require a phone or Android SDK.
|
- Real installations require `adb` and either `aapt2` or `aapt` in `PATH`; tests must continue to mock them rather than require a phone or Android SDK.
|
||||||
- `aapt2 dump packagename` (with `aapt dump badging` fallback) is mandatory pre-install validation; never add an installation path that bypasses package-ID validation.
|
- `aapt2 dump packagename` (with `aapt dump badging` fallback) is mandatory pre-install validation; never add an installation path that bypasses package-ID validation.
|
||||||
|
- `--update-all` must read the installed `versionCode` with `adb shell dumpsys package`, read the remote APK `versionCode` with `aapt2 dump badging` (falling back to `aapt dump badging`), and install only when the remote code is strictly greater.
|
||||||
|
- `--update-all` must report configured apps that are not installed without downloading them, continue after per-app errors, and print a final summary. Device preparation and selection must happen once per batch.
|
||||||
- ADB commands must remain argument lists passed with `shell=False`; do not construct shell command strings or use `shell=True`.
|
- 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.
|
- 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.
|
- Wi-Fi ADB uses an explicit `HOST:PORT` from `[adb]`; do not add network scanning or implicit device discovery.
|
||||||
@@ -29,3 +32,4 @@
|
|||||||
- F-Droid resolution reads `index-v2.json` and versions are nested under `packages[package].versions`; keep numeric `versionCode` ordering and correct URL joining.
|
- 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.
|
- 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 Android SDK tool behavior is mocked in the unit tests; add response fixtures or mocks for new integrations instead of live API tests.
|
- Network, ADB, and Android SDK tool behavior is mocked in the unit tests; add response fixtures or mocks for new integrations instead of live API tests.
|
||||||
|
- Tests for `--update-all` should cover absent packages, equal/older/newer version codes, aapt2 fallback, dry-run, partial failures, summary output, and the single-device preparation path.
|
||||||
|
|||||||
91
README.fr.md
Normal file
91
README.fr.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# adb-sideload-free-apps
|
||||||
|
|
||||||
|
Installer des applications Android libres avec ADB depuis F-Droid, les releases GitHub,
|
||||||
|
GitLab ou Codeberg, ou des pages HTTPS génériques.
|
||||||
|
|
||||||
|
[English](README.md)
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
- Python 3.11 ou supérieur
|
||||||
|
- `adb` (Android Debug Bridge) dans le `PATH`
|
||||||
|
- `aapt2` ou `aapt` (Android SDK Build Tools) dans le `PATH`
|
||||||
|
|
||||||
|
## Démarrage rapide
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install -e .
|
||||||
|
cp config.example.toml config.toml
|
||||||
|
# Modifier config.toml pour ajouter les applications
|
||||||
|
python -m sideload
|
||||||
|
```
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
```text
|
||||||
|
sideload [APP] [--list] [--update-all] [--device SERIAL] [--dry-run] [--config PATH]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `APP` : nom de l’application dans la configuration (omettre pour la sélection interactive)
|
||||||
|
- `--list` : lister les applications configurées
|
||||||
|
- `--device`, `-s` : numéro de série du périphérique ADB
|
||||||
|
- `--dry-run` : résoudre, télécharger et valider sans installer
|
||||||
|
- `--update-all` : inspecter toutes les applications configurées sur un périphérique choisi
|
||||||
|
une seule fois et mettre à jour uniquement les applications installées dont l’APK distant
|
||||||
|
possède un `versionCode` supérieur (incompatible avec `APP` et `--list`)
|
||||||
|
- `--config`, `-c` : chemin vers `config.toml` (par défaut : `./config.toml`)
|
||||||
|
|
||||||
|
`--update-all` sélectionne le périphérique ADB une seule fois, puis vérifie chaque paquet
|
||||||
|
configuré avec `adb shell dumpsys package`. Les applications absentes du périphérique sont
|
||||||
|
signalées et ignorées sans téléchargement. Une application installée est mise à jour
|
||||||
|
uniquement lorsque l’APK distant validé possède un `versionCode` strictement supérieur ; les
|
||||||
|
versions égales ou inférieures sont ignorées. Une erreur sur une application n’interrompt pas
|
||||||
|
le traitement des suivantes. La commande affiche un bilan et retourne un code non nul si une
|
||||||
|
mise à jour a échoué.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Voir `config.example.toml`. Les sources prises en charge sont :
|
||||||
|
|
||||||
|
| Source | Champs requis |
|
||||||
|
| --- | --- |
|
||||||
|
| `fdroid` | `url` (page F-Droid) |
|
||||||
|
| `github` | `url` (dépôt) |
|
||||||
|
| `gitlab` | `url` (projet) |
|
||||||
|
| `codeberg` | `url` (dépôt Codeberg) |
|
||||||
|
| `generic` | `url`, `asset_pattern` (page ou répertoire HTTPS) |
|
||||||
|
|
||||||
|
Chaque application doit aussi définir `name` et `package`. Pour GitHub, GitLab et Codeberg,
|
||||||
|
définir `asset_pattern` lorsqu’une release contient plusieurs APK compatibles. Les APK de
|
||||||
|
debug, de test et non signés sont rejetés par défaut.
|
||||||
|
|
||||||
|
La source `generic` suit les liens HTML HTTPS depuis `url`. Utiliser
|
||||||
|
`intermediate_patterns` dans l’ordre pour parcourir les répertoires ; lorsque plusieurs liens
|
||||||
|
correspondent, un motif avec des groupes de capture numériques sélectionne le plus grand tuple
|
||||||
|
numérique. `asset_pattern` est obligatoire et doit correspondre à un seul APK final. Les champs
|
||||||
|
optionnels `headers` et `sha256` sont pris en charge ; les valeurs des en-têtes ne sont jamais
|
||||||
|
écrites dans les logs.
|
||||||
|
|
||||||
|
`aapt2` valide l’identifiant du paquet APK avant l’installation, avec `aapt` comme solution de
|
||||||
|
secours pour les anciennes versions des Android SDK Build Tools. Au moins un des deux outils
|
||||||
|
doit être disponible dans le `PATH`. Le script refuse d’installer un APK si aucun outil ne peut
|
||||||
|
le valider.
|
||||||
|
|
||||||
|
Avec `--update-all`, `aapt2 dump badging` (avec fallback `aapt dump badging`) lit également le
|
||||||
|
`versionCode` de l’APK distant avant de décider si une installation est nécessaire.
|
||||||
|
|
||||||
|
Pour utiliser ADB en Wi-Fi, configurer une adresse explicite après avoir activé le débogage sans
|
||||||
|
fil :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[adb]
|
||||||
|
mode = "wifi"
|
||||||
|
address = "192.168.1.42:5555"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pytest
|
||||||
|
python -m pytest tests/ -v
|
||||||
|
```
|
||||||
18
README.md
18
README.md
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Install free Android apps via ADB from F-Droid, GitHub Releases, GitLab Releases, Codeberg Releases, or generic HTTPS pages.
|
Install free Android apps via ADB from F-Droid, GitHub Releases, GitLab Releases, Codeberg Releases, or generic HTTPS pages.
|
||||||
|
|
||||||
|
[Français](README.fr.md)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.11+
|
- Python 3.11+
|
||||||
@@ -20,15 +22,24 @@ python -m sideload
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
sideload [APP] [--list] [--device SERIAL] [--dry-run] [--config PATH]
|
sideload [APP] [--list] [--update-all] [--device SERIAL] [--dry-run] [--config PATH]
|
||||||
```
|
```
|
||||||
|
|
||||||
- `APP` — app name from config (omit for interactive selection)
|
- `APP` — app name from config (omit for interactive selection)
|
||||||
- `--list` — list configured apps
|
- `--list` — list configured apps
|
||||||
- `--device`, `-s` — ADB device serial
|
- `--device`, `-s` — ADB device serial
|
||||||
- `--dry-run` — resolve, download, validate without installing
|
- `--dry-run` — resolve, download, validate without installing
|
||||||
|
- `--update-all` — inspect every configured app on one selected device and update only
|
||||||
|
installed apps whose remote APK has a greater `versionCode` (incompatible with `APP` and `--list`)
|
||||||
- `--config`, `-c` — path to config.toml (default: ./config.toml)
|
- `--config`, `-c` — path to config.toml (default: ./config.toml)
|
||||||
|
|
||||||
|
`--update-all` selects the ADB device once, then checks every configured package with
|
||||||
|
`adb shell dumpsys package`. Apps that are not installed are reported and skipped without
|
||||||
|
downloading anything. Installed apps are updated only when the validated remote APK has a
|
||||||
|
strictly greater `versionCode`; equal or lower versions are skipped. Errors for one app do
|
||||||
|
not stop the other apps, and the command prints a summary and returns a non-zero status if
|
||||||
|
an update failed.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
See `config.example.toml`. Supported sources:
|
See `config.example.toml`. Supported sources:
|
||||||
@@ -37,7 +48,7 @@ See `config.example.toml`. Supported sources:
|
|||||||
| -------- | ---------------------------- |
|
| -------- | ---------------------------- |
|
||||||
| `fdroid` | `url` (page F-Droid) |
|
| `fdroid` | `url` (page F-Droid) |
|
||||||
| `github` | `url` (repository) |
|
| `github` | `url` (repository) |
|
||||||
| `gitlab` | `url` (projet) |
|
| `gitlab` | `url` (project) |
|
||||||
| `codeberg` | `url` (repository Codeberg) |
|
| `codeberg` | `url` (repository Codeberg) |
|
||||||
| `generic` | `url`, `asset_pattern` (HTTPS page or directory) |
|
| `generic` | `url`, `asset_pattern` (HTTPS page or directory) |
|
||||||
|
|
||||||
@@ -56,6 +67,9 @@ pattern with numeric capture groups selects the greatest numeric tuple.
|
|||||||
must be available in `PATH`. The script refuses to install an APK when neither
|
must be available in `PATH`. The script refuses to install an APK when neither
|
||||||
tool can validate it.
|
tool can validate it.
|
||||||
|
|
||||||
|
For `--update-all`, `aapt2 dump badging` (with `aapt dump badging` fallback) also reads the
|
||||||
|
remote APK `versionCode` before deciding whether an installation is needed.
|
||||||
|
|
||||||
For Wi-Fi ADB, configure an explicit address after enabling wireless debugging:
|
For Wi-Fi ADB, configure an explicit address after enabling wireless debugging:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
|||||||
164
src/sideload.py
164
src/sideload.py
@@ -118,6 +118,10 @@ class AdbDevice:
|
|||||||
transport: str # "usb" or "wifi"
|
transport: str # "usb" or "wifi"
|
||||||
|
|
||||||
|
|
||||||
|
class InstalledPackageError(RuntimeError):
|
||||||
|
"""The installed package state could not be read safely."""
|
||||||
|
|
||||||
|
|
||||||
# ── utilities ─────────────────────────────────────────────────────────────────
|
# ── utilities ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -210,7 +214,8 @@ def _run(
|
|||||||
*args: str, check: bool = True, timeout: int = 30, **kwargs
|
*args: str, check: bool = True, timeout: int = 30, **kwargs
|
||||||
) -> subprocess.CompletedProcess:
|
) -> subprocess.CompletedProcess:
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
list(args), check=check, timeout=timeout, capture_output=True, text=True, **kwargs
|
list(args), check=check, timeout=timeout, capture_output=True, text=True,
|
||||||
|
shell=False, **kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -907,6 +912,24 @@ class ApkValidator:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def version_code(self, apk_path: Path) -> int:
|
||||||
|
"""Read the APK versionCode, using aapt2 then the legacy aapt tool."""
|
||||||
|
errors: list[str] = []
|
||||||
|
for binary in (self.aapt2_bin, self.aapt_bin):
|
||||||
|
try:
|
||||||
|
result = _run(binary, "dump", "badging", str(apk_path), check=False, timeout=15)
|
||||||
|
except FileNotFoundError:
|
||||||
|
errors.append(f"{binary} was not found")
|
||||||
|
continue
|
||||||
|
if result.returncode != 0:
|
||||||
|
errors.append(result.stderr.strip() or f"{binary} failed")
|
||||||
|
continue
|
||||||
|
match = re.search(r"versionCode\s*=\s*['\"](\d+)['\"]", result.stdout)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
errors.append(f"{binary} output has no parseable versionCode")
|
||||||
|
raise ValueError("unable to read APK versionCode: " + "; ".join(errors))
|
||||||
|
|
||||||
|
|
||||||
# ── ADB management ────────────────────────────────────────────────────────────
|
# ── ADB management ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -951,6 +974,23 @@ class AdbManager:
|
|||||||
return f"install failed: {err}"
|
return f"install failed: {err}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def installed_version_code(self, package: str, serial: str | None = None) -> int | None:
|
||||||
|
"""Return versionCode, or None when dumpsys confirms the app is absent."""
|
||||||
|
cmd = [self.binary]
|
||||||
|
if serial:
|
||||||
|
cmd += ["-s", serial]
|
||||||
|
cmd += ["shell", "dumpsys", "package", package]
|
||||||
|
result = _run(*cmd, check=False, timeout=30)
|
||||||
|
output = (result.stdout or "") + "\n" + (result.stderr or "")
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise InstalledPackageError(output.strip() or "dumpsys failed")
|
||||||
|
if re.search(r"unable to find package|package .* not found", output, re.IGNORECASE):
|
||||||
|
return None
|
||||||
|
match = re.search(r"\bversionCode=(\d+)\b", result.stdout or "")
|
||||||
|
if not match:
|
||||||
|
raise InstalledPackageError("installed package versionCode is missing or invalid")
|
||||||
|
return int(match.group(1))
|
||||||
|
|
||||||
|
|
||||||
# ── interactive helpers ───────────────────────────────────────────────────────
|
# ── interactive helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1035,6 +1075,115 @@ def _download_progress(downloaded: int, total: int | None) -> None:
|
|||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_device(
|
||||||
|
config: Config, adb: AdbManager, device_serial: str | None = None
|
||||||
|
) -> AdbDevice:
|
||||||
|
"""Connect (at most once) and select the single device used by an operation."""
|
||||||
|
if config.adb_mode == "wifi":
|
||||||
|
if not config.adb_address or not _validate_adb_address(config.adb_address):
|
||||||
|
raise ValueError("Invalid adb.address; expected HOST:PORT")
|
||||||
|
error = adb.connect_wifi(config.adb_address)
|
||||||
|
if error:
|
||||||
|
raise RuntimeError(error)
|
||||||
|
devices = adb.list_devices()
|
||||||
|
if not devices:
|
||||||
|
raise RuntimeError("No devices found")
|
||||||
|
if device_serial:
|
||||||
|
for device in devices:
|
||||||
|
if device.serial == device_serial:
|
||||||
|
return device
|
||||||
|
serials = ", ".join(d.serial for d in devices)
|
||||||
|
raise RuntimeError(f"Device '{device_serial}' not found. Available: {serials}")
|
||||||
|
return _choose_device(adb, devices)
|
||||||
|
|
||||||
|
|
||||||
|
def _download_and_validate(
|
||||||
|
app: AppEntry,
|
||||||
|
config: Config,
|
||||||
|
adb_validator: ApkValidator,
|
||||||
|
*,
|
||||||
|
progress: Callable[[int, int | None], None] | None = None,
|
||||||
|
) -> tuple[ReleaseInfo, Path]:
|
||||||
|
source = _make_source(app)
|
||||||
|
release = source.get_latest_release()
|
||||||
|
apk_path = source.download_apk(release, DownloadCache(config.cache_dir), progress=progress)
|
||||||
|
validation_error = adb_validator.validate(apk_path, app.app_id)
|
||||||
|
if validation_error:
|
||||||
|
raise ValueError(validation_error)
|
||||||
|
return release, apk_path
|
||||||
|
|
||||||
|
|
||||||
|
def update_all(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
device_serial: str | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
adb_manager: AdbManager | None = None,
|
||||||
|
validator: ApkValidator | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Update installed configured apps, continuing after per-app failures."""
|
||||||
|
adb = adb_manager or AdbManager(config.adb_binary)
|
||||||
|
apk_validator = validator or ApkValidator()
|
||||||
|
try:
|
||||||
|
device = _prepare_device(config, adb, device_serial)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Error detecting devices: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"Updating {len(config.apps)} apps on {device.model} ({device.serial})…")
|
||||||
|
failures = 0
|
||||||
|
counts: dict[str, int] = {key: 0 for key in ("updated", "current", "absent", "failed")}
|
||||||
|
|
||||||
|
for app in config.apps:
|
||||||
|
print(f"\n{app.name} ({app.app_id})")
|
||||||
|
try:
|
||||||
|
installed = adb.installed_version_code(app.app_id, device.serial)
|
||||||
|
if installed is None:
|
||||||
|
counts["absent"] += 1
|
||||||
|
print(" not installed (skipped; no download)")
|
||||||
|
continue
|
||||||
|
print(f" installed versionCode: {installed}")
|
||||||
|
progress_seen = False
|
||||||
|
|
||||||
|
def report_progress(downloaded: int, total: int | None) -> None:
|
||||||
|
nonlocal progress_seen
|
||||||
|
progress_seen = True
|
||||||
|
_download_progress(downloaded, total)
|
||||||
|
|
||||||
|
release, apk_path = _download_and_validate(
|
||||||
|
app, config, apk_validator, progress=report_progress
|
||||||
|
)
|
||||||
|
if progress_seen:
|
||||||
|
sys.stderr.write("\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
remote_code = apk_validator.version_code(apk_path)
|
||||||
|
print(f" remote versionCode: {remote_code}")
|
||||||
|
if remote_code <= installed:
|
||||||
|
counts["current"] += 1
|
||||||
|
state = "already up to date" if remote_code == installed else "local version is newer"
|
||||||
|
print(f" {state}; skipped")
|
||||||
|
continue
|
||||||
|
if dry_run:
|
||||||
|
counts["updated"] += 1
|
||||||
|
print(" [dry-run] would install")
|
||||||
|
continue
|
||||||
|
error = adb.install(apk_path, device.serial)
|
||||||
|
if error:
|
||||||
|
raise RuntimeError(error)
|
||||||
|
counts["updated"] += 1
|
||||||
|
print(f" updated to {remote_code}")
|
||||||
|
except Exception as exc:
|
||||||
|
counts["failed"] += 1
|
||||||
|
failures += 1
|
||||||
|
print(f" FAILED: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"\nSummary: "
|
||||||
|
f"{counts['updated']} updated, {counts['current']} unchanged, "
|
||||||
|
f"{counts['absent']} not installed, {counts['failed']} failed"
|
||||||
|
)
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
def sideload(
|
def sideload(
|
||||||
app: AppEntry,
|
app: AppEntry,
|
||||||
config: Config,
|
config: Config,
|
||||||
@@ -1168,6 +1317,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="resolve/download/validate without installing",
|
help="resolve/download/validate without installing",
|
||||||
)
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--update-all",
|
||||||
|
action="store_true",
|
||||||
|
help="update every configured app already installed on one ADB device",
|
||||||
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--config",
|
"--config",
|
||||||
"-c",
|
"-c",
|
||||||
@@ -1185,6 +1339,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.update_all and args.app:
|
||||||
|
parser.error("--update-all cannot be combined with the APP argument")
|
||||||
|
if args.update_all and args.list:
|
||||||
|
parser.error("--update-all cannot be combined with --list")
|
||||||
|
|
||||||
if not args.config.is_file():
|
if not args.config.is_file():
|
||||||
print(f"Config not found: {args.config}", file=sys.stderr)
|
print(f"Config not found: {args.config}", file=sys.stderr)
|
||||||
print(
|
print(
|
||||||
@@ -1204,6 +1363,9 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
print(f" {a.name} ({a.source}) -> {a.app_id}")
|
print(f" {a.name} ({a.source}) -> {a.app_id}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if args.update_all:
|
||||||
|
return update_all(config, device_serial=args.device, dry_run=args.dry_run)
|
||||||
|
|
||||||
if args.app:
|
if args.app:
|
||||||
matches = [a for a in config.apps if a.name.lower() == args.app.lower()]
|
matches = [a for a in config.apps if a.name.lower() == args.app.lower()]
|
||||||
if not matches:
|
if not matches:
|
||||||
|
|||||||
@@ -844,6 +844,33 @@ class TestApkValidator:
|
|||||||
assert err is not None
|
assert err is not None
|
||||||
assert "failed" in err
|
assert "failed" in err
|
||||||
|
|
||||||
|
def test_version_code_aapt2(self, apk_file: Path):
|
||||||
|
validator = sideload.ApkValidator(aapt2_bin="aapt2", aapt_bin="aapt")
|
||||||
|
with mock.patch(
|
||||||
|
"sideload._run",
|
||||||
|
return_value=subprocess.CompletedProcess(
|
||||||
|
args=["aapt2"], returncode=0,
|
||||||
|
stdout="package: name='org.example.test' versionCode='42' versionName='x'\n",
|
||||||
|
stderr="",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
assert validator.version_code(apk_file) == 42
|
||||||
|
|
||||||
|
def test_version_code_falls_back_to_aapt(self, apk_file: Path):
|
||||||
|
validator = sideload.ApkValidator(aapt2_bin="aapt2", aapt_bin="aapt")
|
||||||
|
with mock.patch(
|
||||||
|
"sideload._run",
|
||||||
|
side_effect=[
|
||||||
|
subprocess.CompletedProcess(args=["aapt2"], returncode=1, stdout="", stderr="bad"),
|
||||||
|
subprocess.CompletedProcess(
|
||||||
|
args=["aapt"], returncode=0,
|
||||||
|
stdout="package: name='org.example.test' versionCode='7' versionName='x'\n",
|
||||||
|
stderr="",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
):
|
||||||
|
assert validator.version_code(apk_file) == 7
|
||||||
|
|
||||||
|
|
||||||
# ── AdbManager tests ──────────────────────────────────────────────────────────
|
# ── AdbManager tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -944,6 +971,32 @@ class TestAdbManager:
|
|||||||
assert err is not None
|
assert err is not None
|
||||||
assert "INSTALL_FAILED" in err
|
assert "INSTALL_FAILED" in err
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("output", "expected"),
|
||||||
|
[
|
||||||
|
("Packages:\n versionCode=123 minSdk=26\n", 123),
|
||||||
|
("Unable to find package org.missing\n", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_installed_version_code(self, output: str, expected: int | None):
|
||||||
|
adb = sideload.AdbManager(binary="fake-adb")
|
||||||
|
with mock.patch(
|
||||||
|
"sideload._run",
|
||||||
|
return_value=subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=0, stdout=output, stderr=""
|
||||||
|
),
|
||||||
|
):
|
||||||
|
assert adb.installed_version_code("org.example.test", "SERIAL") == expected
|
||||||
|
|
||||||
|
def test_installed_version_code_failure(self):
|
||||||
|
adb = sideload.AdbManager(binary="fake-adb")
|
||||||
|
with mock.patch(
|
||||||
|
"sideload._run",
|
||||||
|
return_value=subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="offline"),
|
||||||
|
):
|
||||||
|
with pytest.raises(sideload.InstalledPackageError):
|
||||||
|
adb.installed_version_code("org.example.test", "SERIAL")
|
||||||
|
|
||||||
|
|
||||||
# ── _make_source tests ────────────────────────────────────────────────────────
|
# ── _make_source tests ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -982,6 +1035,58 @@ class TestMakeSource:
|
|||||||
|
|
||||||
|
|
||||||
class TestSideload:
|
class TestSideload:
|
||||||
|
def test_update_all_mixed_batch_continues(self, tmp_path: Path, apk_file: Path, capsys):
|
||||||
|
apps = [
|
||||||
|
sideload.AppEntry(f"App{i}", "fdroid", f"org.example.{i}", {})
|
||||||
|
for i in range(4)
|
||||||
|
]
|
||||||
|
config = sideload.Config(apps, tmp_path / "cache", "fake-adb")
|
||||||
|
device = sideload.AdbDevice("SERIAL", "Pixel", "usb")
|
||||||
|
|
||||||
|
class FakeAdb:
|
||||||
|
def __init__(self):
|
||||||
|
self.installed = iter([10, 5, None])
|
||||||
|
self.installs: list[str] = []
|
||||||
|
|
||||||
|
def list_devices(self):
|
||||||
|
return [device]
|
||||||
|
|
||||||
|
def installed_version_code(self, package, serial):
|
||||||
|
if package.endswith("3"):
|
||||||
|
raise RuntimeError("dumpsys unavailable")
|
||||||
|
return next(self.installed)
|
||||||
|
|
||||||
|
def install(self, path, serial):
|
||||||
|
self.installs.append(serial)
|
||||||
|
return None
|
||||||
|
|
||||||
|
class FakeValidator:
|
||||||
|
def validate(self, path, package):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def version_code(self, path):
|
||||||
|
return 11 if path.name == "remote-1.apk" else 5
|
||||||
|
|
||||||
|
adb = FakeAdb()
|
||||||
|
release = sideload.ReleaseInfo(apps[0], "x", "https://example.test/x.apk", None)
|
||||||
|
downloaded: list[str] = []
|
||||||
|
|
||||||
|
def fake_download(app, cfg, validator, *, progress=None):
|
||||||
|
downloaded.append(app.name)
|
||||||
|
path = tmp_path / ("remote-1.apk" if app.name == "App0" else "remote-2.apk")
|
||||||
|
path.write_bytes(b"apk")
|
||||||
|
return release, path
|
||||||
|
|
||||||
|
with mock.patch("sideload._download_and_validate", side_effect=fake_download):
|
||||||
|
result = sideload.update_all(config, adb_manager=adb, validator=FakeValidator())
|
||||||
|
assert result == 1
|
||||||
|
assert downloaded == ["App0", "App1"]
|
||||||
|
assert adb.installs == ["SERIAL"]
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "1 updated" in captured.out
|
||||||
|
# The complete four-counter summary is printed on a single line.
|
||||||
|
assert "Summary: 1 updated, 1 unchanged, 1 not installed, 1 failed" in captured.out
|
||||||
|
|
||||||
def test_dry_run_success(
|
def test_dry_run_success(
|
||||||
self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path
|
self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path
|
||||||
):
|
):
|
||||||
@@ -1199,6 +1304,169 @@ class TestSideload:
|
|||||||
assert result == 0
|
assert result == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateAll:
|
||||||
|
"""Focused coverage for update_all decision paths and failure handling."""
|
||||||
|
|
||||||
|
def _device(self) -> sideload.AdbDevice:
|
||||||
|
return sideload.AdbDevice("SERIAL", "Pixel", "usb")
|
||||||
|
|
||||||
|
def _run_updates(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
capsys,
|
||||||
|
*,
|
||||||
|
installed: int | None = 10,
|
||||||
|
remote_code: int = 11,
|
||||||
|
install_error: str | None = None,
|
||||||
|
version_code_error: Exception | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> tuple[int, list[tuple[Path, str]], pytest.CaptureFixture]:
|
||||||
|
apps = [sideload.AppEntry("App0", "fdroid", "org.example.0", {})]
|
||||||
|
config = sideload.Config(apps, tmp_path / "cache", "fake-adb")
|
||||||
|
device = self._device()
|
||||||
|
|
||||||
|
class FakeAdb:
|
||||||
|
def __init__(self):
|
||||||
|
self.install_calls: list[tuple[Path, str]] = []
|
||||||
|
|
||||||
|
def list_devices(self):
|
||||||
|
return [device]
|
||||||
|
|
||||||
|
def installed_version_code(self, package, serial):
|
||||||
|
return installed
|
||||||
|
|
||||||
|
def install(self, path, serial):
|
||||||
|
self.install_calls.append((path, serial))
|
||||||
|
return install_error
|
||||||
|
|
||||||
|
class FakeValidator:
|
||||||
|
def validate(self, path, package):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def version_code(self, path):
|
||||||
|
if version_code_error is not None:
|
||||||
|
raise version_code_error
|
||||||
|
return remote_code
|
||||||
|
|
||||||
|
adb = FakeAdb()
|
||||||
|
apk_path = tmp_path / "remote.apk"
|
||||||
|
apk_path.write_bytes(b"apk")
|
||||||
|
release = sideload.ReleaseInfo(apps[0], "x", "https://example.test/x.apk", None)
|
||||||
|
with mock.patch("sideload._download_and_validate", return_value=(release, apk_path)):
|
||||||
|
result = sideload.update_all(
|
||||||
|
config,
|
||||||
|
adb_manager=adb,
|
||||||
|
validator=FakeValidator(),
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
return result, adb.install_calls, capsys.readouterr()
|
||||||
|
|
||||||
|
def test_dry_run_no_install_but_update_status(self, tmp_path, capsys):
|
||||||
|
result, install_calls, captured = self._run_updates(
|
||||||
|
tmp_path, capsys, installed=10, remote_code=11, dry_run=True
|
||||||
|
)
|
||||||
|
assert result == 0
|
||||||
|
assert install_calls == []
|
||||||
|
assert " remote versionCode: 11" in captured.out
|
||||||
|
assert "[dry-run] would install" in captured.out
|
||||||
|
assert "Summary: 1 updated, 0 unchanged, 0 not installed, 0 failed" in captured.out
|
||||||
|
|
||||||
|
def test_version_code_read_error_counts_failed(self, tmp_path, capsys):
|
||||||
|
error = ValueError("unable to read APK versionCode: no tools available")
|
||||||
|
result, install_calls, captured = self._run_updates(
|
||||||
|
tmp_path, capsys, installed=10, remote_code=11, version_code_error=error
|
||||||
|
)
|
||||||
|
assert result == 1
|
||||||
|
assert install_calls == []
|
||||||
|
assert "unable to read APK versionCode" in captured.err
|
||||||
|
assert "Summary: 0 updated, 0 unchanged, 0 not installed, 1 failed" in captured.out
|
||||||
|
|
||||||
|
def test_remote_version_equal_is_current(self, tmp_path, capsys):
|
||||||
|
result, install_calls, captured = self._run_updates(
|
||||||
|
tmp_path, capsys, installed=10, remote_code=10
|
||||||
|
)
|
||||||
|
assert result == 0
|
||||||
|
assert install_calls == []
|
||||||
|
assert " already up to date; skipped" in captured.out
|
||||||
|
assert "Summary: 0 updated, 1 unchanged, 0 not installed, 0 failed" in captured.out
|
||||||
|
|
||||||
|
def test_remote_version_lower_is_current(self, tmp_path, capsys):
|
||||||
|
result, install_calls, captured = self._run_updates(
|
||||||
|
tmp_path, capsys, installed=10, remote_code=5
|
||||||
|
)
|
||||||
|
assert result == 0
|
||||||
|
assert install_calls == []
|
||||||
|
assert " local version is newer; skipped" in captured.out
|
||||||
|
assert "Summary: 0 updated, 1 unchanged, 0 not installed, 0 failed" in captured.out
|
||||||
|
|
||||||
|
def test_install_error_returned_counts_failed(self, tmp_path, capsys):
|
||||||
|
result, install_calls, captured = self._run_updates(
|
||||||
|
tmp_path,
|
||||||
|
capsys,
|
||||||
|
installed=10,
|
||||||
|
remote_code=11,
|
||||||
|
install_error="install failed: INSTALL_FAILED_TEST",
|
||||||
|
)
|
||||||
|
assert result == 1
|
||||||
|
assert len(install_calls) == 1
|
||||||
|
assert "FAILED: install failed: INSTALL_FAILED_TEST" in captured.err
|
||||||
|
assert "Summary: 0 updated, 0 unchanged, 0 not installed, 1 failed" in captured.out
|
||||||
|
|
||||||
|
def test_absent_app_skips_download(self, tmp_path, capsys):
|
||||||
|
result, install_calls, captured = self._run_updates(tmp_path, capsys, installed=None)
|
||||||
|
assert result == 0
|
||||||
|
assert install_calls == []
|
||||||
|
assert " not installed (skipped; no download)" in captured.out
|
||||||
|
assert "Summary: 0 updated, 0 unchanged, 1 not installed, 0 failed" in captured.out
|
||||||
|
|
||||||
|
def test_device_prep_failure_no_devices(self, tmp_path, capsys):
|
||||||
|
apps = [sideload.AppEntry("App0", "fdroid", "org.example.0", {})]
|
||||||
|
config = sideload.Config(apps, tmp_path / "cache", "fake-adb")
|
||||||
|
|
||||||
|
class FakeAdb:
|
||||||
|
def list_devices(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = sideload.update_all(config, adb_manager=FakeAdb())
|
||||||
|
assert result == 1
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "Error detecting devices: No devices found" in captured.err
|
||||||
|
assert "Summary:" not in captured.out
|
||||||
|
|
||||||
|
def test_device_prep_failure_serial_not_found(self, tmp_path, capsys):
|
||||||
|
apps = [sideload.AppEntry("App0", "fdroid", "org.example.0", {})]
|
||||||
|
config = sideload.Config(apps, tmp_path / "cache", "fake-adb")
|
||||||
|
device = self._device()
|
||||||
|
|
||||||
|
class FakeAdb:
|
||||||
|
def list_devices(self):
|
||||||
|
return [device]
|
||||||
|
|
||||||
|
result = sideload.update_all(config, adb_manager=FakeAdb(), device_serial="GONE")
|
||||||
|
assert result == 1
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "Error detecting devices: Device 'GONE' not found. Available: SERIAL" in captured.err
|
||||||
|
|
||||||
|
def test_device_prep_failure_wifi_connect(self, tmp_path, capsys):
|
||||||
|
apps = [sideload.AppEntry("App0", "fdroid", "org.example.0", {})]
|
||||||
|
config = sideload.Config(
|
||||||
|
apps,
|
||||||
|
tmp_path / "cache",
|
||||||
|
"fake-adb",
|
||||||
|
adb_mode="wifi",
|
||||||
|
adb_address="192.168.1.10:5555",
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeAdb:
|
||||||
|
def connect_wifi(self, address):
|
||||||
|
return "failed to connect: timeout"
|
||||||
|
|
||||||
|
result = sideload.update_all(config, adb_manager=FakeAdb())
|
||||||
|
assert result == 1
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "Error detecting devices: failed to connect: timeout" in captured.err
|
||||||
|
|
||||||
|
|
||||||
# ── CLI tests ─────────────────────────────────────────────────────────────────
|
# ── CLI tests ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -1254,6 +1522,54 @@ class TestCLI:
|
|||||||
assert args.app == "MyApp"
|
assert args.app == "MyApp"
|
||||||
assert args.dry_run is True
|
assert args.dry_run is True
|
||||||
|
|
||||||
|
def test_update_all_parser_and_conflicts(self):
|
||||||
|
parser = sideload.build_parser()
|
||||||
|
assert parser.parse_args(["--update-all", "--dry-run"]).update_all is True
|
||||||
|
# The positional argument is optional, so these semantic conflicts are
|
||||||
|
# rejected by main() after argparse has parsed the options.
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
sideload.main(["--update-all", "MyApp"])
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
sideload.main(["--update-all", "--list"])
|
||||||
|
|
||||||
|
def test_main_update_all_dry_run_skips_install(self, tmp_config: Path, tmp_path: Path, capsys):
|
||||||
|
device = sideload.AdbDevice("SERIAL", "Pixel", "usb")
|
||||||
|
apk_path = tmp_path / "remote.apk"
|
||||||
|
apk_path.write_bytes(b"apk")
|
||||||
|
config = sideload.Config.from_file(tmp_config)
|
||||||
|
app = config.apps[0]
|
||||||
|
release = sideload.ReleaseInfo(app, "x", "https://example.test/x.apk", None)
|
||||||
|
|
||||||
|
class FakeAdb:
|
||||||
|
def __init__(self, binary):
|
||||||
|
self.binary = binary
|
||||||
|
|
||||||
|
def list_devices(self):
|
||||||
|
return [device]
|
||||||
|
|
||||||
|
def installed_version_code(self, package, serial):
|
||||||
|
return 10
|
||||||
|
|
||||||
|
def install(self, path, serial):
|
||||||
|
raise AssertionError("install must not run in dry-run")
|
||||||
|
|
||||||
|
class FakeValidator:
|
||||||
|
def validate(self, path, package):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def version_code(self, path):
|
||||||
|
return 11
|
||||||
|
|
||||||
|
with mock.patch("sideload.AdbManager", return_value=FakeAdb("adb")), \
|
||||||
|
mock.patch("sideload.ApkValidator", return_value=FakeValidator()), \
|
||||||
|
mock.patch("sideload._download_and_validate", return_value=(release, apk_path)):
|
||||||
|
result = sideload.main(["--config", str(tmp_config), "--update-all", "--dry-run"])
|
||||||
|
|
||||||
|
assert result == 0
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "[dry-run] would install" in captured.out
|
||||||
|
assert "Summary: 1 updated, 0 unchanged, 0 not installed, 0 failed" in captured.out
|
||||||
|
|
||||||
|
|
||||||
# ── utility tests ─────────────────────────────────────────────────────────────
|
# ── utility tests ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user