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
7 changes: 5 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,14 @@ Cargo.lock
# docker-compose; paste contents into Dokploy's Files tab, never commit.
wavesync_relay/secrets/

# Keep CLAUDE.md and doc files tracked — only ignore generated markdown
# Ignore all markdown by default; force-add docs you want tracked with `git add -f`.
**/*.md
**/*firebase*.json
**/*google-services*.json
**/.gradle/
**/build/
**/.build/
**/.kotlin/
**/.kotlin/
# Maestro / FCM test harness runtime artifacts
examples/dioxus_fcm_sync/.test-logs/
examples/dioxus_fcm_sync/.test-pids/
156 changes: 156 additions & 0 deletions examples/dioxus_fcm_sync/TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# FCM cold-wake test — `examples/dioxus_fcm_sync`

End-to-end test that proves the headline contract of the push-sync
feature: **when the Android app is killed (swiped from recents),
a write made by another peer reaches the killed phone via FCM
wake-up**, the engine wakes briefly in the background, syncs, and
shuts back down.

If this test passes locally, you have evidence that:

1. The relay correctly maps `NotifyTopic` → FCM token list →
`messaging.send()`.
2. The Android app's `FirebaseMessagingService` survives the kill
and is invoked when FCM arrives.
3. The Rust engine's background-sync entry point reconnects to
the relay, runs version-vector catch-up, and writes the row
to local SQLite — all while the UI is dead.

## Files

| File | Purpose |
|---|---|
| `test.sh` | Orchestrator: relay + writer peer + APK build/install + maestro phases |
| `test.maestro.phase-a.yaml` | Setup: launchApp clear, add sentinel, killApp |
| `test.maestro.phase-b.yaml` | Assertion: relaunch (no clear), assert remote row visible at first paint |

## Prerequisites

### Hardware / OS

- A Linux or macOS workstation with `adb` on PATH.
- A connected Android device **OR** an emulator running a Google
Play Services system image. **`default` images don't work** —
Google Play Services isn't installed on them, so FCM never
delivers and `test.sh` will stall at the wait step.

Recommended AVD setup:

```bash
sdkmanager 'system-images;android-34;google_apis_playstore;x86_64'
avdmanager create avd \
-n fcm-test \
-k 'system-images;android-34;google_apis_playstore;x86_64' \
-d pixel_7
emulator -avd fcm-test -no-snapshot-load &
```

Quick check that GMS is on the device:

```bash
adb shell pm list packages | grep com.google.android.gms
# → package:com.google.android.gms
```

### Tools

- `dx` (Dioxus CLI): `cargo install dioxus-cli`
- Maestro: <https://maestro.mobile.dev> (tested against v1.39+)
- Rust toolchain (already required by this repo)

### Firebase project

Two halves must come from the **same Firebase project**:

- `examples/dioxus_fcm_sync/google-services.json` — the client
config bundled into the APK. Already present in the repo for
the `wavesync` Firebase project; replace it with your own if
testing under a different project.
- An Admin SDK service-account JSON for the relay, exported via
`FCM_CREDENTIALS`. Generate via Firebase Console →
Project Settings → Service Accounts → "Generate new private
key". Save outside the repo and never commit it.

If the two halves don't match, the relay's FCM `send()` succeeds
but the message goes to a different project's tokens (or fails
silently), and the test stalls.

## Running

```bash
export FCM_CREDENTIALS=/abs/path/to/firebase-adminsdk.json
./test.sh
```

Optional knobs:

| Var | Default | What |
|---|---|---|
| `ANDROID_SERIAL` | first online adb device | which device to drive |
| `WAKE_WAIT` | `90` (seconds) | how long to wait for FCM delivery + background sync |
| `WRITER_HTTP_PORT` | `8489` | local port for the writer-peer HTTP API |
| `RELAY_QUIC_PORT` | `4001` | UDP port the local relay listens on |
| `SKIP_INSTALL` | (unset) | reuse already-installed APK; speeds iteration |

## Pass / fail interpretation

**Pass** — Phase B's `extendedWaitUntil` for the writer's row
succeeds within 5 s of relaunch. The row was written to SQLite
*before* the UI started, which means FCM woke the engine in the
background and let it complete the sync.

