diff --git a/AGENTS.md b/AGENTS.md index c62a08e..c41c23e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,8 +16,8 @@ ## 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. +- 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. - 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. @@ -28,4 +28,4 @@ - 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. +- 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. diff --git a/README.md b/README.md index f703169..fa5fbb9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Install free Android apps via ADB from F-Droid, GitHub Releases, or GitLab Relea - Python 3.11+ - `adb` (Android Debug Bridge) in PATH -- `apkanalyzer` (Android SDK tools) in PATH +- `aapt2` or `aapt` (Android SDK Build Tools) in PATH ## Quick start @@ -43,8 +43,10 @@ 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. +`aapt2` is used to validate the APK package ID before installation, with +`aapt` as a fallback for older Android SDK Build Tools. At least one of them +must be available in `PATH`. The script refuses to install an APK when neither +tool can validate it. For Wi-Fi ADB, configure an explicit address after enabling wireless debugging: diff --git a/src/sideload.py b/src/sideload.py index eca440a..d83c0ec 100644 --- a/src/sideload.py +++ b/src/sideload.py @@ -229,30 +229,37 @@ class FdroidSource: # 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: + version_entries = [] + for key, candidate in pkg.items(): + if not isinstance(candidate, dict): + continue + manifest = candidate.get("manifest", {}) + version_code = manifest.get("versionCode") + if version_code is None: + # Older index shapes used versionCode as the dictionary key. + try: + version_code = int(key) + except (TypeError, ValueError): + continue + try: + version_entries.append((int(version_code), key, candidate)) + except (TypeError, ValueError): + continue + if not version_entries: raise ValueError(f"no versions for '{pkg_name}' in F-Droid index") - latest = versions[0] - entry = pkg[latest] + version_code, _, entry = max(version_entries, key=lambda item: item[0]) 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}" + f"no file name in F-Droid index for '{pkg_name}' v{version_code}" ) - 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) + # F-Droid file names begin with '/', but are relative to the repo + # directory rather than to the domain root. + download_url = urllib.parse.urljoin(self.repo_url + "/", apk_name.lstrip("/")) return ReleaseInfo( app_entry=self.app_entry, - version=latest, + version=str(entry.get("manifest", {}).get("versionName", version_code)), download_url=download_url, expected_sha256=self.app_entry.config.get("sha256") or file_info.get("sha256"), file_size=file_info.get("size"), @@ -409,21 +416,31 @@ class GitLabSource: class ApkValidator: - def __init__(self, apkanalyzer_bin: str = "apkanalyzer"): - self.bin = apkanalyzer_bin + def __init__(self, aapt2_bin: str = "aapt2", aapt_bin: str = "aapt"): + self.aapt2_bin = aapt2_bin + self.aapt_bin = aapt_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), + self.aapt2_bin, "dump", "packagename", str(apk_path), check=False, timeout=15, ) except FileNotFoundError: - return f"apkanalyzer not found: {self.bin}" + try: + result = _run( + self.aapt_bin, "dump", "badging", str(apk_path), + check=False, timeout=15, + ) + except FileNotFoundError: + return f"neither aapt2 nor aapt was found in PATH" if result.returncode != 0: - return f"apkanalyzer failed: {result.stderr.strip()}" + return f"APK package validation failed: {result.stderr.strip()}" actual = result.stdout.strip() + if self.aapt_bin in result.args: + match = re.search(r"^package: name='([^']+)'", actual, re.MULTILINE) + actual = match.group(1) if match else "" if actual != expected_app_id: return ( f"application-id mismatch: expected '{expected_app_id}', " diff --git a/tests/test_sideload.py b/tests/test_sideload.py index 3fef44b..c621d72 100644 --- a/tests/test_sideload.py +++ b/tests/test_sideload.py @@ -1,4 +1,4 @@ -"""Unit tests for sideload module — no network, ADB, or apkanalyzer required.""" +"""Unit tests for sideload module — no network, ADB, or Android SDK required.""" from __future__ import annotations @@ -243,14 +243,14 @@ class TestFdroidSource: "org.example.test": { "12345": { "file": { - "name": "/repo/org.example.test_12345.apk", + "name": "/org.example.test_12345.apk", "sha256": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", "size": 1234567, } }, "12344": { "file": { - "name": "/repo/org.example.test_12344.apk", + "name": "/org.example.test_12344.apk", "sha256": "0000000000000000000000000000000000000000000000000000000000000000", } }, @@ -310,6 +310,29 @@ class TestFdroidSource: assert release.version == "12345" assert release.download_url.endswith("/repo/org.example.test_12345.apk") + def test_get_latest_release_uses_manifest_version_code(self, app_entry: sideload.AppEntry): + index = { + "packages": { + "org.example.test": { + "versions": { + "sha256-of-version": { + "file": {"name": "/org.example.test_36.apk", "sha256": "a" * 64}, + "manifest": {"versionCode": 36, "versionName": "5.1.1"}, + }, + "older-version": { + "file": {"name": "/org.example.test_34.apk", "sha256": "b" * 64}, + "manifest": {"versionCode": 34, "versionName": "5.0.0"}, + }, + } + } + } + } + source = sideload.FdroidSource(app_entry) + with mock.patch("sideload._http_get", return_value=json.dumps(index).encode()): + release = source.get_latest_release() + assert release.version == "5.1.1" + assert release.download_url.endswith("/org.example.test_36.apk") + def test_missing_repo_url_raises(self): entry = sideload.AppEntry(name="X", source="fdroid", app_id="x", config={}) with pytest.raises(ValueError, match="repo_url is required"): @@ -471,7 +494,7 @@ class TestGitLabSource: class TestApkValidator: def test_validate_success(self, apk_file: Path): - validator = sideload.ApkValidator(apkanalyzer_bin="fake-apkanalyzer") + validator = sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt") with mock.patch( "sideload._run", return_value=subprocess.CompletedProcess( @@ -482,7 +505,7 @@ class TestApkValidator: assert err is None def test_validate_mismatch(self, apk_file: Path): - validator = sideload.ApkValidator(apkanalyzer_bin="fake-apkanalyzer") + validator = sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt") with mock.patch( "sideload._run", return_value=subprocess.CompletedProcess( @@ -493,12 +516,12 @@ class TestApkValidator: assert err is not None assert "mismatch" in err - def test_validate_apkanalyzer_fails(self, apk_file: Path): - validator = sideload.ApkValidator(apkanalyzer_bin="fake-apkanalyzer") + def test_validate_aapt_fails(self, apk_file: Path): + validator = sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt") with mock.patch( "sideload._run", return_value=subprocess.CompletedProcess( - args=[], returncode=1, stdout="", stderr="command not found" + args=[], returncode=1, stdout="", stderr="invalid APK" ), ): err = validator.validate(apk_file, "org.example.test") @@ -662,7 +685,7 @@ class TestSideload: with mock.patch("sideload._run", side_effect=mock_run): result = sideload.sideload( app_entry, config, dry_run=True, - validator=sideload.ApkValidator(apkanalyzer_bin="fake"), + validator=sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt"), ) assert result == 0 @@ -700,7 +723,7 @@ class TestSideload: with mock.patch("sideload._run", side_effect=mock_run): result = sideload.sideload( app_entry, config, dry_run=True, - validator=sideload.ApkValidator(apkanalyzer_bin="fake"), + validator=sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt"), ) assert result == 1 @@ -744,7 +767,7 @@ class TestSideload: with mock.patch("sideload._run", side_effect=mock_run): result = sideload.sideload( app_entry, config, - validator=sideload.ApkValidator(apkanalyzer_bin="fake"), + validator=sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt"), adb_manager=sideload.AdbManager(binary="fake-adb"), ) assert result == 1 @@ -790,7 +813,7 @@ class TestSideload: result = sideload.sideload( app_entry, config, device_serial="NONEXISTENT", - validator=sideload.ApkValidator(apkanalyzer_bin="fake"), + validator=sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt"), adb_manager=sideload.AdbManager(binary="fake-adb"), ) assert result == 1 @@ -843,7 +866,7 @@ class TestSideload: result = sideload.sideload( app_entry, config, device_serial="ABCD1234", - validator=sideload.ApkValidator(apkanalyzer_bin="fake"), + validator=sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt"), adb_manager=sideload.AdbManager(binary="fake-adb"), ) assert result == 0