From 34632ffbfe15626cf131d8f2b5b7d90916e53847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Fil=C3=ADpek?= Date: Thu, 13 Aug 2026 13:52:58 +0200 Subject: [PATCH] Verify the Android artifact carries the Dart snapshot this build produced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A release APK went out containing a libapp.so from the previous day, and with no libapp.so at all for arm64-v8a and x86_64. Gradle reported success, so nothing caught it. A missing snapshot means the app dies right after the splash screen — the Dart VM never comes up and the engine null-dereferences in Shell::Create. The cause is packagingOptions.pickFirst "**/*.so" in the project combined with an incremental local build: files covered by pickFirst are not refreshed on re-merge, and Flutter's libapp.so falls under that pattern. The project-side fix is to narrow the pattern, but the build tool should not be able to ship such an artifact either way. - android_snapshot_check.py reads GNU build-ids straight out of ELF via PT_NOTE, so it needs no NDK and handles ELF32 and ELF64 alike. Build-ids survive stripDebugSymbols, which makes them comparable between the intermediates and the packaged library. - Non-debug apk/appbundle builds drop merged_native_libs and stripped_native_libs for their variant beforehand. Other variants keep their caches. - After the build every lib//libapp.so in the artifact is matched against what Flutter produced into intermediates/flutter//jniLibs. Handles the AAB base/lib/ prefix, skips debug builds, warns instead of failing when there is nothing to compare. - A mismatch or a missing ABI raises, so the existing failure path reverts the version bump and no symbols get uploaded for a rejected artifact. find_and_rename_output now returns the artifact path as well, since the verification needs the file and not just its directory. --- README.md | 1 + src/logic/android_snapshot_check.py | 276 ++++++++++++++++++++++++++++ src/logic/build_android.py | 22 ++- src/logic/build_logic.py | 9 +- 4 files changed, 300 insertions(+), 8 deletions(-) create mode 100644 src/logic/android_snapshot_check.py diff --git a/README.md b/README.md index a7ece7a..5afa80c 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Runs a parametrised Flutter build with all the steps you would normally chain by - **Version bumping**: `major` / `minor` / `patch` / `build` directly in `pubspec.yaml`, with automatic revert if the build fails. - **CHANGELOG.md update** — appends the new version with the bumped number. - **Obfuscation** toggle (`--obfuscate --split-debug-info`) for mobile builds. +- **Dart snapshot verification (Android)** — non-debug `apk` / `appbundle` builds drop the Gradle native-lib merge cache for the variant beforehand, and afterwards every `lib//libapp.so` inside the artifact is matched by GNU build-id against the `libapp.so` this build actually produced. A stale snapshot or a missing ABI fails the build instead of shipping an app that runs old Dart code — or crashes on startup on the ABI whose snapshot is missing. - **Symbol upload to Firebase Crashlytics** — Android via the Firebase CLI (`crashlytics:symbols:upload`), iOS via the `upload-symbols` script that ships with the FirebaseCrashlytics CocoaPod. - **CocoaPods install** for iOS (clean reinstall before the build). - **Git push** of `pubspec.yaml` and `CHANGELOG.md` after a successful build. diff --git a/src/logic/android_snapshot_check.py b/src/logic/android_snapshot_check.py new file mode 100644 index 0000000..cf553c6 --- /dev/null +++ b/src/logic/android_snapshot_check.py @@ -0,0 +1,276 @@ +# src/logic/android_snapshot_check.py +""" +Kontrola, že zabalené APK/AAB opravdu obsahuje Dart snapshot z právě proběhlého buildu. + +Gradle merguje nativní knihovny inkrementálně a `packagingOptions.pickFirst "**/*.so"` +umí způsobit, že se do artefaktu dostane starý `libapp.so` z předchozího buildu, případně +že tam pro některé ABI nebude vůbec. Build v obou případech skončí úspěchem — aplikace +pak ale běží na starém Dart kódu, nebo na daném ABI spadne hned po splash screenu +v `Shell::Create` (chybí snapshot -> Dart VM nevznikne -> null dereference v enginu). + +Porovnává se GNU build-id ze sekce `.note.gnu.build-id`, které Dart do snapshotu zapisuje +a které přežije strip (`stripDebugSymbols` mění velikost souboru, build-id ne). +""" +import os +import glob +import shutil +import struct +import zipfile + +FLUTTER_INTERMEDIATES = os.path.join("build", "app", "intermediates", "flutter") +NATIVE_LIBS_CACHE_DIRS = [ + os.path.join("build", "app", "intermediates", "merged_native_libs"), + os.path.join("build", "app", "intermediates", "stripped_native_libs"), +] + +_NT_GNU_BUILD_ID = 3 +_PT_NOTE = 4 + + +def _camel_case(s): + """Převede 'release' na 'Release', 'prod' na 'Prod'.""" + if not s: + return "" + return s[0].upper() + s[1:].lower() + + +def variant_name(flavor, env, mode): + """ + Sestaví název Gradle varianty, pod kterou Flutter i AGP ukládají mezivýstupy. + + Např. flavor 'tapygo' + env 'prod' + mode 'release' -> 'tapygoProdRelease'. + Bez flavoru je varianta jen 'release' / 'profile' / 'debug'. + """ + mode_camel = _camel_case(mode) + + if not flavor: + return mode.lower() if mode else "" + + return f"{flavor}{_camel_case(env)}{mode_camel}" + + +def _read_build_id(data): + """ + Vytáhne GNU build-id z ELF souboru v paměti. Vrací hex string, nebo None. + + Čte se přes program headers (PT_NOTE), aby to fungovalo i na stripnutých + knihovnách, kde nemusí být tabulka sekcí. + """ + if len(data) < 64 or data[:4] != b"\x7fELF": + return None + + is_64 = data[4] == 2 + endian = "<" if data[5] == 1 else ">" + + try: + if is_64: + e_phoff = struct.unpack_from(endian + "Q", data, 0x20)[0] + e_phentsize, e_phnum = struct.unpack_from(endian + "HH", data, 0x36) + else: + e_phoff = struct.unpack_from(endian + "I", data, 0x1C)[0] + e_phentsize, e_phnum = struct.unpack_from(endian + "HH", data, 0x2A) + except struct.error: + return None + + for i in range(e_phnum): + off = e_phoff + i * e_phentsize + + try: + p_type = struct.unpack_from(endian + "I", data, off)[0] + + if p_type != _PT_NOTE: + continue + + if is_64: + p_offset = struct.unpack_from(endian + "Q", data, off + 0x08)[0] + p_filesz = struct.unpack_from(endian + "Q", data, off + 0x20)[0] + else: + p_offset = struct.unpack_from(endian + "I", data, off + 0x04)[0] + p_filesz = struct.unpack_from(endian + "I", data, off + 0x10)[0] + except struct.error: + continue + + build_id = _scan_notes(data, endian, p_offset, p_filesz) + + if build_id: + return build_id + + return None + + +def _scan_notes(data, endian, offset, size): + """Projde jeden PT_NOTE segment a vrátí build-id, pokud ho obsahuje.""" + end = min(offset + size, len(data)) + pos = offset + + while pos + 12 <= end: + try: + namesz, descsz, ntype = struct.unpack_from(endian + "III", data, pos) + except struct.error: + return None + + name_start = pos + 12 + desc_start = name_start + ((namesz + 3) // 4) * 4 + desc_end = desc_start + descsz + + if desc_end > end: + return None + + if ntype == _NT_GNU_BUILD_ID and data[name_start:name_start + namesz] == b"GNU\x00": + return data[desc_start:desc_end].hex() + + pos = desc_start + ((descsz + 3) // 4) * 4 + + return None + + +def purge_native_libs_cache(logger, flavor, env, mode): + """ + Smaže Gradle cache sloučených nativních knihoven, aby se `libapp.so` zabalil znovu. + + Jde o pár set MB, které se stejně regenerují — proti plnému `flutter clean` to stojí + sekundy a přesně tenhle krok je ten, který si drží starý snapshot. + """ + variant = variant_name(flavor, env, mode) + removed = [] + + for cache_dir in NATIVE_LIBS_CACHE_DIRS: + # Cache ostatních variant necháváme být — stará se jen tahle varianta. + # Bez známé varianty nezbývá než smazat celý adresář. + if variant: + targets = [os.path.join(cache_dir, variant)] + else: + targets = [cache_dir] + + for target in [t for t in targets if os.path.isdir(t)]: + try: + shutil.rmtree(target) + removed.append(target) + except Exception as e: + logger.warn(f"Nepodařilo se smazat '{target}': {e}") + + if removed: + logger.header("--- Čistím cache nativních knihoven (Gradle merge) ---") + + for target in removed: + logger.info(f" ✓ smazáno: {target}") + else: + logger.info("Cache nativních knihoven je prázdná, není co mazat.") + + +def collect_expected_snapshots(logger, flavor, env, mode): + """ + Načte build-id `libapp.so`, které Flutter v tomhle buildu vyrobil, pro každé ABI. + + Vrací dict {abi: build_id}. Prázdný dict znamená, že se nedá co porovnávat + (debug build žádný AOT snapshot nemá). + """ + variant = variant_name(flavor, env, mode) + jni_dir = os.path.join(FLUTTER_INTERMEDIATES, variant, "jniLibs") + + if not os.path.isdir(jni_dir): + # Varianta se nemusí trefit (jiné pojmenování flavoru) — vezmeme tu, + # jejíž snapshoty jsou nejčerstvější, protože build právě doběhl. + candidates = glob.glob(os.path.join(FLUTTER_INTERMEDIATES, "*", "jniLibs", "*", "libapp.so")) + + if not candidates: + return {} + + candidates.sort(key=os.path.getmtime, reverse=True) + jni_dir = os.path.dirname(os.path.dirname(candidates[0])) + + logger.info(f"Varianta '{variant}' nenalezena, používám '{jni_dir}'.") + + expected = {} + + for path in sorted(glob.glob(os.path.join(jni_dir, "*", "libapp.so"))): + abi = os.path.basename(os.path.dirname(path)) + + try: + with open(path, "rb") as f: + build_id = _read_build_id(f.read()) + except Exception as e: + logger.warn(f"Nelze přečíst '{path}': {e}") + continue + + if build_id: + expected[abi] = build_id + else: + logger.warn(f"V '{path}' není GNU build-id, ABI {abi} nelze ověřit.") + + return expected + + +def _artifact_snapshots(artifact_path): + """Vrátí {abi: build_id} pro všechny libapp.so uvnitř APK/AAB.""" + found = {} + + with zipfile.ZipFile(artifact_path) as archive: + for name in archive.namelist(): + parts = name.split("/") + + # APK: lib//libapp.so, AAB: base/lib//libapp.so + if len(parts) < 3 or parts[-1] != "libapp.so" or parts[-3] != "lib": + continue + + found[parts[-2]] = _read_build_id(archive.read(name)) + + return found + + +def verify_dart_snapshots(logger, artifact_path, flavor, env, mode): + """ + Ověří, že artefakt obsahuje pro každé ABI ten `libapp.so`, který tenhle build vyrobil. + + Vrací True, pokud je vše v pořádku nebo pokud není co ověřovat (debug build). + """ + if (mode or "").lower() == "debug": + return True + + logger.header("--- Ověřuji Dart snapshot v artefaktu ---") + + expected = collect_expected_snapshots(logger, flavor, env, mode) + + if not expected: + logger.warn("Nenalezen žádný vyrobený libapp.so — snapshot nelze ověřit.") + + return True + + try: + packaged = _artifact_snapshots(artifact_path) + except Exception as e: + logger.error(f"Nepodařilo se přečíst artefakt '{artifact_path}': {e}") + + return False + + problems = [] + + for abi, build_id in sorted(expected.items()): + actual = packaged.get(abi) + + if actual is None: + problems.append(f"{abi}: libapp.so v artefaktu úplně chybí") + elif actual != build_id: + problems.append(f"{abi}: zabalen starý snapshot ({actual[:16]}… místo {build_id[:16]}…)") + else: + logger.info(f" ✓ {abi}: {build_id}") + + for abi in sorted(set(packaged) - set(expected)): + logger.warn(f" ? {abi}: libapp.so v artefaktu navíc, tenhle build ho nevyrobil") + + if not problems: + logger.success(f"Snapshot sedí pro všechna ABI ({', '.join(sorted(expected))}).") + + return True + + logger.error("Artefakt neobsahuje kód z tohoto buildu:") + + for problem in problems: + logger.error(f" ✗ {problem}") + + logger.error( + "Nejčastější příčina je `packagingOptions.pickFirst \"**/*.so\"` v android/app/build.gradle " + "v kombinaci s inkrementálním buildem. Spusť `flutter clean` a build zopakuj." + ) + + return False diff --git a/src/logic/build_android.py b/src/logic/build_android.py index 874b4f3..c070633 100644 --- a/src/logic/build_android.py +++ b/src/logic/build_android.py @@ -6,6 +6,7 @@ import platform from .build_common import execute_command, resolve_value, get_package_name +from .android_snapshot_check import verify_dart_snapshots from ..constants import KEY_BUILD_TYPE, KEY_FLAVOR, KEY_ENV, KEY_BUILD_MODE, \ KEY_DISABLE_OBFUSCATION, KEY_UPLOAD_SYMBOLS @@ -61,7 +62,7 @@ def find_and_rename_output(logger, params, env_vars): package_name = get_package_name(logger, env_vars) if not package_name: - return None + return None, None env_camel = _camel_case(env) env_lc = env.lower() if env else "" @@ -105,7 +106,7 @@ def find_and_rename_output(logger, params, env_vars): if not output_file or not os.path.exists(output_file): logger.error("Nepodařilo se najít výstupní soubor.") - return None + return None, None # Přejmenování output_dir = os.path.dirname(output_file) @@ -125,21 +126,28 @@ def find_and_rename_output(logger, params, env_vars): logger.success(f"Soubor přejmenován na: {new_file_name}") else: logger.info(f"Soubor již má správný název: {new_file_name}") - - return output_dir + + return output_dir, new_path except Exception as e: logger.error(f"Chyba při přejmenování '{output_file}' na '{new_path}': {e}") - return None + return None, None def run_android_tasks_post_build(logger, params, env_vars, actions_performed): """ Spustí úlohy specifické pro Android po úspěšném buildu. + + Vyhazuje RuntimeError, pokud artefakt neobsahuje Dart snapshot z tohohle buildu — + takový artefakt se nesmí dostat ven ani se pro něj nemají nahrávat symboly. """ flavor = params.get(KEY_FLAVOR) env = params.get(KEY_ENV) - - output_dir = find_and_rename_output(logger, params, env_vars) + mode = params.get(KEY_BUILD_MODE) + + output_dir, artifact_path = find_and_rename_output(logger, params, env_vars) + + if artifact_path and not verify_dart_snapshots(logger, artifact_path, flavor, env, mode): + raise RuntimeError("Artefakt neobsahuje Dart kód z tohoto buildu.") if not params.get(KEY_DISABLE_OBFUSCATION, False) and params.get(KEY_UPLOAD_SYMBOLS, False): logger.header("--- Nahrávám symboly (Android) na Firebase ---") diff --git a/src/logic/build_logic.py b/src/logic/build_logic.py index ff7765c..9ab4cfa 100644 --- a/src/logic/build_logic.py +++ b/src/logic/build_logic.py @@ -16,6 +16,7 @@ to_camel_case, get_version_parts, update_changelog ) from .build_android import run_android_tasks_post_build +from .android_snapshot_check import purge_native_libs_cache from .build_ios import run_ios_tasks_pre_build, run_ios_tasks_post_build from .build_web import run_web_tasks_pre_build, run_web_tasks_post_build, restore_web_build_from_git from .build_desktop import run_desktop_tasks_post_build @@ -188,7 +189,13 @@ def _handle_failure(message): logger.info(f"Symboly budou uloženy do: {symbols_dir}") build_command.extend(dart_defines) - + + # 5.5 Vyčištění Gradle cache sloučených nativních knihoven + # AGP je merguje inkrementálně a umí do artefaktu zabalit libapp.so z předchozího + # buildu, případně ho pro některé ABI vynechat. Smazání cache stojí sekundy. + if build_type in ['apk', 'appbundle'] and build_mode != 'debug': + purge_native_libs_cache(logger, flavor, env, build_mode) + # Spuštění buildu ret_code, _ = execute_command(build_command, logger, f"Spouštím Flutter Build ({build_type} - {build_mode})")