import os import re 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 - try to extract what we can data = z.read("AndroidManifest.xml") metadata = _parse_binary_manifest(data, 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 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_binary_manifest(data: bytes, metadata: Dict) -> Dict: """Parse binary XML manifest. Android uses a proprietary binary XML format.""" try: # Try to decode string table - the binary XML format has a header # then a string table, then keypool, then resources # This is a simplified parser that extracts common fields # Check if it's binary XML (starts with XML header magic) if len(data) < 20: return metadata # Binary XML header magic: 0x00080003 if data[0:4] == b'\x03\x00\x08\x00' or data[0:4] == b'\x00\x08\x00\x03': # Try to extract strings from the string table strings = _extract_strings(data) # Look for package attribute for s in strings: if s and "." in s and not s.startswith("android"): # Likely a package name if re.match(r'^[a-zA-Z][a-zA-Z0-9_.]*$', s) and len(s.split('.')) >= 2: metadata["package_name"] = s break # Look for versionName for s in strings: if s and re.match(r'^\d+\.\d+', s): metadata["version_name"] = s break # Look for versionCode (numeric) for s in strings: if s and re.match(r'^\d{1,10}$', s) and len(s) < 10: # Could be version code or other number if metadata["version_code"] is None: metadata["version_code"] = s break # Look for app label - usually contains spaces or title-case words for s in strings: if s and len(s) > 1 and len(s) < 50 and not s.startswith("android"): if any(c.isupper() for c in s) or " " in s: if s != metadata["package_name"]: metadata["label"] = s break except Exception as e: print(f"Error parsing binary manifest: {e}") return metadata def _extract_strings(data: bytes) -> List[str]: """Extract string table from binary XML.""" strings = [] try: # Binary XML header structure: # 4 bytes: type (0x0008) # 4 bytes: reserved # 4 bytes: header size # 4 bytes: end of strings offset # 4 bytes: start of string data offset if len(data) < 20: return strings header_size = int.from_bytes(data[8:12], 'little') strings_end = int.from_bytes(data[12:16], 'little') strings_start = int.from_bytes(data[16:20], 'little') if strings_end <= strings_start or strings_start < header_size: return strings # String count is at offset header_size (right after header) str_count_offset = header_size if str_count_offset + 4 > len(data): return strings str_count = int.from_bytes(data[str_count_offset:str_count_offset + 4], 'little') # Index table starts after count index_offset = str_count_offset + 4 # Extract string indices indices = [] for i in range(min(str_count, 1000)): # limit to avoid issues idx_off = index_offset + (i * 4) if idx_off + 4 > len(data): break idx = int.from_bytes(data[idx_off:idx_off + 4], 'little') indices.append(idx) # Extract strings using indices for idx in indices: str_offset = strings_start + idx if str_offset + 4 > len(data): continue # String length is stored as 2 bytes (UTF-16 length) utf16_len = int.from_bytes(data[str_offset:str_offset + 2], 'little') # Then 2 bytes for UTF-8 length utf8_len = int.from_bytes(data[str_offset + 2:str_offset + 4], 'little') if utf8_len == 0 or utf8_len > 500: continue str_data_start = str_offset + 4 if str_data_start + utf8_len > len(data): continue try: s = data[str_data_start:str_data_start + utf8_len].decode('utf-8', errors='ignore') if s: strings.append(s) except Exception: continue except Exception as e: print(f"Error extracting strings: {e}") 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