"""Verify APK identity, upgrade signature, fresh Flutter libraries and icon assets.""" import argparse import hashlib import io import json from pathlib import Path import re import subprocess import zipfile from PIL import Image def sha(data): return hashlib.sha256(data).hexdigest() def verify(args): apk = Path(args.apk) previous = Path(args.previous) sdk = Path(args.build_tools) metadata = subprocess.check_output([str(sdk / 'aapt.exe'), 'dump', 'badging', str(apk)], text=True, encoding='utf-8') identity = re.search(r"package: name='([^']+)' versionCode='([^']+)' versionName='([^']+)'", metadata) if identity is None or identity.groups() != ('ru.ikar.app', str(args.code), args.version): raise RuntimeError('Incorrect Android package or version') def certificate(path): output = subprocess.check_output([str(sdk / 'apksigner.bat'), 'verify', '--verbose', '--print-certs', str(path)], text=True, encoding='utf-8') values = re.findall(r'Signer #\d+ certificate SHA-256 digest: ([0-9a-f]+)', output) if len(values) != 1 or 'Verified using v2 scheme (APK Signature Scheme v2): true' not in output: raise RuntimeError('APK signature verification failed') return values[0] signer = certificate(apk) if signer != certificate(previous): raise RuntimeError('Signing certificate differs from the previously published APK') latest_source = max(path.stat().st_mtime for path in Path('lib').rglob('*.dart')) libraries = [] assets = [] with zipfile.ZipFile(apk) as package: icon_path = re.search(r"application-icon-160:'([^']+)'", metadata).group(1) icon_tree = subprocess.check_output([str(sdk / 'aapt.exe'), 'dump', 'xmltree', str(apk), icon_path], text=True, encoding='utf-8') inset = re.search(r'android:inset\([^)]*\)=\(type 0x6\)0x([0-9a-f]+)', icon_tree) if inset is None or int(inset[1], 16) >> 8 != 0: raise RuntimeError('Adaptive launcher icon still has the old inset') resources = subprocess.check_output([str(sdk / 'aapt.exe'), 'dump', '--values', 'resources', str(apk)], text=True, encoding='utf-8') paths = re.findall(r'[^\n]*ru\.ikar\.app:drawable/ic_launcher_foreground: t=0x03[^\n]*\n\s*\(string8\) "([^"]+)"', resources) expected_icons = {} for path in Path('android/app/src/main/res').glob('drawable-*/ic_launcher_foreground.png'): with Image.open(path) as image: expected_icons[image.size] = image.convert('RGBA').tobytes() if len(paths) != len(expected_icons): raise RuntimeError('Missing launcher foreground densities') for path in paths: with Image.open(io.BytesIO(package.read(path))) as image: if image.convert('RGBA').tobytes() != expected_icons.get(image.size): raise RuntimeError('Packaged launcher icon differs from the current drawing') for abi in ('armeabi-v7a', 'arm64-v8a', 'x86_64'): generated = Path(f'build/app/intermediates/flutter/ikarRelease/jniLibs/{abi}/libapp.so') stripped = Path(f'build/app/intermediates/stripped_native_libs/ikarRelease/stripIkarReleaseDebugSymbols/out/lib/{abi}/libapp.so') actual = sha(package.read(f'lib/{abi}/libapp.so')) if actual != sha(stripped.read_bytes()) or generated.stat().st_mtime < latest_source: raise RuntimeError(f'Stale Flutter library for {abi}') for name in ('libflutter.so', 'libfjs.so', 'libkomet_crypto.so', 'libkomet_tlottie.so'): if not package.read(f'lib/{abi}/{name}'): raise RuntimeError(f'Missing native dependency: {abi}/{name}') libraries.append({'abi': abi, 'apkSha256': actual, 'matchesStripped': True, 'generatedAfterLastSourceChange': True}) for name in ('ikar.png', 'ikar_icon.png'): expected = Path('assets') / name if sha(package.read(f'assets/flutter_assets/assets/{name}')) != sha(expected.read_bytes()): raise RuntimeError(f'Stale icon asset: {name}') assets.append(name) report = {'passed': True, 'package': 'ru.ikar.app', 'version': args.version, 'androidVersionCode': args.code, 'sha256': sha(apk.read_bytes()), 'bytes': apk.stat().st_size, 'certificateSha256': signer, 'upgradeSignerMatches': True, 'libraries': libraries, 'currentIconAssets': assets, 'adaptiveLauncherMatchesCurrentDrawing': True, 'adaptiveLauncherInsetZero': True} Path(f'artifacts/apk-verification-{args.version}.json').write_text(json.dumps(report, indent=2), encoding='utf-8') print(json.dumps(report)) if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('--apk', required=True) parser.add_argument('--previous', required=True) parser.add_argument('--version', required=True) parser.add_argument('--code', required=True, type=int) parser.add_argument('--build-tools', default='C:/Tools/AndroidSdk/build-tools/36.0.0') verify(parser.parse_args())