Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/build-test-auto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```

Expand Down Expand Up @@ -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.**
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
208 changes: 208 additions & 0 deletions tools/check_apk_invariants.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading