From f7e9576d158a52b61e14745ecbab50917632bdb0 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Thu, 3 Sep 2026 17:15:48 +0300 Subject: [PATCH 1/2] ci: gate LeanTypeDual fork invariants Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-test-auto.yml | 15 + .github/workflows/release.yml | 6 + AGENTS.md | 7 +- tools/check_apk_invariants.py | 208 ++++++ tools/check_fork_invariants.py | 707 ++++++++++++++++++ tools/tests/fixtures/apk_invariants.json | 26 + .../.github/workflows/release.yml | 30 + .../fork_invariants/app/build.gradle.kts | 47 ++ .../app/src/main/AndroidManifest.xml | 8 + .../helium314/keyboard/latin/LatinIME.java | 11 + .../keyboard/settings/SettingsContainer.kt | 11 + .../keyboard/latin/utils/ProofreadHelper.kt | 3 + .../keyboard/latin/utils/ProofreadService.kt | 3 + .../app/src/standard/AndroidManifest.xml | 4 + .../app/src/standardfull/AndroidManifest.xml | 4 + .../android/en-US/changelogs/4300.txt | 1 + tools/tests/test_check_apk_invariants.py | 108 +++ tools/tests/test_check_fork_invariants.py | 164 ++++ 18 files changed, 1362 insertions(+), 1 deletion(-) create mode 100644 tools/check_apk_invariants.py create mode 100644 tools/check_fork_invariants.py create mode 100644 tools/tests/fixtures/apk_invariants.json create mode 100644 tools/tests/fixtures/fork_invariants/.github/workflows/release.yml create mode 100644 tools/tests/fixtures/fork_invariants/app/build.gradle.kts create mode 100644 tools/tests/fixtures/fork_invariants/app/src/main/AndroidManifest.xml create mode 100644 tools/tests/fixtures/fork_invariants/app/src/main/java/helium314/keyboard/latin/LatinIME.java create mode 100644 tools/tests/fixtures/fork_invariants/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt create mode 100644 tools/tests/fixtures/fork_invariants/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadHelper.kt create mode 100644 tools/tests/fixtures/fork_invariants/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt create mode 100644 tools/tests/fixtures/fork_invariants/app/src/standard/AndroidManifest.xml create mode 100644 tools/tests/fixtures/fork_invariants/app/src/standardfull/AndroidManifest.xml create mode 100644 tools/tests/fixtures/fork_invariants/fastlane/metadata/android/en-US/changelogs/4300.txt create mode 100644 tools/tests/test_check_apk_invariants.py create mode 100644 tools/tests/test_check_fork_invariants.py diff --git a/.github/workflows/build-test-auto.yml b/.github/workflows/build-test-auto.yml index e48994cbb..2cbbc787f 100644 --- a/.github/workflows/build-test-auto.yml +++ b/.github/workflows/build-test-auto.yml @@ -14,12 +14,24 @@ on: - '*.gradle*' - 'gradle.properties' - '.github/workflows/build-test-auto.yml' + - '.github/workflows/release.yml' + - 'tools/check_fork_invariants.py' + - 'tools/check_apk_invariants.py' + - 'tools/tests/**' + - 'fastlane/metadata/android/en-US/changelogs/**' push: branches: [ dev ] paths: - 'app/**' - 'gradle/**' - '*.gradle*' + - 'gradle.properties' + - '.github/workflows/build-test-auto.yml' + - '.github/workflows/release.yml' + - 'tools/check_fork_invariants.py' + - 'tools/check_apk_invariants.py' + - 'tools/tests/**' + - 'fastlane/metadata/android/en-US/changelogs/**' workflow_dispatch: jobs: @@ -29,6 +41,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Verify LeanTypeDual fork invariants + run: python3 tools/check_fork_invariants.py + - name: Set up JDK uses: actions/setup-java@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31fa38747..47a2b8efc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,6 +62,12 @@ jobs: - name: Build signed release APKs (all flavors) run: ./gradlew :app:assembleStandardRelease :app:assembleStandardfullRelease :app:assembleOfflineRelease :app:assembleOfflineliteRelease + - name: Verify packaged LeanTypeDual invariants + run: | + APKANALYZER="$(find "$ANDROID_SDK_ROOT/cmdline-tools" -path '*/bin/apkanalyzer' -type f | sort -V | tail -1)" + test -x "$APKANALYZER" + python3 tools/check_apk_invariants.py --apk-dir app/build/outputs/apk --apkanalyzer "$APKANALYZER" + - name: Verify release APK signatures run: | APKSIGNER="$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner -type f | sort -V | tail -1)" diff --git a/AGENTS.md b/AGENTS.md index d8e5ff327..38060704f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,10 @@ Requires **JDK 17 or 21** and the Android SDK. On Windows use `gradlew.bat` and ./gradlew :app:assembleStandardDebug # also assembleOfflineDebug, assembleOfflineliteDebug # Fast CI compile check (no APK) — what PR CI runs ./gradlew compileOfflineRunTestsKotlin +# Fast fork-identity/product gate (run before and after upstream merges) +python tools/check_fork_invariants.py +# Packaged release gate (after all four release APKs are assembled) +python tools/check_apk_invariants.py --apk-dir app/build/outputs/apk # Unit tests for one flavor ./gradlew :app:testOfflineDebugUnitTest # A single test class @@ -43,7 +47,7 @@ Requires **JDK 17 or 21** and the Android SDK. On Windows use `gradlew.bat` and PowerShell with a pinned JDK: ```powershell -$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.11.10-hotspot" +$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.7-hotspot" .\gradlew.bat :app:assembleStandardDebug --no-daemon ``` @@ -86,6 +90,7 @@ $env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.11.10-hotspot" ## Testing & QA - **JVM-only** (no `androidTest`/device): JUnit4 + **Robolectric 4.14.1** (simulates `LatinIME`/`Context`/prefs/key events on the JVM) + **Mockito 5.17.0**. Tests live in `app/src/test/java/helium314/keyboard/`. `testOptions.unitTests.isIncludeAndroidResources = true`. - **Run:** `./gradlew :app:testOfflineDebugUnitTest` (add `--tests "*ClassName"` for one class). +- **Upstream-merge gates:** run `python tools/check_fork_invariants.py` before and after resolving an upstream merge. Unit-test CI uses it as a fast source/configuration prefilter and fails with the specific LeanTypeDual invariant that was lost. Release CI also runs `tools/check_apk_invariants.py` after assembling all four APKs to verify the effective package IDs, minSdk values, INTERNET permissions, recursive dictionary contents, and exact artifact set. Their fixture/mutation tests run via `python -m unittest discover -s tools/tests`. - **Key tests:** `InputLogicTest.kt` (typing/autocorrect/combining-mode/Hangul), `SuggestTest.kt`, `WordComposerTest.java`, `DictionaryGroupTest.kt` (reflection + Mockito on the package-internal `DictionaryGroup`), `SettingsContainerTest.kt` (settings wiring), `KeyboardParserTest.kt`, `ClipboardDaoTest.kt`. - **Conventions:** `@Test`; method names use camelCase or backtick form; obtain `Context` via Robolectric; package-internal classes are exercised via reflection (`Class.forName(...).declaredConstructors`). - **Known failures:** the full debug unit suite has ~11 pre-existing failures (in `KeyboardParserTest`, `XLinkTest`, `StringUtilsTest` emoji, and `InputLogicTest` Hangul/autocorrect-revert/autospace-indicator) that are environment/data-dependent and usually unrelated to a change. The `runTests` build type exists to skip these on CI. **Verify a change by diffing failures against an `origin/main` baseline run, not by absolute pass count.** diff --git a/tools/check_apk_invariants.py b/tools/check_apk_invariants.py new file mode 100644 index 000000000..84a1359bd --- /dev/null +++ b/tools/check_apk_invariants.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Verify LeanTypeDual invariants in assembled release APKs.""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import sys +import zipfile +from collections.abc import Callable +from pathlib import Path + + +EXPECTED = { + "standard": ("com.asafmah.leantypedual", 23, True, False), + "standardfull": ("com.asafmah.leantypedual", 23, True, False), + "offline": ("com.asafmah.leantypedual.offline", 26, False, True), + "offlinelite": ("com.asafmah.leantypedual.offlinelite", 21, False, True), +} +APK_NAME = re.compile( + r"-(standard|standardfull|offline|offlinelite)-release\.apk$", re.IGNORECASE +) +INTERNET = "android.permission.INTERNET" +Analyzer = Callable[[str, Path], str] + + +class AnalyzerError(RuntimeError): + pass + + +def subprocess_analyzer(executable: str) -> Analyzer: + def run(operation: str, apk: Path) -> str: + try: + result = subprocess.run( + [executable, "manifest", operation, str(apk)], + check=False, + capture_output=True, + text=True, + encoding="utf-8", + ) + except OSError as exc: + raise AnalyzerError(f"cannot run {executable}: {exc}") from exc + if result.returncode: + detail = result.stderr.strip() or result.stdout.strip() or "no diagnostic" + raise AnalyzerError( + f"{executable} manifest {operation} failed for {apk.name}: {detail}" + ) + return result.stdout + + return run + + +def resolve_apkanalyzer(explicit: str | None) -> str: + if explicit: + return explicit + on_path = shutil.which("apkanalyzer") + if on_path: + return on_path + for variable in ("ANDROID_HOME", "ANDROID_SDK_ROOT"): + sdk = os.environ.get(variable) + if not sdk: + continue + command_line_tools = Path(sdk) / "cmdline-tools" + preferred = [ + command_line_tools / "latest/bin/apkanalyzer", + command_line_tools / "latest/bin/apkanalyzer.bat", + ] + candidates = preferred + sorted(command_line_tools.glob("*/bin/apkanalyzer*"), reverse=True) + for candidate in candidates: + if candidate.is_file(): + return str(candidate) + return "apkanalyzer" + + +def _discover(apk_dir: Path, problems: list[str]) -> dict[str, Path]: + if not apk_dir.is_dir(): + problems.append(f"[apk/set] APK directory does not exist: {apk_dir}") + return {} + apks = sorted( + path for path in apk_dir.rglob("*.apk") + if path.name.lower().endswith("-release.apk") + ) + grouped: dict[str, list[Path]] = {flavor: [] for flavor in EXPECTED} + unmatched = [] + for apk in apks: + match = APK_NAME.search(apk.name) + if match: + grouped[match.group(1).lower()].append(apk) + else: + unmatched.append(apk.name) + wrong_counts = { + flavor: len(paths) for flavor, paths in grouped.items() if len(paths) != 1 + } + if len(apks) != len(EXPECTED) or unmatched or wrong_counts: + details = ", ".join( + f"{flavor}={len(grouped[flavor])}" for flavor in sorted(grouped) + ) + if unmatched: + details += f"; unrecognized={','.join(unmatched)}" + problems.append( + "[apk/set] expected exactly four release APKs, one per flavor " + f"(standard, standardfull, offline, offlinelite); found {details}" + ) + return { + flavor: paths[0] + for flavor, paths in grouped.items() + if len(paths) == 1 + } + + +def _zip_entries(apk: Path) -> set[str]: + try: + with zipfile.ZipFile(apk) as archive: + return {name.lstrip("/") for name in archive.namelist()} + except (OSError, zipfile.BadZipFile) as exc: + raise AnalyzerError(f"cannot inspect ZIP contents of {apk.name}: {exc}") from exc + + +def _permissions(output: str) -> set[str]: + return set(re.findall(r"\bandroid\.permission\.[A-Za-z0-9_.]+", output)) + + +def check_apks(apk_dir: Path, analyzer: Analyzer) -> list[str]: + problems: list[str] = [] + apks = _discover(apk_dir, problems) + for flavor, (expected_id, expected_min_sdk, needs_internet, needs_dict) in EXPECTED.items(): + apk = apks.get(flavor) + if apk is None: + continue + try: + entries = _zip_entries(apk) + except AnalyzerError as exc: + problems.append(f"[apk/{flavor}/assets] {exc}") + entries = set() + dictionaries = { + entry + for entry in entries + if entry.startswith("assets/dicts/") and entry.endswith(".dict") + } + if needs_dict and "assets/dicts/main_en-US.dict" not in dictionaries: + problems.append( + f"[apk/{flavor}/dictionaries] {apk.name} must contain " + "assets/dicts/main_en-US.dict" + ) + if not needs_dict and dictionaries: + sample = ", ".join(sorted(dictionaries)[:3]) + problems.append( + f"[apk/{flavor}/dictionaries] {apk.name} must not package .dict files " + f"under assets/dicts/ (found {sample})" + ) + + try: + application_id = analyzer("application-id", apk).strip() + min_sdk_text = analyzer("min-sdk", apk).strip() + permission_set = _permissions(analyzer("permissions", apk)) + except AnalyzerError as exc: + problems.append(f"[apk/{flavor}/manifest] {exc}") + continue + + if application_id != expected_id: + problems.append( + f"[apk/{flavor}/application-id] expected {expected_id}, " + f"found {application_id or 'empty output'}" + ) + if not re.fullmatch(r"\d+", min_sdk_text): + problems.append( + f"[apk/{flavor}/min-sdk] apkanalyzer returned a non-integer minSdk: " + f"{min_sdk_text or 'empty output'}" + ) + elif int(min_sdk_text) != expected_min_sdk: + problems.append( + f"[apk/{flavor}/min-sdk] expected {expected_min_sdk}, " + f"found {min_sdk_text}" + ) + has_internet = INTERNET in permission_set + if has_internet != needs_internet: + expected = "declare" if needs_internet else "not declare" + problems.append( + f"[apk/{flavor}/internet] {apk.name} must {expected} {INTERNET}" + ) + return problems + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apk-dir", required=True, type=Path) + parser.add_argument( + "--apkanalyzer", + default=None, + help="apkanalyzer executable (auto-detected from PATH or the Android SDK)", + ) + args = parser.parse_args(argv) + analyzer = resolve_apkanalyzer(args.apkanalyzer) + problems = check_apks(args.apk_dir, subprocess_analyzer(analyzer)) + if problems: + print(f"LeanTypeDual APK invariant gate failed ({len(problems)} violation(s)):") + for problem in problems: + print(f" - {problem}") + return 1 + print("[ok] packaged LeanTypeDual APK invariants hold") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/check_fork_invariants.py b/tools/check_fork_invariants.py new file mode 100644 index 000000000..ebe3dc2a7 --- /dev/null +++ b/tools/check_fork_invariants.py @@ -0,0 +1,707 @@ +#!/usr/bin/env python3 +"""Fail when an upstream merge drops a LeanTypeDual product invariant.""" + +from __future__ import annotations + +import argparse +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +EXPECTED_FLAVORS = {"standard", "standardfull", "offline", "offlinelite"} +LEGACY_SIGNATURE_FLAVORS = {"standard", "standardfull", "offlinelite"} +ANDROID_NAME = "{http://schemas.android.com/apk/res/android}name" +VERSION_CODE_FLOOR = 4300 + + +def _masked(text: str, hide_strings: bool) -> str: + """Mask comments, and optionally strings, without changing offsets.""" + out = list(text) + i = 0 + state = "code" + quote = "" + while i < len(text): + if state == "code": + if text.startswith("//", i): + out[i:i + 2] = " " + i += 2 + state = "line-comment" + elif text.startswith("/*", i): + out[i:i + 2] = " " + i += 2 + state = "block-comment" + elif text.startswith('"""', i): + if hide_strings: + out[i:i + 3] = " " + i += 3 + state = "triple-string" + elif text[i] in {'"', "'"}: + quote = text[i] + if hide_strings: + out[i] = " " + i += 1 + state = "string" + else: + i += 1 + elif state == "line-comment": + if text[i] == "\n": + state = "code" + else: + out[i] = " " + i += 1 + elif state == "block-comment": + if text.startswith("*/", i): + out[i:i + 2] = " " + i += 2 + state = "code" + else: + if text[i] != "\n": + out[i] = " " + i += 1 + elif state == "triple-string": + if text.startswith('"""', i): + if hide_strings: + out[i:i + 3] = " " + i += 3 + state = "code" + else: + if hide_strings and text[i] != "\n": + out[i] = " " + i += 1 + else: + if text[i] == "\\" and i + 1 < len(text): + if hide_strings: + out[i:i + 2] = " " + i += 2 + elif text[i] == quote: + if hide_strings: + out[i] = " " + i += 1 + state = "code" + else: + if hide_strings and text[i] != "\n": + out[i] = " " + i += 1 + return "".join(out) + + +def _without_comments(text: str) -> str: + return _masked(text, hide_strings=False) + + +def _structure(text: str) -> str: + return _masked(text, hide_strings=True) + + +def _matching_delimiter(text: str, opening: int, left: str, right: str) -> int: + depth = 0 + for i in range(opening, len(text)): + if text[i] == left: + depth += 1 + elif text[i] == right: + depth -= 1 + if depth == 0: + return i + return -1 + + +def _extract_block(text: str, header_pattern: str) -> str | None: + clean = _without_comments(text) + structure = _structure(text) + match = re.search(header_pattern, clean, re.MULTILINE) + if not match: + return None + opening = structure.find("{", match.end()) + if opening < 0: + return None + closing = _matching_delimiter(structure, opening, "{", "}") + return None if closing < 0 else text[opening + 1:closing] + + +def _extract_parenthesized(text: str, header_pattern: str) -> str | None: + clean = _without_comments(text) + structure = _structure(text) + match = re.search(header_pattern, clean, re.MULTILINE) + if not match: + return None + opening = structure.find("(", match.end()) + if opening < 0: + return None + closing = _matching_delimiter(structure, opening, "(", ")") + return None if closing < 0 else text[opening + 1:closing] + + +def _brace_depth_before(structure: str, offset: int) -> int: + return structure[:offset].count("{") - structure[:offset].count("}") + + +def _flavor_blocks(product_flavors: str) -> dict[str, list[str]]: + clean = _without_comments(product_flavors) + structure = _structure(product_flavors) + blocks: dict[str, list[str]] = {} + pattern = re.compile(r'\bcreate\s*\(\s*"([^"]+)"\s*\)\s*\{') + for match in pattern.finditer(clean): + opening = match.end() - 1 + if _brace_depth_before(structure, opening) != 0: + continue + closing = _matching_delimiter(structure, opening, "{", "}") + if closing >= 0: + blocks.setdefault(match.group(1), []).append( + product_flavors[opening + 1:closing] + ) + return blocks + + +def _string_assignments(block: str, name: str) -> list[str]: + pattern = rf'(?m)^\s*{re.escape(name)}\s*=\s*"([^"]+)"\s*$' + return re.findall(pattern, _without_comments(block)) + + +def _integer_assignments(block: str, name: str) -> list[int]: + pattern = rf"(?m)^\s*{re.escape(name)}\s*=\s*(\d+)\s*$" + return [int(value) for value in re.findall(pattern, _without_comments(block))] + + +def _read(root: Path, relative: str, invariant: str, problems: list[str]) -> str | None: + path = root / relative + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + problems.append(f"[{invariant}] cannot read {relative}: {exc}") + return None + + +def _expect_one(values: list[object], expected: object) -> bool: + return len(values) == 1 and values[0] == expected + + +def _check_identity_and_flavors(root: Path, problems: list[str]) -> None: + relative = "app/build.gradle.kts" + source = _read(root, relative, "gradle", problems) + if source is None: + return + + default = _extract_block(source, r"\bdefaultConfig\b") + if default is None: + problems.append("[identity] app/build.gradle.kts has no readable defaultConfig block") + return + + app_ids = _string_assignments(default, "applicationId") + if not _expect_one(app_ids, "com.asafmah.leantypedual"): + problems.append( + "[application-id] defaultConfig.applicationId must be the literal " + '"com.asafmah.leantypedual"' + ) + + default_min_sdks = _integer_assignments(default, "minSdk") + if not _expect_one(default_min_sdks, 21): + problems.append( + "[flavors/offlinelite] defaultConfig.minSdk must be 21 so offlinelite " + "inherits API 21 support" + ) + + version_names = _string_assignments(default, "versionName") + version_codes = _integer_assignments(default, "versionCode") + if len(version_names) != 1 or not re.fullmatch(r"\d+\.\d+\.\d+", version_names[0]): + problems.append( + "[version] defaultConfig.versionName must be one literal SemVer value (X.Y.Z)" + ) + if len(version_codes) != 1: + problems.append("[version] defaultConfig.versionCode must be one literal integer") + if ( + len(version_names) == 1 + and re.fullmatch(r"\d+\.\d+\.\d+", version_names[0]) + and len(version_codes) == 1 + ): + major, minor, patch = (int(part) for part in version_names[0].split(".")) + code = version_codes[0] + if major == 4: + problems.append( + f"[version] versionName {version_names[0]} looks like the upstream 4.x " + "release line, not LeanTypeDual's independent version" + ) + if minor > 9 or patch > 9: + problems.append( + "[version] minor and patch must stay single-digit because the documented " + "versionCode formula allocates one decimal digit to each" + ) + expected_code = 4000 + major * 1000 + minor * 100 + patch * 10 + changelog_dir = root / "fastlane/metadata/android/en-US/changelogs" + released_codes = [ + int(path.stem) + for path in changelog_dir.glob("*.txt") + if path.stem.isdigit() + ] + maintained_floor = max([VERSION_CODE_FLOOR, *released_codes]) + if code < maintained_floor: + problems.append( + f"[version] versionCode {code} is below LeanTypeDual's maintained floor " + f"{maintained_floor} (the newest numeric fastlane changelog)" + ) + if code != expected_code: + problems.append( + f"[version] versionCode {code} does not match LeanTypeDual's documented " + f"formula for {version_names[0]} (expected {expected_code})" + ) + + product_flavors = _extract_block(source, r"\bproductFlavors\b") + if product_flavors is None: + problems.append("[flavors] app/build.gradle.kts has no readable productFlavors block") + else: + blocks = _flavor_blocks(product_flavors) + names = set(blocks) + if names != EXPECTED_FLAVORS or any(len(items) != 1 for items in blocks.values()): + problems.append( + "[flavors] productFlavors must define exactly once: " + + ", ".join(sorted(EXPECTED_FLAVORS)) + + f"; found: {', '.join(sorted(names)) or 'none'}" + ) + expected_details = { + "standard": (23, None), + "standardfull": (23, None), + "offline": (26, ".offline"), + "offlinelite": (None, ".offlinelite"), + } + for name, (min_sdk, suffix) in expected_details.items(): + if len(blocks.get(name, [])) != 1: + continue + block = blocks[name][0] + min_sdks = _integer_assignments(block, "minSdk") + suffixes = _string_assignments(block, "applicationIdSuffix") + min_ok = not min_sdks if min_sdk is None else _expect_one(min_sdks, min_sdk) + suffix_ok = not suffixes if suffix is None else _expect_one(suffixes, suffix) + if not min_ok: + expected = "inherit defaultConfig.minSdk 21" if min_sdk is None else str(min_sdk) + problems.append(f"[flavors/{name}] minSdk must {('be ' + expected) if min_sdk is not None else expected}") + if not suffix_ok: + expected = "have no applicationIdSuffix" if suffix is None else f'be "{suffix}"' + problems.append(f"[flavors/{name}] applicationIdSuffix must {expected}") + + _check_dictionary_packaging(source, problems) + + +def _check_dictionary_packaging(source: str, problems: list[str]) -> None: + variants = _extract_block(source, r"\bandroidComponents\.onVariants\b") + if variants is None: + problems.append( + "[dictionary-assets] app/build.gradle.kts has no readable " + "androidComponents.onVariants block" + ) + else: + clean = _without_comments(variants) + structure = _structure(variants) + guards: list[tuple[int, int]] = [] + for match in re.finditer(r"\bif\s*\(([^)]*)\)\s*\{", clean): + condition = re.sub(r"\s+", "", match.group(1)) + accepted = { + 'variant.flavorName=="standard"||variant.flavorName=="standardfull"', + 'variant.flavorName=="standardfull"||variant.flavorName=="standard"', + } + if condition not in accepted: + continue + opening = match.end() - 1 + closing = _matching_delimiter(structure, opening, "{", "}") + if closing >= 0: + guards.append((opening + 1, closing)) + + markers = [ + 'project.file("src/main/assets/dicts")', + 'file.name.endsWith(".dict")', + "patterns.add(file.name)", + ] + guard_ok = False + if len(guards) == 1: + start, end = guards[0] + guarded = re.sub(r"\s+", "", clean[start:end]) + guard_ok = all(re.sub(r"\s+", "", marker) in guarded for marker in markers) + dict_scans = [m.start() for m in re.finditer(r'endsWith\s*\(\s*"\.dict"\s*\)', clean)] + guard_ok = guard_ok and len(dict_scans) == 1 and start <= dict_scans[0] < end + if not guard_ok: + problems.append( + "[dictionary-assets] bulk .dict exclusion must be guarded by exactly " + 'variant.flavorName == "standard" || variant.flavorName == "standardfull"; ' + "offline flavors must retain bundled dictionaries" + ) + assignments = re.findall( + r"variant\.androidResources\.ignoreAssetsPatterns\s*=\s*patterns\b", + clean, + ) + if len(assignments) != 1: + problems.append( + "[dictionary-assets] the guarded dictionary patterns must be applied " + "exactly once via variant.androidResources.ignoreAssetsPatterns" + ) + + clean_source = _without_comments(source) + dependency = re.compile( + r'"offlineImplementation"\s*\(\s*' + r'"io\.github\.ljcamargo:llamacpp-kotlin:0\.4\.0"\s*\)' + ) + if len(dependency.findall(clean_source)) != 1: + problems.append( + "[offline-ai] app/build.gradle.kts must retain exactly one " + 'offlineImplementation("io.github.ljcamargo:llamacpp-kotlin:0.4.0")' + ) + + +def _manifest_permissions(path: Path) -> tuple[int, str | None]: + try: + root = ET.parse(path).getroot() + except (OSError, ET.ParseError) as exc: + return 0, str(exc) + count = 0 + for element in root.iter(): + tag = element.tag.rsplit("}", 1)[-1] + if tag in {"uses-permission", "uses-permission-sdk-23"}: + count += element.get(ANDROID_NAME) == "android.permission.INTERNET" + return count, None + + +def _check_manifests(root: Path, problems: list[str]) -> None: + src = root / "app/src" + manifests = sorted(src.glob("*/AndroidManifest.xml")) if src.is_dir() else [] + by_source_set = {path.parent.name: path for path in manifests} + + for source_set in ("standard", "standardfull"): + path = by_source_set.get(source_set) + if path is None: + problems.append( + f"[internet/{source_set}] app/src/{source_set}/AndroidManifest.xml must " + "declare android.permission.INTERNET exactly once" + ) + continue + count, error = _manifest_permissions(path) + if error: + problems.append(f"[internet/{source_set}] cannot parse {path.relative_to(root)}: {error}") + elif count != 1: + problems.append( + f"[internet/{source_set}] {path.relative_to(root)} must declare " + f"android.permission.INTERNET exactly once; found {count}" + ) + + for source_set, path in by_source_set.items(): + if source_set in {"standard", "standardfull"}: + continue + count, error = _manifest_permissions(path) + if error: + problems.append(f"[internet/{source_set}] cannot parse {path.relative_to(root)}: {error}") + elif count: + problems.append( + f"[internet/{source_set}] {path.relative_to(root)} must not declare " + "android.permission.INTERNET" + ) + + main = by_source_set.get("main") + if main is None: + problems.append("[initialization-provider] app/src/main/AndroidManifest.xml is missing") + return + try: + manifest = ET.parse(main).getroot() + except (OSError, ET.ParseError) as exc: + problems.append(f"[initialization-provider] cannot parse {main.relative_to(root)}: {exc}") + return + providers = [ + element + for element in manifest.iter() + if element.tag.rsplit("}", 1)[-1] == "provider" + and element.get(ANDROID_NAME) == "androidx.startup.InitializationProvider" + ] + if len(providers) != 1: + problems.append( + "[initialization-provider] app/src/main/AndroidManifest.xml must contain " + f"exactly one androidx.startup.InitializationProvider; found {len(providers)}" + ) + + +def _class_level_method(source: str, class_name: str, method_name: str) -> str | None: + class_body = _extract_block( + source, rf"\bpublic\s+(?:final\s+)?class\s+{re.escape(class_name)}\b[^{{}}]*" + ) + if class_body is None: + return None + clean = _without_comments(class_body) + structure = _structure(class_body) + pattern = re.compile( + rf"\bpublic\s+void\s+{re.escape(method_name)}\s*\(\s*\)\s*\{{" + ) + methods = [] + for match in pattern.finditer(clean): + opening = match.end() - 1 + if _brace_depth_before(structure, opening) != 0: + continue + closing = _matching_delimiter(structure, opening, "{", "}") + if closing >= 0: + methods.append(class_body[opening + 1:closing]) + return methods[0] if len(methods) == 1 else None + + +def _has_direct_call(method_body: str, call_pattern: str) -> bool: + clean = _without_comments(method_body) + structure = _structure(method_body) + matches = list(re.finditer(call_pattern, clean)) + return ( + len(matches) == 1 + and _brace_depth_before(structure, matches[0].start()) == 0 + ) + + +def _check_sources(root: Path, problems: list[str]) -> None: + proofread_base = Path( + "app/src/offlinelite/java/helium314/keyboard/latin/utils" + ) + for filename in ("ProofreadHelper.kt", "ProofreadService.kt"): + relative = proofread_base / filename + if not (root / relative).is_file(): + problems.append(f"[offlinelite-sources] required source is missing: {relative}") + + latin_relative = "app/src/main/java/helium314/keyboard/latin/LatinIME.java" + latin = _read(root, latin_relative, "latin-ime", problems) + if latin is not None: + on_create = _class_level_method(latin, "LatinIME", "onCreate") + initialize = ( + r"(?:helium314\.keyboard\.latin\.gesture\.)?" + r"SwipeGestureEngine\.initialize\s*\(\s*this\s*\)\s*;" + ) + if on_create is None or not _has_direct_call(on_create, initialize): + problems.append( + "[latin-ime/on-create] LatinIME.onCreate must directly call " + "SwipeGestureEngine.initialize(this) exactly once" + ) + on_destroy = _class_level_method(latin, "LatinIME", "onDestroy") + cancel = ( + r"(?:helium314\.keyboard\.latin\.gesture\.)?" + r"SwipeGestureEngine\.cancelIndexing\s*\(\s*\)\s*;" + ) + if on_destroy is None or not _has_direct_call(on_destroy, cancel): + problems.append( + "[latin-ime/on-destroy] LatinIME.onDestroy must directly call " + "SwipeGestureEngine.cancelIndexing() exactly once" + ) + + settings_relative = ( + "app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt" + ) + settings = _read(root, settings_relative, "two-thumb-settings", problems) + if settings is None: + return + modules = _extract_parenthesized( + settings, r"\bprivate\s+val\s+modules\s*=\s*listOf\b" + ) + matching_modules = 0 + if modules is not None: + clean = _without_comments(modules) + structure = _structure(modules) + for match in re.finditer(r"\bSettingsModule\s*\(", clean): + opening = match.end() - 1 + closing = _matching_delimiter(structure, opening, "(", ")") + if closing < 0: + continue + arguments = re.sub(r"\s+", "", clean[opening + 1:closing]) + if ( + arguments.startswith( + "SettingsWithoutKey.SCREEN_NAV_TWO_THUMB_TYPING," + "SettingsDestination.TwoThumbTyping," + ) + and "provider=::createTwoThumbTypingSettings" in arguments + ): + matching_modules += 1 + clean_settings = _without_comments(settings) + constants = re.findall( + r'(?m)^\s*const\s+val\s+SCREEN_NAV_TWO_THUMB_TYPING\s*=\s*' + r'"screen_nav_two_thumb_typing"\s*$', + clean_settings, + ) + if matching_modules != 1 or len(constants) != 1: + problems.append( + "[two-thumb-settings] SettingsContainer.kt must define " + "SCREEN_NAV_TWO_THUMB_TYPING and register exactly one TwoThumbTyping " + "SettingsModule with createTwoThumbTypingSettings" + ) + + +def _workflow_step_run( + source: str, step_name: str, invariant: str, problems: list[str] +) -> str | None: + lines = source.splitlines() + matches: list[tuple[int, int]] = [] + for i, line in enumerate(lines): + match = re.match(r"^(\s*)-\s+name:\s*(.*?)\s*$", line) + if match and match.group(2).strip("'\"") == step_name: + matches.append((i, len(match.group(1)))) + if len(matches) != 1: + problems.append( + f"[{invariant}] release workflow must contain exactly one step named " + f'"{step_name}"; found {len(matches)}' + ) + return None + start, indent = matches[0] + end = len(lines) + item_prefix = re.compile(rf"^\s{{{indent}}}-\s+") + for i in range(start + 1, len(lines)): + if item_prefix.match(lines[i]): + end = i + break + for i in range(start + 1, end): + match = re.match(r"^(\s*)run:\s*(.*?)\s*$", lines[i]) + if not match: + continue + value = match.group(2) + if value and value not in {"|", "|-"}: + return value + if value not in {"|", "|-"}: + problems.append( + f"[{invariant}] step \"{step_name}\" must use an inline or literal run block" + ) + return None + return "\n".join(lines[i + 1:end]) + problems.append(f'[{invariant}] step "{step_name}" has no run command') + return None + + +def _strip_shell_comments(source: str) -> str: + cleaned = [] + for line in source.splitlines(): + quote = "" + escaped = False + code = [] + for char in line: + if escaped: + code.append(char) + escaped = False + elif char == "\\": + code.append(char) + escaped = True + elif quote: + code.append(char) + if char == quote: + quote = "" + elif char in {"'", '"'}: + code.append(char) + quote = char + elif char == "#": + break + else: + code.append(char) + cleaned.append("".join(code)) + return "\n".join(cleaned) + + +def _check_release_workflow(root: Path, problems: list[str]) -> None: + relative = ".github/workflows/release.yml" + source = _read(root, relative, "release", problems) + if source is None: + return + + build = _workflow_step_run( + source, "Build signed release APKs (all flavors)", "release/build-flavors", problems + ) + if build is not None: + tasks = re.findall( + r":app:assemble([A-Za-z0-9]+)Release\b", _strip_shell_comments(build) + ) + flavors = {task.lower() for task in tasks} + if flavors != EXPECTED_FLAVORS or len(tasks) != len(EXPECTED_FLAVORS): + problems.append( + "[release/build-flavors] release build step must execute exactly one " + "Release assemble task for each of: " + + ", ".join(sorted(EXPECTED_FLAVORS)) + ) + + verify = _workflow_step_run( + source, "Verify release APK signatures", "release/legacy-signatures", problems + ) + if verify is None: + return + verify = _strip_shell_comments(verify) + lines = verify.splitlines() + legacy_arms: list[tuple[set[str], str]] = [] + for i, line in enumerate(lines): + flavors = set(re.findall(r"\*-(standard|standardfull|offline|offlinelite)-release\.apk", line)) + if not flavors or ")" not in line: + continue + end = next((j for j in range(i + 1, len(lines)) if ";;" in lines[j]), -1) + if end >= 0: + legacy_arms.append((flavors, "\n".join(lines[i + 1:end]))) + matching_arms = [ + body for flavors, body in legacy_arms if flavors == LEGACY_SIGNATURE_FLAVORS + ] + low_api_ok = False + if len(matching_arms) == 1: + for line in matching_arms[0].splitlines(): + if ( + "APKSIGNER" in line + and re.search(r"\bverify\b", line) + and re.search(r"--min-sdk-version\s+21\b", line) + and re.search(r"--max-sdk-version\s+23\b", line) + and re.search(r'["\']?\$apk["\']?', line) + ): + low_api_ok = True + break + count_ok = re.search(r'test\s+["\']?\$count["\']?\s+-eq\s+4\b', verify) + legacy_count_ok = re.search( + r'test\s+["\']?\$legacy_count["\']?\s+-eq\s+3\b', verify + ) + loop_ok = re.search( + r"for\s+apk\s+in\s+app/build/outputs/apk/\*/release/\*\.apk\s*;", verify + ) + if not (low_api_ok and count_ok and legacy_count_ok and loop_ok): + problems.append( + "[release/legacy-signatures] signature step must inspect exactly four " + "release APKs and run apksigner with min SDK 21 / max SDK 23 for exactly " + "standard, standardfull, and offlinelite (the v1/JAR-signature variants)" + ) + + packaged = _workflow_step_run( + source, + "Verify packaged LeanTypeDual invariants", + "release/packaged-invariants", + problems, + ) + if packaged is not None: + command = re.sub(r"\s+", " ", _strip_shell_comments(packaged)).strip() + required = ( + r'python3 tools/check_apk_invariants\.py ' + r'--apk-dir app/build/outputs/apk ' + r'--apkanalyzer ["\']?\$APKANALYZER["\']?' + ) + if not re.search(required, command): + problems.append( + "[release/packaged-invariants] packaged invariant step must run " + "tools/check_apk_invariants.py against app/build/outputs/apk with " + "the resolved APKANALYZER executable" + ) + + +def check_repo(root: Path) -> list[str]: + root = root.resolve() + problems: list[str] = [] + _check_identity_and_flavors(root, problems) + _check_manifests(root, problems) + _check_sources(root, problems) + _check_release_workflow(root, problems) + return problems + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="repository root (defaults to the parent of tools/)", + ) + args = parser.parse_args(argv) + problems = check_repo(args.root) + if problems: + print(f"LeanTypeDual fork invariant gate failed ({len(problems)} violation(s)):") + for problem in problems: + print(f" - {problem}") + return 1 + print("[ok] LeanTypeDual fork invariants hold") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/tests/fixtures/apk_invariants.json b/tools/tests/fixtures/apk_invariants.json new file mode 100644 index 000000000..9acceaf2d --- /dev/null +++ b/tools/tests/fixtures/apk_invariants.json @@ -0,0 +1,26 @@ +{ + "standard": { + "applicationId": "com.asafmah.leantypedual", + "minSdk": 23, + "permissions": ["android.permission.INTERNET"], + "files": [] + }, + "standardfull": { + "applicationId": "com.asafmah.leantypedual", + "minSdk": 23, + "permissions": ["android.permission.INTERNET"], + "files": [] + }, + "offline": { + "applicationId": "com.asafmah.leantypedual.offline", + "minSdk": 26, + "permissions": [], + "files": ["assets/dicts/main_en-US.dict"] + }, + "offlinelite": { + "applicationId": "com.asafmah.leantypedual.offlinelite", + "minSdk": 21, + "permissions": [], + "files": ["assets/dicts/main_en-US.dict"] + } +} diff --git a/tools/tests/fixtures/fork_invariants/.github/workflows/release.yml b/tools/tests/fixtures/fork_invariants/.github/workflows/release.yml new file mode 100644 index 000000000..60b311295 --- /dev/null +++ b/tools/tests/fixtures/fork_invariants/.github/workflows/release.yml @@ -0,0 +1,30 @@ +name: Release + +jobs: + release: + steps: + - name: Build signed release APKs (all flavors) + run: ./gradlew :app:assembleStandardRelease :app:assembleStandardfullRelease :app:assembleOfflineRelease :app:assembleOfflineliteRelease + + - name: Verify packaged LeanTypeDual invariants + run: | + APKANALYZER="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/apkanalyzer" + python3 tools/check_apk_invariants.py --apk-dir app/build/outputs/apk --apkanalyzer "$APKANALYZER" + + - name: Verify release APK signatures + run: | + APKSIGNER="$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner -type f | tail -1)" + count=0 + legacy_count=0 + for apk in app/build/outputs/apk/*/release/*.apk; do + "$APKSIGNER" verify --verbose --print-certs "$apk" + case "$apk" in + *-standard-release.apk|*-standardfull-release.apk|*-offlinelite-release.apk) + "$APKSIGNER" verify --verbose --min-sdk-version 21 --max-sdk-version 23 "$apk" + legacy_count=$((legacy_count + 1)) + ;; + esac + count=$((count + 1)) + done + test "$count" -eq 4 + test "$legacy_count" -eq 3 diff --git a/tools/tests/fixtures/fork_invariants/app/build.gradle.kts b/tools/tests/fixtures/fork_invariants/app/build.gradle.kts new file mode 100644 index 000000000..ce6088996 --- /dev/null +++ b/tools/tests/fixtures/fork_invariants/app/build.gradle.kts @@ -0,0 +1,47 @@ +android { + defaultConfig { + applicationId = "com.asafmah.leantypedual" + minSdk = 21 + versionCode = 4300 + versionName = "0.3.0" + } + + productFlavors { + create("standard") { + dimension = "privacy" + minSdk = 23 + } + create("standardfull") { + dimension = "privacy" + minSdk = 23 + } + create("offline") { + dimension = "privacy" + applicationIdSuffix = ".offline" + minSdk = 26 + } + create("offlinelite") { + dimension = "privacy" + applicationIdSuffix = ".offlinelite" + } + } + + androidComponents.onVariants { variant -> + val patterns = mutableListOf() + if (variant.flavorName == "standard" || variant.flavorName == "standardfull") { + val dictsDir = project.file("src/main/assets/dicts") + dictsDir.listFiles()?.forEach { file -> + if (file.name.endsWith(".dict")) { + patterns.add(file.name) + } + } + } + if (patterns.isNotEmpty()) { + variant.androidResources.ignoreAssetsPatterns = patterns + } + } +} + +dependencies { + "offlineImplementation"("io.github.ljcamargo:llamacpp-kotlin:0.4.0") +} diff --git a/tools/tests/fixtures/fork_invariants/app/src/main/AndroidManifest.xml b/tools/tests/fixtures/fork_invariants/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..99deaa052 --- /dev/null +++ b/tools/tests/fixtures/fork_invariants/app/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/tools/tests/fixtures/fork_invariants/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/tools/tests/fixtures/fork_invariants/app/src/main/java/helium314/keyboard/latin/LatinIME.java new file mode 100644 index 000000000..b322c6563 --- /dev/null +++ b/tools/tests/fixtures/fork_invariants/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -0,0 +1,11 @@ +package helium314.keyboard.latin; + +public class LatinIME { + public void onCreate() { + helium314.keyboard.latin.gesture.SwipeGestureEngine.initialize(this); + } + + public void onDestroy() { + helium314.keyboard.latin.gesture.SwipeGestureEngine.cancelIndexing(); + } +} diff --git a/tools/tests/fixtures/fork_invariants/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt b/tools/tests/fixtures/fork_invariants/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt new file mode 100644 index 000000000..18aff5c72 --- /dev/null +++ b/tools/tests/fixtures/fork_invariants/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt @@ -0,0 +1,11 @@ +private val modules = listOf( + SettingsModule( + SettingsWithoutKey.SCREEN_NAV_TWO_THUMB_TYPING, + SettingsDestination.TwoThumbTyping, + provider = ::createTwoThumbTypingSettings, + ), +) + +object SettingsWithoutKey { + const val SCREEN_NAV_TWO_THUMB_TYPING = "screen_nav_two_thumb_typing" +} diff --git a/tools/tests/fixtures/fork_invariants/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadHelper.kt b/tools/tests/fixtures/fork_invariants/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadHelper.kt new file mode 100644 index 000000000..ab01fdee2 --- /dev/null +++ b/tools/tests/fixtures/fork_invariants/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadHelper.kt @@ -0,0 +1,3 @@ +package helium314.keyboard.latin.utils + +object ProofreadHelper diff --git a/tools/tests/fixtures/fork_invariants/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt b/tools/tests/fixtures/fork_invariants/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt new file mode 100644 index 000000000..aef234e34 --- /dev/null +++ b/tools/tests/fixtures/fork_invariants/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -0,0 +1,3 @@ +package helium314.keyboard.latin.utils + +class ProofreadService diff --git a/tools/tests/fixtures/fork_invariants/app/src/standard/AndroidManifest.xml b/tools/tests/fixtures/fork_invariants/app/src/standard/AndroidManifest.xml new file mode 100644 index 000000000..19d2638eb --- /dev/null +++ b/tools/tests/fixtures/fork_invariants/app/src/standard/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tools/tests/fixtures/fork_invariants/app/src/standardfull/AndroidManifest.xml b/tools/tests/fixtures/fork_invariants/app/src/standardfull/AndroidManifest.xml new file mode 100644 index 000000000..19d2638eb --- /dev/null +++ b/tools/tests/fixtures/fork_invariants/app/src/standardfull/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tools/tests/fixtures/fork_invariants/fastlane/metadata/android/en-US/changelogs/4300.txt b/tools/tests/fixtures/fork_invariants/fastlane/metadata/android/en-US/changelogs/4300.txt new file mode 100644 index 000000000..2a59600bf --- /dev/null +++ b/tools/tests/fixtures/fork_invariants/fastlane/metadata/android/en-US/changelogs/4300.txt @@ -0,0 +1 @@ +Fixture release notes. diff --git a/tools/tests/test_check_apk_invariants.py b/tools/tests/test_check_apk_invariants.py new file mode 100644 index 000000000..c52373d03 --- /dev/null +++ b/tools/tests/test_check_apk_invariants.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Fixture-driven tests for packaged LeanTypeDual APK invariants.""" + +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import check_apk_invariants as gate # noqa: E402 + + +FIXTURE = Path(__file__).parent / "fixtures" / "apk_invariants.json" + + +class ApkInvariantTests(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.apk_dir = Path(self.temp_dir.name) / "apks" + self.apk_dir.mkdir() + self.metadata = json.loads(FIXTURE.read_text(encoding="utf-8")) + self._write_apks() + + def tearDown(self): + self.temp_dir.cleanup() + + def _write_apks(self): + for apk in self.apk_dir.glob("*.apk"): + apk.unlink() + for flavor, metadata in self.metadata.items(): + path = self.apk_dir / f"1-LeanTypeDual_0.3.0-{flavor}-release.apk" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("AndroidManifest.xml", b"fixture") + for entry in metadata["files"]: + archive.writestr(entry, b"dictionary") + + def _analyzer(self, operation: str, apk: Path) -> str: + match = gate.APK_NAME.search(apk.name) + self.assertIsNotNone(match) + metadata = self.metadata[match.group(1).lower()] + if operation == "application-id": + return metadata["applicationId"] + "\n" + if operation == "min-sdk": + return str(metadata["minSdk"]) + "\n" + if operation == "permissions": + return "\n".join(metadata["permissions"]) + "\n" + raise AssertionError(f"unexpected operation: {operation}") + + def assert_violation(self, invariant: str): + problems = gate.check_apks(self.apk_dir, self._analyzer) + self.assertTrue( + any(problem.startswith(f"[{invariant}]") for problem in problems), + f"expected [{invariant}], got: {problems}", + ) + + def test_valid_release_set_passes(self): + self.assertEqual(gate.check_apks(self.apk_dir, self._analyzer), []) + + def test_missing_flavor_fails(self): + next(self.apk_dir.glob("*-offlinelite-release.apk")).unlink() + self.assert_violation("apk/set") + + def test_extra_debug_apk_is_ignored(self): + with zipfile.ZipFile(self.apk_dir / "LeanTypeDual-standard-debug.apk", "w") as archive: + archive.writestr("AndroidManifest.xml", b"debug") + self.assertEqual(gate.check_apks(self.apk_dir, self._analyzer), []) + + def test_unknown_release_apk_fails(self): + with zipfile.ZipFile(self.apk_dir / "LeanTypeDual-unknown-release.apk", "w") as archive: + archive.writestr("AndroidManifest.xml", b"unknown") + self.assert_violation("apk/set") + + def test_nested_dictionary_in_standard_fails(self): + apk = next(self.apk_dir.glob("*-standard-release.apk")) + with zipfile.ZipFile(apk, "a") as archive: + archive.writestr("assets/dicts/nested/should-not-ship.dict", b"dictionary") + self.assert_violation("apk/standard/dictionaries") + + def test_offline_requires_main_english_dictionary(self): + self.metadata["offline"]["files"] = ["assets/dicts/main_de.dict"] + self._write_apks() + self.assert_violation("apk/offline/dictionaries") + + def test_internet_permission_matches_privacy_tier(self): + with self.subTest(flavor="standard"): + self.metadata["standard"]["permissions"] = [] + self.assert_violation("apk/standard/internet") + self.metadata = json.loads(FIXTURE.read_text(encoding="utf-8")) + with self.subTest(flavor="offlinelite"): + self.metadata["offlinelite"]["permissions"] = ["android.permission.INTERNET"] + self.assert_violation("apk/offlinelite/internet") + + def test_application_id_is_effective_packaged_id(self): + self.metadata["offline"]["applicationId"] = "com.asafmah.leantypedual" + self.assert_violation("apk/offline/application-id") + + def test_min_sdk_is_effective_packaged_value(self): + self.metadata["offline"]["minSdk"] = 21 + self.assert_violation("apk/offline/min-sdk") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_check_fork_invariants.py b/tools/tests/test_check_fork_invariants.py new file mode 100644 index 000000000..283228e0a --- /dev/null +++ b/tools/tests/test_check_fork_invariants.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Mutation tests for the LeanTypeDual fork invariant gate.""" + +from __future__ import annotations + +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import check_fork_invariants as gate # noqa: E402 + + +FIXTURE = Path(__file__).parent / "fixtures" / "fork_invariants" +REPO_ROOT = Path(__file__).resolve().parents[2] + + +class ForkInvariantTests(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "repo" + shutil.copytree(FIXTURE, self.root) + + def tearDown(self): + self.temp_dir.cleanup() + + def mutate(self, relative: str, old: str, new: str) -> None: + path = self.root / relative + source = path.read_text(encoding="utf-8") + self.assertIn(old, source, f"fixture no longer contains mutation target in {relative}") + path.write_text(source.replace(old, new, 1), encoding="utf-8") + + def assert_violation(self, invariant: str) -> None: + problems = gate.check_repo(self.root) + self.assertTrue( + any(problem.startswith(f"[{invariant}]") for problem in problems), + f"expected [{invariant}], got: {problems}", + ) + + def test_valid_fixture_passes(self): + self.assertEqual(gate.check_repo(self.root), []) + + def test_real_repository_passes(self): + self.assertEqual(gate.check_repo(REPO_ROOT), []) + + def test_missing_offlinelite_flavor_fails(self): + self.mutate( + "app/build.gradle.kts", + ''' create("offlinelite") { + dimension = "privacy" + applicationIdSuffix = ".offlinelite" + } +''', + "", + ) + self.assert_violation("flavors") + + def test_offline_min_sdk_21_fails(self): + self.mutate("app/build.gradle.kts", " minSdk = 26", " minSdk = 21") + self.assert_violation("flavors/offline") + + def test_version_cannot_regress_below_released_metadata(self): + released = self.root / "fastlane/metadata/android/en-US/changelogs/4400.txt" + released.write_text("Newer released version\n", encoding="utf-8") + self.assert_violation("version") + + def test_internet_in_main_or_offline_fails(self): + permission = ' \n' + with self.subTest(source_set="main"): + self.mutate( + "app/src/main/AndroidManifest.xml", + "", + permission + " ", + ) + self.assert_violation("internet/main") + shutil.rmtree(self.root) + shutil.copytree(FIXTURE, self.root) + with self.subTest(source_set="offline"): + offline = self.root / "app/src/offline/AndroidManifest.xml" + offline.parent.mkdir(parents=True) + offline.write_text( + '\n' + '\n' + f"{permission}\n", + encoding="utf-8", + ) + self.assert_violation("internet/offline") + + def test_removed_two_thumb_module_fails(self): + self.mutate( + "app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt", + ''' SettingsModule( + SettingsWithoutKey.SCREEN_NAV_TWO_THUMB_TYPING, + SettingsDestination.TwoThumbTyping, + provider = ::createTwoThumbTypingSettings, + ), +''', + "", + ) + self.assert_violation("two-thumb-settings") + + def test_duplicate_initialization_provider_fails(self): + provider = ( + ' \n' + ) + self.mutate( + "app/src/main/AndroidManifest.xml", + " ", + provider + " ", + ) + self.assert_violation("initialization-provider") + + def test_release_build_missing_flavor_fails(self): + self.mutate( + ".github/workflows/release.yml", + " :app:assembleOfflineliteRelease", + "", + ) + self.assert_violation("release/build-flavors") + + def test_release_packaged_gate_step_is_required(self): + self.mutate( + ".github/workflows/release.yml", + ''' - name: Verify packaged LeanTypeDual invariants + run: | + APKANALYZER="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/apkanalyzer" + python3 tools/check_apk_invariants.py --apk-dir app/build/outputs/apk --apkanalyzer "$APKANALYZER" + +''', + "", + ) + self.assert_violation("release/packaged-invariants") + + def test_unscoped_dictionary_exclusion_fails(self): + self.mutate( + "app/build.gradle.kts", + ' if (variant.flavorName == "standard" || variant.flavorName == "standardfull") {', + " if (true) {", + ) + self.assert_violation("dictionary-assets") + + def test_dictionary_exclusion_must_be_applied(self): + self.mutate( + "app/build.gradle.kts", + " variant.androidResources.ignoreAssetsPatterns = patterns", + " println(patterns)", + ) + self.assert_violation("dictionary-assets") + + def test_missing_offline_llama_dependency_fails(self): + self.mutate( + "app/build.gradle.kts", + ' "offlineImplementation"("io.github.ljcamargo:llamacpp-kotlin:0.4.0")\n', + "", + ) + self.assert_violation("offline-ai") + + +if __name__ == "__main__": + unittest.main() From 59dde41c7aa26beeb26d653a021b60c1538e0c95 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Thu, 3 Sep 2026 17:16:43 +0300 Subject: [PATCH 2/2] docs(changelog): record fork invariant gates Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5506d4dd5..4c4aee562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - Documented the two-thumb decoder research in `docs/TWO_THUMB_TEMPORAL_ALIGNMENT.md`, including the measurement that deliberately overlapping stroke timestamps corrupts the decoder's speed features rather than helping. (#135) ### Reliability & testing +- Added source-level and packaged-APK gates that fail upstream merges when LeanTypeDual's identity, privacy flavors, bundled offline dictionaries, fork integrations, or four-flavor release coverage are lost. (#148) - Added a native gesture **two-pointer track harness** (`jni/tests/replay/two_pointer_track_test.cpp`) that drives the real AOSP `ProximityInfoState` on the host, with tunable knobs and a printed sweep table. Runs in CI alongside the existing native suite. Note that it exercises the in-repo engine, which is not the decoder used when a gesture library is loaded. (#135, #144) - The multi-part trail merge moved behind a pure, unit-tested `StrokeAligner` seam whose defaults reproduce the previous behaviour exactly. (#135)