#!/usr/bin/env python3 """Check the checked-in Android release manifest inventory against merged output.""" from pathlib import Path import sys import xml.etree.ElementTree as ET ANDROID_NS = "{http://schemas.android.com/apk/res/android}" ROOT = Path(__file__).resolve().parents[1] DEFAULT_INVENTORY = ROOT / "docs" / "android-release-manifest-inventory.txt" DEFAULT_MERGED = ROOT / "app" / "build" / "intermediates" / "merged_manifest" / "release" / "processReleaseMainManifest" / "AndroidManifest.xml" def attrs(node: ET.Element) -> str: """ Every attribute on an element, canonicalised. Namespace prefixes are dropped (the merged manifest uses only `android:` plus a few tool markers), and the list is sorted so ordering can never look like drift. """ items = sorted( (key.rsplit("}", 1)[-1], value) for key, value in node.attrib.items() ) return ";".join(f"{key}={value}" for key, value in items) def inventory(path: Path) -> str: root = ET.parse(path).getroot() lines = [] for permission in sorted(root.findall("uses-permission"), key=lambda node: node.get(ANDROID_NS + "name", "")): lines.append( f"permission|{permission.get(ANDROID_NS + 'name', '')}|{attrs(permission)}" ) for feature in sorted(root.findall("uses-feature"), key=lambda node: node.get(ANDROID_NS + "name", "")): lines.append( f"uses-feature|{feature.get(ANDROID_NS + 'name', '')}|{attrs(feature)}" ) for queries in root.findall("queries"): lines.append(f"queries|{attrs(queries)}") for child in sorted(queries, key=lambda node: (node.tag, node.get(ANDROID_NS + "name", ""))): lines.append( f"query|{child.tag}|{child.get(ANDROID_NS + 'name', '')}|{attrs(child)}" ) for data in sorted(child.findall("data"), key=attrs): lines.append(f"query-data|{child.tag}|{attrs(data)}") application = root.find("application") if application is None: raise SystemExit("release manifest has no application") # fullBackupContent, networkSecurityConfig, usesCleartextTraffic, debuggable, # process, enabled — is exposure-bearing and was previously invisible here. # The manifest element's own versionName/versionCode are deliberately NOT # recorded: they change on every version bump, which is not the drift this # gate exists to catch (PB-26-133). lines.append(f"application|{attrs(application)}") for child in sorted(application, key=lambda node: (node.tag, node.get(ANDROID_NS + "name", ""))): name = child.get(ANDROID_NS + "name", "") # The full attribute set, so provider authorities, grantUriPermissions, # readPermission/writePermission, enabled, process, directBootAware and # every meta-data value are frozen too. lines.append(f"component|{child.tag}|{name}|{attrs(child)}") for intent_filter in child.findall("intent-filter"): actions = sorted(action.get(ANDROID_NS + "name", "") for action in intent_filter.findall("action")) categories = sorted(category.get(ANDROID_NS + "name", "") for category in intent_filter.findall("category")) lines.append(f"intent-filter|{child.tag}|{name}|actions={','.join(actions)}|categories={','.join(categories)}") for data in sorted(intent_filter.findall("data"), key=attrs): lines.append(f"intent-filter-data|{child.tag}|{name}|{attrs(data)}") return "\n".join(lines) + "\n" USAGE = f"usage: {Path(sys.argv[0]).name} check|write [merged-manifest] [inventory]" if len(sys.argv) not in (2, 3, 4): raise SystemExit(USAGE) MERGED = Path(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_MERGED INVENTORY = Path(sys.argv[3]) if len(sys.argv) > 3 else DEFAULT_INVENTORY if not MERGED.is_file(): raise SystemExit(f"merged release manifest not found: {MERGED}") if sys.argv[1] == "write": INVENTORY.write_text(inventory(MERGED)) elif sys.argv[1] == "check": if not INVENTORY.is_file(): raise SystemExit(f"checked-in inventory not found: {INVENTORY}") actual = inventory(MERGED) expected = INVENTORY.read_text() if actual != expected: raise SystemExit("merged release manifest differs from checked-in inventory") else: raise SystemExit(f"unknown command: {sys.argv[1]}\n{USAGE}")