Use Android build tools for APK validation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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}', "
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user