**Fail at Phase B's assertion** — three sub-cases:

1. **No row at all after 5 s** — FCM didn't wake the app. Check:
- Is the AVD/device on a Play Store image?
`adb shell pm list packages | grep gms`
- Are `FCM_CREDENTIALS` and `google-services.json` from the
same Firebase project?
- Did the relay's startup log show `FCM credentials loaded`
(look in `.test-logs/relay.log`)?
- Was the FCM token registered with the relay during phase A?
Look for `RegisterToken` in the relay log.
2. **Row appears within ~10 s of relaunch but not at first paint**
— FCM wake didn't happen, but foreground-resume sync caught
up. Less catastrophic than (1) but still a failure of the
cold-wake contract.
3. **Sentinel `from-phone-A` is missing in Phase B** — local DB
was wiped, probably by an OS-level "uninstall on app crash"
or by a stale `clearState` somewhere. Re-run.

**Fail at the sentinel-check step (after Phase A)** — the
app→relay path itself is broken, before FCM is even relevant.
Most common cause: the relay's bridge address isn't reachable
from the emulator (check `LAN_IP` detection in `test.sh` and
that the emulator can ping it).

## Known limitations

- Single-test-at-a-time: the topic is hardcoded, so two parallel
runs would cross-talk via the relay.
- The "writer" peer is the test-peer binary from `tests-e2e/`,
which has `iproute2`/`iptables` baggage it doesn't need here
but builds the same way. No functional impact.
- Doze mode delays FCM delivery by minutes when the device has
been idle a long time. Real CI machines aren't a concern; if
you're testing on a personal phone left overnight, run a
short workload first to keep it out of Doze.
- iOS is not yet covered. APNs has a similar wake mechanism but
Maestro's `killApp` interacts differently with iOS task death,
and APNs sandbox tokens have shorter TTLs. Filed as a future
follow-up.

## Why we use `killApp` and not `am force-stop`

`killApp` (Maestro) maps to `adb shell am stop-app` (Android 14+)
or a process kill — the app exits but stays FCM-deliverable, the
shape of "user swiped from recents".

