# Android Functional Sync Test

**Status:** Planning → walking-skeleton in progress
**Branch:** `feat/android-functional-test`
**Owner:** kitplummer
**Date:** 2026-05-20

## Why

Two field-diagnosed sync bugs escaped to on-device UAT before being caught:

- **peat-mesh#134** — `sync_all_documents_with_peer` eagerly materialized
  `Vec<(String, Automerge)>` via `scan_prefix("")`, then discarded the bodies.
  Multi-GB native-heap sawtooth on Android; OOM-killed within ~120 s.
- **peat-mesh#136** — `initiate_delta_sync` returned `Err` when
  `SyncDoc::generate_sync_message` returned `None`, which is the
  Automerge-protocol-defined success signal for "already in sync."

Per the team norm (`feedback_dev_team_owns_validation`): bug classes
that escape on-device UAT come back as PR-time CI tests, not as a QA
gate. This plan adds the gate.

## Goal

Lock in the sync-protocol shape on a real Android emulator, gating
every peat-mesh PR.

## What we test

Two in-process `Node` instances inside an Android emulator, connected
by an in-memory loopback `TestTransport`, exercising:

1. **Initial sync emits a message** — `initiate_delta_sync` produces a
   non-empty initial `SyncMessage`.
2. **`generate_sync_message=None` is success** (PR #136 regression) —
   after catch-up, a second `initiate_delta_sync` resolves `Ok(())`,
   and the per-peer circuit breaker stays closed.
3. **Delta exchange round-trips** — a document written on node A is
   visible on node B after one sync round.
4. **No body materialization** (PR #134 regression) — after N=50
   `sync_all_documents_with_peer` cycles against a store with M
   long-history documents, `Debug.getNativeHeapAllocatedSize()` delta
   stays under a tunable ceiling.

## Structural decisions

| Question | Decision | Why |
|---|---|---|
| Goal scope | Sync protocol behavior (initiate / None / delta) + the OOM ceiling | Matches the two escaped bug classes |
| Runner | GH Actions Android emulator via `reactivecircus/android-emulator-runner@v2`, API 30, x86_64, `default` image | Standard, cacheable; arm64 emulators on GH runners are still slow |
| Native target in CI | `x86_64-linux-android` only | x86_64 emulator on x86_64 runner is the only fast path; aarch64 still built locally via existing peat-ffi gradle task |
| Test home repo | peat-mesh | Library under test owns the regression gate |
| JNI shim | New `android-tests/jni/` cdylib in peat-mesh | Decoupled from peat-ffi. Narrow API (~4 fns). Accepts divergence risk — mitigated by a follow-up that runs the same instrumented suite against real peat-ffi |
| Transport | New in-memory `TestTransport` (loopback `Arc<Mutex<VecDeque>>`) | Sync-protocol focus; eliminate emulator-side networking flake |
| Gating | New `android-test` job in `ci.yml`, required for merge | Per `feedback_dev_team_owns_validation` |
| OOM check | `Debug.getNativeHeapAllocatedSize()` delta < ceiling after N cycles | Direct measurement of the symptom that fired in the field |
| First milestone | Walking skeleton: load `.so`, return JNI version string | Get the GH Actions pipeline green before adding real assertions |

## Build pieces

1. **`android-tests/jni/Cargo.toml`** — `crate-type = ["cdylib"]`,
   path-dep on `peat-mesh`. Name: `peat-mesh-android-jni`. Output:
   `libpeat_mesh_test.so`.
2. **`android-tests/jni/src/lib.rs`** — extern "system" fns under
   `Java_com_defenseunicorns_peat_mesh_test_TestJni_*`:
   - `jniVersion(env, _class) -> jstring` (walking skeleton)
   - `initNode(env, _class, node_id) -> jlong` (future)
   - `publishDoc(env, _class, handle, key, body)` (future)
   - `syncOnce(env, _class, handle_a, handle_b) -> jboolean` (future)
   - `getDoc(env, _class, handle, key) -> jstring` (future)
   - `TestTransport` in-memory loopback (future)
3. **`android-tests/`** — self-contained Gradle project:
   - `build.gradle.kts` — Android library + instrumented tests
   - `src/androidTest/kotlin/com/defenseunicorns/peat_mesh/test/SyncProtocolTest.kt`
   - `gradlew` wrapper (vendored from peat-ffi/android/)
4. **`android-tests/build.gradle.kts` cargo task** — invokes
   `cargo build --release --target x86_64-linux-android -p
   peat-mesh-android-jni`, copies output into
   `src/main/jniLibs/x86_64/libpeat_mesh_test.so`. Pattern lifted
   from `peat/peat-ffi/android/build.gradle.kts`.
5. **`.github/workflows/ci.yml`** — new `android-test` job:
   - install Android NDK r27
   - install `x86_64-linux-android` rust target
   - cache emulator AVD
   - run `./gradlew connectedCheck` from `android-tests/`
6. **Kotlin test header comments** — each test annotated with
   `// LOCKS IN: peat-mesh#<issue>` per
   `feedback_dev_team_owns_validation`.

## Milestones

- **M1 (landed in #139): Walking skeleton end-to-end.**
  `android-tests/jni/` cdylib + `android-tests/` Gradle project + CI job.
  Test asserts only that `TestJni.jniVersion()` returns a non-empty
  string. Goal: prove the pipeline.
- **M2 (rescoped): peat-mesh#136 runtime coverage.**
  Originally planned as `noNewMessageAfterCatchupSucceeds` Kotlin
  test inside the Android cdylib. On investigation, this would have
  required either (a) standing up two real iroh endpoints in-process
  or (b) exposing a `test-helpers` feature flag on peat-mesh —
  `AutomergeSyncCoordinator::new()` only accepts a transport that
  returns concrete `iroh::endpoint::Connection`, and the existing
  test patterns inside the crate construct the coordinator by direct
  private-field access (`automerge_sync.rs:4080` self-documents this
  constraint).
  Reframed as a Rust integration test in
  `tests/automerge_sync_protocol_contract.rs` that pins the
  *upstream* invariant peat-mesh#136 depends on (Automerge's
  `generate_sync_message` returning `None` for "already in sync"
  rather than an error). Combined with the
  `initiate_delta_sync_treats_no_sync_message_as_success` source-
  level structural pin landed in #136, the runtime + revert gap is
  closed without the iroh two-endpoint test harness.
- **M3 (in-flight): peat-mesh#134 OOM ceiling + delta-exchange.**
  Two `AutomergeBackend::with_iroh` instances in one process, connected
  via direct iroh peer-ID exchange (`PeerDiscovery::add_peer`).
  `deltaExchangeRoundTrip` writes a doc on A, force-syncs, polls for
  it on B. `scanDoesNotMaterializeBodies` publishes N=30 docs each
  ~1 KB on A, captures `Debug.getNativeHeapAllocatedSize()` baseline,
  runs 30 `force_sync` cycles (which iterate
  `sync_all_documents_with_peer` per connected peer), and asserts
  delta < 200 MB. Threshold is intentionally generous — pre-fix the
  bug peaked in the multi-GB range; below 200 MB rules that out while
  allowing legitimate iroh + Automerge sync-state scratch. Tighten in
  follow-up PRs once CI baselines accumulate.
  Infrastructure (Rust JNI handle layer + lazy tokio runtime + Kotlin
  glue) is shared with M4; M3 establishes it.
- **M4a (landed in peat#879 / peat#880): peat-ffi exposes the JNI
  methods M4b needs.** Originally scoped as "5 new methods"; reduced
  to 2 (`endpointSocketAddrJni`, `getDocumentJni`) after investigation
  showed `requestSyncJni`, `connectPeerJni`, and `createNodeJni`
  already cover three of the gaps atomically. Shipped to Maven
  Central as `com.defenseunicorns:peat-ffi:0.1.1`.
- **M4b: retire `android-tests/jni/`, consume peat-ffi as an AAR.**
  This PR. `android-tests/build.gradle.kts` drops the cargo cross-
  compile task and adds `implementation("com.defenseunicorns:peat-ffi
  :0.1.1")`. The Kotlin instrumented tests switch from the test-only
  `TestJni.*` surface to peat-ffi's `PeatJni.*`. Adds the
  `getDocumentJni` Err-branch test (peat#879 QA carryover). Removes
  the divergence risk M1–M3 accepted.
- **M4 follow-ups (not in M4b):**
  - JVM-in-process Rust integration tests for peat-ffi (tracked at
    peat#881).
  - Tighten the OOM ceiling threshold once CI baselines stabilize.
  - **`getDocumentThrowsOnStoreError` is non-deterministic.** The
    test's accept-set is `{RuntimeException, null}` — a `null`
    return passes silently because POSIX-deleting the storage dir
    doesn't always evict the just-published doc from Automerge's
    LRU. The test cannot fail-closed on the throw contract; it
    can pass green forever without ever exercising the Err
    branch. Deterministic verification needs an upstream fault-
    injection hook (e.g. `forceStoreErrorForTestingJni`) in
    peat-ffi. Tracked as a peat-ffi follow-up; until then the
    test is best-effort.
  - **`PeatJni.kt` is a hand-rolled binding** with no automated
    drift detection vs peat-ffi's `Java_..._PeatJni_*` extern
    fns. A peat-ffi rename or signature change won't surface until
    the on-device suite runs (UnsatisfiedLinkError at runtime).
    Two mitigations under consideration: pin the AAR sha256 in
    `gradle.properties` so silent re-publishes are detected;
    upstream a canonical `PeatJni.kt` into the peat-ffi AAR so
    consumers drop their hand-rolled copies.

## Risks

- **Emulator flake.** Mitigation: cache AVD; pin API 30 + image; use
  `reactivecircus/android-emulator-runner@v2`'s built-in retry.
- **Native-heap threshold drift.** Tune empirically once M3 lands;
  err high initially, tighten as data accrues. The test reports the
  measured delta in the failure message so threshold updates are
  data-driven.
- **JNI-shim divergence from peat-ffi.** Accepted for M1–M3; closed
  by M4.
