import os import re import subprocess import zipfile import xml.etree.ElementTree as ET from typing import Dict, List, Optional from pathlib import Path def parse_manifest(apk_path: str) -> Dict: """Extract metadata from an APK by reading AndroidManifest.xml directly.""" metadata = { "package_name": None, "version_name": None, "version_code": None, "label": None, "icon": None, "min_sdk": None, "target_sdk": None, "permissions": [], } try: with zipfile.ZipFile(apk_path) as z: # Check for compiled manifest (binary XML) if "AndroidManifest.xml" in z.namelist(): # Binary XML - use aapt for reliable parsing metadata = _parse_binary_manifest(apk_path, metadata) else: # No manifest found return metadata # Extract package name from filename if not found in manifest if not metadata["package_name"]: apk_name = Path(apk_path).stem metadata["package_name"] = apk_name # Look for icon resource (fallback if aapt didn't find one) if not metadata.get("icon"): icon_res = _find_icon_resource(apk_path) if icon_res: metadata["icon"] = icon_res except Exception as e: print(f"Error parsing {apk_path}: {e}") return metadata def parse_manifest_bytes(data: bytes) -> Dict: """Extract metadata from APK bytes (for in-memory validation).""" metadata = { "package_name": None, "version_name": None, "version_code": None, "label": None, "icon": None, "min_sdk": None, "target_sdk": None, "permissions": [], } try: import tempfile with tempfile.NamedTemporaryFile(suffix=".apk", delete=False) as tmp: tmp.write(data) tmp_path = tmp.name try: with zipfile.ZipFile(tmp_path) as z: if "AndroidManifest.xml" in z.namelist(): metadata = _parse_binary_manifest(tmp_path, metadata) finally: os.unlink(tmp_path) except Exception as e: print(f"Error parsing APK bytes: {e}") return metadata def _parse_binary_manifest(data_or_path, metadata: Dict) -> Dict: """Parse binary XML manifest using aapt for reliable extraction. Accepts either a file path (str) or raw APK bytes. """ import tempfile if isinstance(data_or_path, str): # Already a file path, use directly with aapt result = _parse_with_aapt(data_or_path) if result: metadata.update(result) return metadata # Raw bytes - write temp APK for aapt try: with tempfile.NamedTemporaryFile(suffix=".apk", delete=False) as tmp: tmp.write(data_or_path) tmp_path = tmp.name result = _parse_with_aapt(tmp_path) os.unlink(tmp_path) if result: metadata.update(result) except Exception as e: print(f"Error parsing binary manifest with aapt: {e}") # Fallback: basic string extraction if aapt fails _parse_fallback(data_or_path, metadata) return metadata def _parse_with_aapt(apk_path: str) -> Optional[Dict]: """Use aapt or aapt2 to extract manifest info reliably.""" for tool in ["aapt2", "aapt"]: try: result = subprocess.run( [tool, "dump", "badging", apk_path], capture_output=True, text=True, timeout=30 ) if result.returncode == 0: return _parse_aapt_output(result.stdout) except (FileNotFoundError, subprocess.TimeoutExpired): continue return None def _parse_aapt_output(output: str) -> Dict: """Parse aapt dump badging output.""" result = { "package_name": None, "version_name": None, "version_code": None, "label": None, "icon": None, "min_sdk": None, "target_sdk": None, "permissions": [], } for line in output.splitlines(): # Package: name='com.example.app' m = re.search(r"package: name='([^']+)'", line) if m: result["package_name"] = m.group(1) # versionName='1.0.0' m = re.search(r"versionName='([^']+)'", line) if m: result["version_name"] = m.group(1) # versionCode='1' m = re.search(r"versionCode='([^']+)'", line) if m: result["version_code"] = m.group(1) # application-label:'My App' m = re.search(r"application-label:'([^']*)'", line) if m: result["label"] = m.group(1) # application-icon-128:'res/path' m = re.search(r"application-icon-\d+:'([^']+)'", line) if m: result["icon"] = m.group(1) # sdkVersion:'21' / minSdkVersion:'21' / targetSdkVersion:'33' m = re.search(r"minSdkVersion='([^']+)'", line) if m: result["min_sdk"] = m.group(1) m = re.search(r"targetSdkVersion='([^']+)'", line) if m: result["target_sdk"] = m.group(1) # uses-permission: name='android.permission.INTERNET' m = re.search(r"uses-permission: name='([^']+)'", line) if m: result["permissions"].append(m.group(1)) return result def _parse_fallback(data: bytes, metadata: Dict) -> None: """Fallback string-based extraction when aapt is unavailable.""" try: if len(data) < 20: return if data[0:4] != b'\x03\x00\x08\x00' and data[0:4] != b'\x00\x08\x00\x03': return strings = _extract_strings_fallback(data) for s in strings: if s and "." in s and not s.startswith("android"): if re.match(r'^[a-zA-Z][a-zA-Z0-9_.]*$', s) and len(s.split('.')) >= 2: if metadata["package_name"] is None: metadata["package_name"] = s break for s in strings: if s and re.match(r'^\d+\.\d+', s): if metadata["version_name"] is None: metadata["version_name"] = s break except Exception as e: print(f"Error in fallback manifest parsing: {e}") def _extract_strings_fallback(data: bytes) -> List[str]: """Extract printable strings >= 4 chars from binary data.""" strings = [] current = [] for byte in data: if 32 <= byte < 127: current.append(chr(byte)) else: if len(current) >= 4: strings.append(''.join(current)) current = [] if len(current) >= 4: strings.append(''.join(current)) return strings def _find_icon_resource(apk_path: str) -> Optional[str]: """Find icon resource path in APK.""" try: with zipfile.ZipFile(apk_path) as z: names = z.namelist() # Look for icon in res/drawable-* folders for name in names: if "ic_launcher" in name and name.endswith(".png"): return name if "icon" in name and name.endswith(".png"): return name except Exception: pass return None def scan_repository(repo_path: str) -> List[Dict]: """Scan repository directory for APKs and return metadata.""" apps = [] repo_dir = Path(repo_path) if not repo_dir.exists(): return apps for apk_file in sorted(repo_dir.glob("*.apk")): metadata = parse_manifest(str(apk_file)) if not metadata["package_name"]: continue app = { "id": apk_file.stem, "package_name": metadata["package_name"], "version_name": str(metadata["version_name"]) if metadata["version_name"] else "unknown", "version_code": str(metadata["version_code"]) if metadata["version_code"] else "0", "name": metadata["label"] or apk_file.stem, "description": "", "icon": metadata["icon"], "size": apk_file.stat().st_size, "file_path": str(apk_file), "min_sdk": str(metadata["min_sdk"]) if metadata["min_sdk"] else None, "target_sdk": str(metadata["target_sdk"]) if metadata["target_sdk"] else None, "permissions": metadata["permissions"], } # Check for README or info file alongside APK info_file = repo_dir / f"{apk_file.stem}.json" if info_file.exists(): import json with open(info_file) as f: info = json.load(f) for key in ("name", "description", "icon", "package_name", "version_name", "version_code", "min_sdk", "target_sdk", "screenshots"): if key in info: app[key] = str(info[key]) if key in ("version_name", "version_code", "min_sdk", "target_sdk") else info[key] if "permissions" in info: app["permissions"] = info["permissions"] apps.append(app) return apps