`am force-stop` puts the package in **stopped state**, an
Android security flag that disables FCM delivery until the user
manually relaunches. It's the right tool for testing the
"reinstall / first launch" path but the wrong tool for testing
"app was killed by the OS or user kill action" — exactly the
case our push-sync feature is designed to handle. Don't switch
to `force-stop` even if `killApp` flakes; the test would silently
become useless.
5 changes: 5 additions & 0 deletions examples/dioxus_fcm_sync/build.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
fn main() {
println!("cargo::rustc-check-cfg=cfg(has_google_services)");
println!("cargo:rerun-if-changed=google-services.json");
// Pick up `WAVESYNC_RELAY_OVERRIDE` in `option_env!()` at compile
// time. The Maestro test harness (`test.sh`) sets this so the
// installed APK dials a local relay with controlled FCM creds
// instead of the bundled production relay address.
println!("cargo:rerun-if-env-changed=WAVESYNC_RELAY_OVERRIDE");

let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "android" {
Expand Down
14 changes: 11 additions & 3 deletions examples/dioxus_fcm_sync/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,17 @@ const STYLE: &str = r#"
/// Relay server address. Set this to your relay's multiaddr for WAN sync.
/// Example: "/ip4/your-server-ip/tcp/4001/p2p/12D3KooW..."
/// Leave as None for LAN-only mDNS sync (desktop testing).
const RELAY_SERVER: Option<&str> = Some(
"/dns4/relay.wavesyncdb.com/udp/4001/quic-v1/p2p/12D3KooWH2ZzVdXehxyNa1QDeWrBLAWKynMVPn8BK2LaDCTiPs4D",
);
///
/// `WAVESYNC_RELAY_OVERRIDE` lets bench/test scripts (`test.sh`) point the
/// app at a locally-running relay with controlled FCM credentials,
/// without modifying this file. `cargo:rerun-if-env-changed` is set in
/// `build.rs` so changing the env triggers a rebuild.
const RELAY_SERVER: Option<&str> = match option_env!("WAVESYNC_RELAY_OVERRIDE") {
Some(s) => Some(s),
None => Some(
"/dns4/relay.wavesyncdb.com/udp/4001/quic-v1/p2p/12D3KooWH2ZzVdXehxyNa1QDeWrBLAWKynMVPn8BK2LaDCTiPs4D",
),
};

static DB: OnceLock<WaveSyncDb> = OnceLock::new();

Expand Down
46 changes: 46 additions & 0 deletions examples/dioxus_fcm_sync/test.maestro.phase-a.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Phase A — setup + kill. See test.sh for the full sequence.
#
# Sets up a known starting state on the phone:
# - Fresh install (clearState wipes the SQLite DB).
# - App boots, registers FCM token with the relay, registers
# entities, syncs schema. The "Tasks" header is the visible
# marker that the engine is up.
# - Add a sentinel task ("from-phone-A") so the parallel writer
# peer in test.sh can verify the app→relay path is alive
# before we kill the app.
# - killApp is the realistic shape of "user swiped from recents":
# the process exits but Android keeps it FCM-deliverable
# (unlike `am force-stop` which puts it in stopped-state).
#
# Selectors:
# - "What needs to be done?" is the placeholder text on the
# AddTaskForm input (see src/main.rs).
# - "Add" is the submit button on the same form.

appId: com.wavesync.mobile_demo
---
- launchApp:
clearState: true

# Wait for the app's WebView to render. h1 in src/main.rs is
# "WaveSyncDB Mobile Demo".
- extendedWaitUntil:
visible: "WaveSyncDB Mobile Demo"
timeout: 60000

# Sentinel task: proves the app itself can write while running and
# the relay path delivers it to the writer-peer. The literal title
# is provided via WAVESYNC_FCM_PHONE_SENTINEL — test.sh sets a
# UUID-suffixed value per run so prior-run residue can't make the
# assertion trivially pass.
- tapOn:
text: "What needs to be done?"
- inputText: ${WAVESYNC_FCM_PHONE_SENTINEL ?? 'from-phone-A'}
- tapOn: "Add"
- extendedWaitUntil:
visible: ${WAVESYNC_FCM_PHONE_SENTINEL ?? 'from-phone-A'}
timeout: 15000

# killApp = "swipe from recents". App is fully killed but
# remains FCM-deliverable; this is the cold-wake test scope.
- killApp
44 changes: 44 additions & 0 deletions examples/dioxus_fcm_sync/test.maestro.phase-b.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Phase B — assert FCM cold-wake delivered the sync. See test.sh
# for the full sequence.
#
# Between phase A and phase B, test.sh:
# 1. Drives a CLI peer (test-peer binary, connected to the same
# relay+topic+passphrase) to add a task with a known title
# while the phone is killed.
# 2. Sleeps up to 90 s so the OS can deliver FCM, wake the
# FirebaseMessagingService, run the engine in background,
# reconnect to the relay, sync, and shut back down.
#
# This phase relaunches the app **without** clearState — we want to
# inspect what the FCM-woken engine wrote to the local SQLite while
# the UI was dead. If the row is there at first paint, FCM cold-wake
# is working. If it isn't there but appears within a few seconds
# after relaunch, FCM didn't wake the engine but the foreground
# resume did — note that as a separate failure mode.
#
# The writer's task title is provided via WAVESYNC_FCM_REMOTE_TASK
# (defaults to a fixed string for ad-hoc runs). It must match the
# string test.sh writes via the writer peer.

appId: com.wavesync.mobile_demo
---
- launchApp:
clearState: false

# Wait for the engine to come back up after relaunch.
- extendedWaitUntil:
visible: "WaveSyncDB Mobile Demo"
timeout: 30000

# The critical assertion: the writer's row must already be in the
# local DB at first paint. We give the visibility check a short
# (5 s) window to absorb UI repaints, but anything longer than that
# means the engine is fetching the row only NOW (foreground resume)
# rather than having received it during FCM background-wake.
- extendedWaitUntil:
visible: ${WAVESYNC_FCM_REMOTE_TASK ?? 'from-cli-while-killed'}
timeout: 5000

# Sentinel from phase A is still here — proves the DB persisted
# across kill+relaunch (no clearState).
- assertVisible: ${WAVESYNC_FCM_PHONE_SENTINEL ?? 'from-phone-A'}
Loading
Loading