app-store/server/scanner.py

233 lines
8.1 KiB
Python

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