Use Android build tools for APK validation

This commit is contained in:
2026-07-28 23:50:37 +02:00
parent 16e9eebc3d
commit ae4a95a337
4 changed files with 82 additions and 40 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -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)
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:
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_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}', "

View File

@@ -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