diff --git a/.gitignore b/.gitignore
index b5d1d92..f2a3ae0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,6 +42,12 @@ app-example
/ios
/android
+# vendored Expo local module build outputs (keep the module source, ignore
+# Gradle/CocoaPods build artifacts produced when autolinked during prebuild)
+modules/**/android/build/
+modules/**/ios/build/
+modules/**/ios/Pods/
+
/docs/superpowers
# fastlane
diff --git a/.oxfmtignore b/.oxfmtignore
index 40542c2..6d43754 100644
--- a/.oxfmtignore
+++ b/.oxfmtignore
@@ -1,4 +1,5 @@
node_modules/
+modules/
ios/Pods/
ios/build/
android/build/
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 90e49db..537f225 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -25,5 +25,5 @@
"rules": {}
}
],
- "ignorePatterns": ["dist/", "node_modules/", ".expo/", "android/", "ios/"]
+ "ignorePatterns": ["dist/", "node_modules/", ".expo/", "android/", "ios/", "modules/"]
}
diff --git a/README.md b/README.md
index 5c022e7..e0c6e1b 100644
--- a/README.md
+++ b/README.md
@@ -131,3 +131,63 @@ To further integrate with identity primitives, the following extensions are sugg
> ```bash
> pnpm android
> ```
+
+## Liquid Auth native module (vendored)
+
+The wallet's connection layer drives Liquid Auth signaling/WebRTC through a
+native **background service** exposed by the `react-native-liquid-auth` package
+(see `hooks/useConnection.ts` and `lib/ac2/nativeTransport.ts`).
+
+That package is **unpublished**, so instead of a workspace/`file:`/`link:`
+dependency it is **vendored as an [Expo local module](https://docs.expo.dev/modules/get-started/)**
+at `modules/react-native-liquid-auth/`. This keeps the wallet a self-contained,
+buildable app — no external workspace or sibling checkout required.
+
+- **Runtime resolution:** a Metro resolver alias (`resolver.extraNodeModules` in
+ `metro.config.js`) maps the bare specifier `react-native-liquid-auth` to
+ `modules/react-native-liquid-auth/src`, so every `require('react-native-liquid-auth')`
+ resolves unchanged and Metro bundles the module's TypeScript directly.
+- **Native autolinking:** Expo autolinking discovers the module's `android/` and
+ `ios/` from `modules/` during prebuild — no `package.json` dependency entry.
+- **Isolation:** the vendored tree is excluded from the wallet's `tsc`, `oxlint`,
+ `oxfmt`, and scoped in Jest, so it is never scanned as app source.
+- **Provenance:** `modules/react-native-liquid-auth/VENDORED.md` records the
+ upstream repo/commit and the one-way (upstream → copy) sync direction.
+
+### Background-service notifications
+
+The native background service shows an ongoing "connected" notification plus a
+per-message notification while the app is backgrounded (rendered natively so it
+works even when the JS runtime is suspended/killed).
+
+- **Runtime permission:** on Android 13+ (`POST_NOTIFICATIONS`) the app must
+ request the permission at runtime. The wallet depends on `expo-notifications`
+ and requests it via `lib/notifications.ts` (`ensureNotificationPermission`)
+ just before starting the service — non-fatal if denied. Adding this native
+ dependency requires an `expo prebuild` + rebuild to link it.
+- **Custom content:** the per-message copy is defined in
+ `DEFAULT_AC2_NOTIFICATIONS` (`lib/ac2/nativeTransport.ts`) and passed to the
+ native service via `connect(options.notifications)`. Heartbeat/stream control
+ channels are suppressed; `ac2/SigningRequest` / `ac2/KeyRequest` get tailored
+ copy; anything else falls back to a generic banner.
+
+### Remaining on-device (Mac) steps
+
+Native linking and a device/simulator run require a macOS host and are **not**
+exercised in CI/Linux:
+
+1. `expo prebuild` — generate the native `android/` / `ios/` projects.
+2. `pod install` (iOS) — **watch for a pod overlap:** the module's podspec pulls
+ `WebRTC-lib`, which can clash with the wallet's `react-native-webrtc`. This is
+ resolved when the in-process WebRTC path is retired.
+3. Gradle autolink (Android) — picks up the module from `modules/` automatically.
+4. Run on device/simulator and confirm `startNativeService` /
+ `createNativeAc2Transport` reach the native `SignalService`.
+
+### Upgrade path
+
+When `react-native-liquid-auth` is published (e.g.
+`@algorandfoundation/react-native-liquid-auth`), migrating is mechanical: delete
+`modules/react-native-liquid-auth/`, drop the Metro alias and the
+`tsconfig`/`oxlint`/`oxfmt`/Jest isolation entries, and add a normal scoped
+dependency.
diff --git a/__tests__/components/chat/chat-reconnect.test.tsx b/__tests__/components/chat/chat-reconnect.test.tsx
index 2d8be52..f410338 100644
--- a/__tests__/components/chat/chat-reconnect.test.tsx
+++ b/__tests__/components/chat/chat-reconnect.test.tsx
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react-native';
import * as React from 'react';
-import { KeyboardAvoidingView } from 'react-native';
+import { Alert, KeyboardAvoidingView } from 'react-native';
jest.mock('react-native-mmkv', () => ({
createMMKV: () => ({ getString: () => undefined, getBoolean: () => false, set: () => {} }),
@@ -60,8 +60,9 @@ function baseConnection() {
isError: false,
isLoading: false,
isReconnecting: false,
+ peerOffline: false,
+ isSocketConnected: true,
reconnectAttempt: 0,
- maxReconnectAttempts: 3,
send: jest.fn(),
sendAc2: jest.fn(),
lastHeartbeat: Date.now(),
@@ -76,6 +77,9 @@ function baseConnection() {
openConversation: jest.fn(),
closeConversation: jest.fn(),
remoteThreads: [],
+ connectionNotice: null,
+ dismissConnectionNotice: jest.fn(),
+ isRegistered: true,
};
}
@@ -99,11 +103,11 @@ describe('ChatScreen reconnect footer', () => {
mockConnectionState = { ...baseConnection(), isReconnecting: true, reconnectAttempt: 2 };
renderChat();
- expect(screen.getByPlaceholderText('Reconnecting (2/3)…')).toBeTruthy();
+ expect(screen.getByPlaceholderText('Reconnecting (attempt 2)…')).toBeTruthy();
expect(screen.queryByLabelText('Reconnect')).toBeNull();
});
- it('falls back to the manual Reconnect bar once retries are exhausted', () => {
+ it('falls back to the manual Reconnect bar when not retrying', () => {
mockConnectionState = { ...baseConnection(), isReconnecting: false, isLoading: false };
renderChat();
@@ -111,6 +115,32 @@ describe('ChatScreen reconnect footer', () => {
expect(screen.queryByPlaceholderText(/Reconnecting/)).toBeNull();
});
+ it('shows a clean peer-offline notice (no pop-up) in the reconnect bar', () => {
+ mockConnectionState = {
+ ...baseConnection(),
+ isReconnecting: false,
+ isLoading: false,
+ peerOffline: true,
+ };
+ renderChat();
+
+ expect(screen.getByLabelText('Reconnect')).toBeTruthy();
+ expect(screen.getByText(/Check your remote device/)).toBeTruthy();
+ });
+
+ it('shows a "service unavailable" notice when the socket is disconnected', () => {
+ mockConnectionState = {
+ ...baseConnection(),
+ isReconnecting: false,
+ isLoading: false,
+ isSocketConnected: false,
+ };
+ renderChat();
+
+ expect(screen.getByLabelText('Reconnect')).toBeTruthy();
+ expect(screen.getByText(/Service unavailable/)).toBeTruthy();
+ });
+
it('shows the live composer when connected', () => {
mockConnectionState = { ...baseConnection(), isConnected: true };
const view = renderChat();
@@ -119,4 +149,34 @@ describe('ChatScreen reconnect footer', () => {
expect(screen.queryByLabelText('Reconnect')).toBeNull();
expect(view.UNSAFE_getByType(KeyboardAvoidingView).props.keyboardVerticalOffset).toBe(162);
});
+
+ it('disables the composer when connected but the wallet is not registered', () => {
+ // A foreign wallet locked out, or no identity granted yet: block new
+ // messages until registration completes.
+ mockConnectionState = { ...baseConnection(), isConnected: true, isRegistered: false };
+ renderChat();
+
+ const input = screen.getByPlaceholderText("Not registered — can't send messages");
+ expect(input).toBeTruthy();
+ expect(input.props.editable).toBe(false);
+ expect(screen.queryByPlaceholderText('Message')).toBeNull();
+ });
+
+ it('prompts to delete the connection when connected but not registered', () => {
+ // The connection is dead (re-pairing generates a new requestId), so the app
+ // offers to delete this thread.
+ const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
+ mockConnectionState = { ...baseConnection(), isConnected: true, isRegistered: false };
+ renderChat();
+
+ expect(alertSpy).toHaveBeenCalledWith(
+ 'Connection not registered',
+ expect.stringContaining('delete'),
+ expect.arrayContaining([
+ expect.objectContaining({ text: 'Keep' }),
+ expect.objectContaining({ text: 'Delete', style: 'destructive' }),
+ ]),
+ );
+ alertSpy.mockRestore();
+ });
});
diff --git a/__tests__/lib/ac2/connectionMachine.test.ts b/__tests__/lib/ac2/connectionMachine.test.ts
new file mode 100644
index 0000000..627f2dd
--- /dev/null
+++ b/__tests__/lib/ac2/connectionMachine.test.ts
@@ -0,0 +1,637 @@
+/**
+ * Unit tests for the pure connection state machine.
+ *
+ * The machine is deterministic — every scenario is expressed as a sequence of
+ * events folded through `transition`, asserting on the resulting state and
+ * the effects emitted along the way. No timers, no mocks, no React.
+ */
+
+import {
+ BACKOFF_BASE_MS,
+ BACKOFF_MAX_MS,
+ NEGOTIATING_DEADLINE_MS,
+ STARTING_DEADLINE_MS,
+ backoffDelayMs,
+ createInitialState,
+ deriveUiState,
+ describeState,
+ transition,
+ type ConnectionEffect,
+ type ConnectionEvent,
+ type ConnectionPhase,
+ type ConnectionState,
+} from '@/lib/ac2/connectionMachine';
+
+/** Folds a sequence of events through the machine, collecting all effects. */
+function run(
+ events: ConnectionEvent[],
+ from: ConnectionState = createInitialState(),
+): { state: ConnectionState; effects: ConnectionEffect[] } {
+ let state = from;
+ const effects: ConnectionEffect[] = [];
+ for (const event of events) {
+ const result = transition(state, event);
+ state = result.state;
+ effects.push(...result.effects);
+ }
+ return { state, effects };
+}
+
+/** Asserts the phase and narrows the state type for property access. */
+function expectPhase
(
+ state: ConnectionState,
+ phase: P,
+): Extract {
+ expect(state.phase).toBe(phase);
+ return state as Extract;
+}
+
+function effectsOfType(
+ effects: ConnectionEffect[],
+ type: T,
+): Extract[] {
+ return effects.filter((e) => e.type === type) as Extract[];
+}
+
+/** START → SERVICE_READY → SOCKET_UP: service up, socket up, peer unknown. */
+function bootToNegotiating(): Extract {
+ const { state } = run([{ type: 'START' }, { type: 'SERVICE_READY' }, { type: 'SOCKET_UP' }]);
+ return expectPhase(state, 'negotiating');
+}
+
+function bootToConnected(): Extract {
+ const negotiating = bootToNegotiating();
+ const { state } = run([{ type: 'ATTEMPT_OK', attemptId: negotiating.attemptId }], negotiating);
+ return expectPhase(state, 'connected');
+}
+
+describe('connectionMachine', () => {
+ describe('happy path', () => {
+ it('walks START → SERVICE_READY → SOCKET_UP → PEER_PRESENT → ATTEMPT_OK → connected', () => {
+ let result = transition(createInitialState(), { type: 'START' });
+ const starting = expectPhase(result.state, 'starting');
+ expect(effectsOfType(result.effects, 'startService')).toHaveLength(1);
+ expect(effectsOfType(result.effects, 'armDeadline')).toEqual([
+ { type: 'armDeadline', attemptId: starting.attemptId, ms: STARTING_DEADLINE_MS },
+ ]);
+
+ result = transition(result.state, { type: 'SERVICE_READY' });
+ expectPhase(result.state, 'waiting');
+ expect(result.state.serviceUp).toBe(true);
+
+ // Peer presence is unknown (null) → allowed to try as soon as the
+ // socket is up, matching the existing presence semantics.
+ result = transition(result.state, { type: 'SOCKET_UP' });
+ const negotiating = expectPhase(result.state, 'negotiating');
+ expect(negotiating.mode).toBe('connect');
+ expect(effectsOfType(result.effects, 'negotiate')).toEqual([
+ { type: 'negotiate', attemptId: negotiating.attemptId, mode: 'connect' },
+ ]);
+ expect(effectsOfType(result.effects, 'armDeadline')).toEqual([
+ { type: 'armDeadline', attemptId: negotiating.attemptId, ms: NEGOTIATING_DEADLINE_MS },
+ ]);
+
+ result = transition(result.state, { type: 'PEER_PRESENT' });
+ expectPhase(result.state, 'negotiating');
+ expect(result.state.peerPresent).toBe(true);
+ expect(result.effects).toEqual([]);
+
+ result = transition(result.state, { type: 'ATTEMPT_OK', attemptId: negotiating.attemptId });
+ expectPhase(result.state, 'connected');
+ expect(result.state.hadSession).toBe(true);
+ expect(deriveUiState(result.state)).toMatchObject({
+ isConnected: true,
+ isLoading: false,
+ isReconnecting: false,
+ });
+ });
+
+ it('gates on a known-absent peer and negotiates once PEER_PRESENT arrives', () => {
+ const { state: waiting } = run([
+ { type: 'START' },
+ { type: 'SERVICE_READY' },
+ { type: 'PEER_ABSENT' },
+ { type: 'SOCKET_UP' },
+ ]);
+ expectPhase(waiting, 'waiting');
+
+ const result = transition(waiting, { type: 'PEER_PRESENT' });
+ const negotiating = expectPhase(result.state, 'negotiating');
+ expect(negotiating.mode).toBe('connect');
+ expect(effectsOfType(result.effects, 'negotiate')).toHaveLength(1);
+ });
+ });
+
+ describe('suspend → resume (the stuck-"Connecting" bug)', () => {
+ it('reconciles a dead native side into a fresh connect attempt, ignoring the stale one', () => {
+ const negotiating = bootToNegotiating();
+ const staleAttemptId = negotiating.attemptId;
+
+ let result = transition(negotiating, { type: 'APP_BACKGROUND' });
+ expectPhase(result.state, 'negotiating');
+ expect(result.state.foreground).toBe(false);
+
+ result = transition(result.state, { type: 'APP_FOREGROUND' });
+ expectPhase(result.state, 'negotiating');
+ expect(effectsOfType(result.effects, 'queryNativeState')).toHaveLength(1);
+
+ result = transition(result.state, {
+ type: 'NATIVE_SNAPSHOT',
+ alive: false,
+ channelOpen: false,
+ });
+ const fresh = expectPhase(result.state, 'negotiating');
+ expect(fresh.mode).toBe('connect');
+ expect(fresh.attemptId).toBeGreaterThan(staleAttemptId);
+ expect(effectsOfType(result.effects, 'teardown')).toEqual([
+ { type: 'teardown', preserveNativePeer: false },
+ ]);
+ expect(effectsOfType(result.effects, 'negotiate')).toEqual([
+ { type: 'negotiate', attemptId: fresh.attemptId, mode: 'connect' },
+ ]);
+
+ // The abandoned attempt's late results must not corrupt the new one.
+ let stale = transition(result.state, {
+ type: 'ATTEMPT_FAILED',
+ attemptId: staleAttemptId,
+ reason: 'late failure',
+ });
+ expect(stale.state).toBe(result.state);
+ expect(stale.effects).toEqual([]);
+ stale = transition(result.state, { type: 'ATTEMPT_OK', attemptId: staleAttemptId });
+ expect(stale.state).toBe(result.state);
+ expect(stale.effects).toEqual([]);
+
+ const ok = transition(result.state, { type: 'ATTEMPT_OK', attemptId: fresh.attemptId });
+ expectPhase(ok.state, 'connected');
+ });
+
+ it('re-attaches when the native peer survived the suspension', () => {
+ const negotiating = bootToNegotiating();
+ const resumed = run([{ type: 'APP_BACKGROUND' }, { type: 'APP_FOREGROUND' }], negotiating);
+
+ const result = transition(resumed.state, {
+ type: 'NATIVE_SNAPSHOT',
+ alive: true,
+ channelOpen: true,
+ });
+ const attach = expectPhase(result.state, 'negotiating');
+ expect(attach.mode).toBe('attach');
+ expect(attach.attemptId).toBeGreaterThan(negotiating.attemptId);
+ // The stale in-flight attempt is torn down WITHOUT killing the live
+ // native peer we are about to attach to.
+ expect(effectsOfType(result.effects, 'teardown')).toEqual([
+ { type: 'teardown', preserveNativePeer: true },
+ ]);
+ });
+
+ it('treats a dead snapshot as CONNECTION_LOST when connected', () => {
+ const connected = bootToConnected();
+ const resumed = run([{ type: 'APP_BACKGROUND' }, { type: 'APP_FOREGROUND' }], connected);
+ expect(effectsOfType(resumed.effects, 'queryNativeState')).toHaveLength(1);
+
+ const dead = transition(resumed.state, {
+ type: 'NATIVE_SNAPSHOT',
+ alive: false,
+ channelOpen: false,
+ });
+ const backoff = expectPhase(dead.state, 'backoff');
+ expect(backoff.delayMs).toBe(BACKOFF_BASE_MS);
+ expect(effectsOfType(dead.effects, 'scheduleRetry')).toHaveLength(1);
+ });
+
+ it('keeps a healthy connection untouched when the snapshot is alive', () => {
+ const connected = bootToConnected();
+ const resumed = run([{ type: 'APP_BACKGROUND' }, { type: 'APP_FOREGROUND' }], connected);
+ const result = transition(resumed.state, {
+ type: 'NATIVE_SNAPSHOT',
+ alive: true,
+ channelOpen: true,
+ });
+ expectPhase(result.state, 'connected');
+ expect(result.effects).toEqual([]);
+ });
+ });
+
+ describe('stale attempt results', () => {
+ it('ignores ATTEMPT_OK / ATTEMPT_FAILED / DEADLINE with an old attemptId', () => {
+ const negotiating = bootToNegotiating();
+ const oldId = negotiating.attemptId - 1;
+
+ for (const event of [
+ { type: 'ATTEMPT_OK', attemptId: oldId },
+ { type: 'ATTEMPT_FAILED', attemptId: oldId, reason: 'stale' },
+ { type: 'DEADLINE', attemptId: oldId },
+ ] as ConnectionEvent[]) {
+ const result = transition(negotiating, event);
+ expect(result.state).toBe(negotiating);
+ expect(result.effects).toEqual([]);
+ }
+ });
+ });
+
+ describe('deadlines and exponential backoff', () => {
+ it('funnels a negotiation deadline into a 2s backoff, then 4s/8s/16s/30s/30s', () => {
+ let state: ConnectionState = bootToNegotiating();
+ const expectedDelays = [2_000, 4_000, 8_000, 16_000, 30_000, 30_000];
+
+ for (const [index, expectedDelay] of expectedDelays.entries()) {
+ const negotiating = expectPhase(state, 'negotiating');
+ const failure =
+ index === 0
+ ? transition(negotiating, { type: 'DEADLINE', attemptId: negotiating.attemptId })
+ : transition(negotiating, {
+ type: 'ATTEMPT_FAILED',
+ attemptId: negotiating.attemptId,
+ reason: 'boom',
+ });
+ const backoff = expectPhase(failure.state, 'backoff');
+ expect(backoff.attempt).toBe(index + 1);
+ expect(backoff.delayMs).toBe(expectedDelay);
+ expect(effectsOfType(failure.effects, 'scheduleRetry')).toEqual([
+ { type: 'scheduleRetry', delayMs: expectedDelay },
+ ]);
+ expect(effectsOfType(failure.effects, 'teardown')).toHaveLength(1);
+
+ const retry = transition(backoff, { type: 'RETRY_DUE' });
+ const next = expectPhase(retry.state, 'negotiating');
+ expect(next.attemptId).toBeGreaterThan(negotiating.attemptId);
+ expect(effectsOfType(retry.effects, 'negotiate')).toHaveLength(1);
+ state = retry.state;
+ }
+ });
+
+ it('exports the documented backoff schedule', () => {
+ expect(BACKOFF_BASE_MS).toBe(2_000);
+ expect(BACKOFF_MAX_MS).toBe(30_000);
+ expect([1, 2, 3, 4, 5, 6, 7].map(backoffDelayMs)).toEqual([
+ 2_000, 4_000, 8_000, 16_000, 30_000, 30_000, 30_000,
+ ]);
+ });
+
+ it('resets the delay schedule after a successful connection', () => {
+ const negotiating = bootToNegotiating();
+ const failed = transition(negotiating, {
+ type: 'ATTEMPT_FAILED',
+ attemptId: negotiating.attemptId,
+ reason: 'boom',
+ });
+ const retried = transition(failed.state, { type: 'RETRY_DUE' });
+ const second = expectPhase(retried.state, 'negotiating');
+ const ok = transition(second, { type: 'ATTEMPT_OK', attemptId: second.attemptId });
+ const lost = transition(ok.state, { type: 'CONNECTION_LOST', reason: 'ice failed' });
+ expect(expectPhase(lost.state, 'backoff').delayMs).toBe(BACKOFF_BASE_MS);
+ });
+ });
+
+ describe('background pause / foreground resume', () => {
+ it('pauses backoff in background and resumes via the snapshot reconcile', () => {
+ const negotiating = bootToNegotiating();
+ const failed = transition(negotiating, {
+ type: 'ATTEMPT_FAILED',
+ attemptId: negotiating.attemptId,
+ reason: 'boom',
+ });
+ expectPhase(failed.state, 'backoff');
+
+ const backgrounded = transition(failed.state, { type: 'APP_BACKGROUND' });
+ const paused = expectPhase(backgrounded.state, 'backoff');
+ expect(paused.pausedInBackground).toBe(true);
+ expect(effectsOfType(backgrounded.effects, 'cancelTimers')).toHaveLength(1);
+
+ // A stray timer firing while paused must not negotiate in background.
+ const stray = transition(paused, { type: 'RETRY_DUE' });
+ expect(stray.state).toBe(paused);
+ expect(stray.effects).toEqual([]);
+
+ const foregrounded = transition(paused, { type: 'APP_FOREGROUND' });
+ expectPhase(foregrounded.state, 'backoff');
+ expect(effectsOfType(foregrounded.effects, 'queryNativeState')).toHaveLength(1);
+
+ const result = transition(foregrounded.state, {
+ type: 'NATIVE_SNAPSHOT',
+ alive: false,
+ channelOpen: false,
+ });
+ const fresh = expectPhase(result.state, 'negotiating');
+ expect(fresh.mode).toBe('connect');
+
+ // The snapshot restart resets the delay schedule back to the base.
+ const fail = transition(fresh, {
+ type: 'ATTEMPT_FAILED',
+ attemptId: fresh.attemptId,
+ reason: 'boom again',
+ });
+ expect(expectPhase(fail.state, 'backoff').delayMs).toBe(BACKOFF_BASE_MS);
+ });
+
+ it('enters backoff already paused when the connection is lost in background', () => {
+ const connected = bootToConnected();
+ const backgrounded = transition(connected, { type: 'APP_BACKGROUND' });
+ const lost = transition(backgrounded.state, { type: 'CONNECTION_LOST', reason: 'heartbeat' });
+ const backoff = expectPhase(lost.state, 'backoff');
+ expect(backoff.pausedInBackground).toBe(true);
+ expect(effectsOfType(lost.effects, 'scheduleRetry')).toEqual([]);
+ });
+ });
+
+ describe('terminal failures', () => {
+ it('treats session-full as terminal: no auto-retry, USER_RECONNECT recovers', () => {
+ const negotiating = bootToNegotiating();
+ const result = transition(negotiating, {
+ type: 'ATTEMPT_FAILED',
+ attemptId: negotiating.attemptId,
+ reason: 'session already has two peers',
+ terminal: 'session-full',
+ });
+ const failed = expectPhase(result.state, 'failed');
+ expect(failed.kind).toBe('session-full');
+ expect(effectsOfType(result.effects, 'scheduleRetry')).toEqual([]);
+ expect(effectsOfType(result.effects, 'teardown')).toHaveLength(1);
+
+ const retry = transition(failed, { type: 'RETRY_DUE' });
+ expect(retry.state).toBe(failed);
+ expect(retry.effects).toEqual([]);
+
+ const peer = transition(failed, { type: 'PEER_PRESENT' });
+ expectPhase(peer.state, 'failed');
+ expect(peer.effects).toEqual([]);
+
+ const recovered = transition(failed, { type: 'USER_RECONNECT' });
+ const fresh = expectPhase(recovered.state, 'negotiating');
+ expect(fresh.mode).toBe('connect');
+ expect(effectsOfType(recovered.effects, 'negotiate')).toHaveLength(1);
+ });
+
+ it('maps SERVICE_FAILED to a terminal failed state with its kind', () => {
+ const { state } = run([{ type: 'START' }]);
+ const result = transition(state, {
+ type: 'SERVICE_FAILED',
+ reason: 'passkey assertion rejected',
+ kind: 'auth',
+ });
+ const failed = expectPhase(result.state, 'failed');
+ expect(failed.kind).toBe('auth');
+ expect(effectsOfType(result.effects, 'teardown')).toHaveLength(1);
+ });
+ });
+
+ describe('STOP', () => {
+ it('stops from any phase with a teardown effect and then ignores everything but START', () => {
+ const negotiating = bootToNegotiating();
+ const connected = bootToConnected();
+ const backoff = transition(negotiating, {
+ type: 'DEADLINE',
+ attemptId: negotiating.attemptId,
+ }).state;
+ const failed = transition(negotiating, {
+ type: 'ATTEMPT_FAILED',
+ attemptId: negotiating.attemptId,
+ reason: 'full',
+ terminal: 'session-full',
+ }).state;
+ const starting = run([{ type: 'START' }]).state;
+ const waiting = run([{ type: 'START' }, { type: 'SERVICE_READY' }]).state;
+
+ for (const state of [starting, waiting, negotiating, connected, backoff, failed]) {
+ const stopped = transition(state, { type: 'STOP' });
+ expectPhase(stopped.state, 'stopped');
+ expect(effectsOfType(stopped.effects, 'teardown')).toEqual([
+ { type: 'teardown', preserveNativePeer: false },
+ ]);
+ expect(effectsOfType(stopped.effects, 'cancelTimers')).toHaveLength(1);
+
+ for (const event of [
+ { type: 'SOCKET_UP' },
+ { type: 'PEER_PRESENT' },
+ { type: 'RETRY_DUE' },
+ { type: 'USER_RECONNECT' },
+ { type: 'NATIVE_SNAPSHOT', alive: true, channelOpen: true },
+ ] as ConnectionEvent[]) {
+ const ignored = transition(stopped.state, event);
+ expect(ignored.state).toBe(stopped.state);
+ expect(ignored.effects).toEqual([]);
+ }
+
+ const restarted = transition(stopped.state, { type: 'START' });
+ expectPhase(restarted.state, 'starting');
+ expect(effectsOfType(restarted.effects, 'startService')).toHaveLength(1);
+ }
+ });
+ });
+
+ describe('connection loss', () => {
+ it('CONNECTION_LOST from connected → backoff, reported as reconnecting', () => {
+ const connected = bootToConnected();
+ const result = transition(connected, { type: 'CONNECTION_LOST', reason: 'channel closed' });
+ const backoff = expectPhase(result.state, 'backoff');
+ expect(backoff.reason).toBe('channel closed');
+ expect(backoff.hadSession).toBe(true);
+ expect(effectsOfType(result.effects, 'teardown')).toHaveLength(1);
+ expect(deriveUiState(backoff)).toMatchObject({
+ isConnected: false,
+ isLoading: false,
+ isReconnecting: true,
+ canManualReconnect: true,
+ });
+ });
+
+ it('PEER_ABSENT while connected behaves like CONNECTION_LOST and gates the retry', () => {
+ const connected = bootToConnected();
+ const result = transition(connected, { type: 'PEER_ABSENT' });
+ expectPhase(result.state, 'backoff');
+
+ // Peer is known absent → the retry parks in `waiting` instead of
+ // negotiating blindly; PEER_PRESENT then un-gates it.
+ const retried = transition(result.state, { type: 'RETRY_DUE' });
+ expectPhase(retried.state, 'waiting');
+ expect(effectsOfType(retried.effects, 'negotiate')).toEqual([]);
+
+ const present = transition(retried.state, { type: 'PEER_PRESENT' });
+ expectPhase(present.state, 'negotiating');
+ });
+ });
+
+ describe('socket loss', () => {
+ it('abandons negotiation on SOCKET_DOWN; RETRY_DUE waits for the socket', () => {
+ const negotiating = bootToNegotiating();
+ const result = transition(negotiating, { type: 'SOCKET_DOWN' });
+ const backoff = expectPhase(result.state, 'backoff');
+ expect(backoff.socketReady).toBe(false);
+ expect(effectsOfType(result.effects, 'teardown')).toHaveLength(1);
+
+ const retried = transition(backoff, { type: 'RETRY_DUE' });
+ expectPhase(retried.state, 'waiting');
+ expect(effectsOfType(retried.effects, 'negotiate')).toEqual([]);
+
+ const socketUp = transition(retried.state, { type: 'SOCKET_UP' });
+ const fresh = expectPhase(socketUp.state, 'negotiating');
+ expect(fresh.mode).toBe('connect');
+ expect(effectsOfType(socketUp.effects, 'negotiate')).toHaveLength(1);
+ });
+
+ it('stays connected on SOCKET_DOWN (p2p can outlive signaling)', () => {
+ const connected = bootToConnected();
+ const result = transition(connected, { type: 'SOCKET_DOWN' });
+ expectPhase(result.state, 'connected');
+ expect(result.state.socketReady).toBe(false);
+ expect(result.effects).toEqual([]);
+ });
+ });
+
+ describe('deriveUiState', () => {
+ it('maps every phase to the expected UI flags', () => {
+ const stopped = createInitialState();
+ expect(deriveUiState(stopped)).toEqual({
+ isConnected: false,
+ isLoading: false,
+ isReconnecting: false,
+ reconnectAttempt: 0,
+ canManualReconnect: false,
+ failureReason: null,
+ });
+
+ const starting = run([{ type: 'START' }]).state;
+ expect(deriveUiState(starting)).toMatchObject({ isLoading: true, isReconnecting: false });
+
+ const waiting = run([{ type: 'START' }, { type: 'SERVICE_READY' }]).state;
+ expect(deriveUiState(waiting)).toMatchObject({ isLoading: true, isReconnecting: false });
+
+ const negotiating = bootToNegotiating();
+ expect(deriveUiState(negotiating)).toMatchObject({
+ isLoading: true,
+ isReconnecting: false,
+ });
+
+ const connected = bootToConnected();
+ expect(deriveUiState(connected)).toMatchObject({
+ isConnected: true,
+ isLoading: false,
+ isReconnecting: false,
+ });
+
+ const lost = transition(connected, { type: 'CONNECTION_LOST', reason: 'gone' });
+ const backoff = expectPhase(lost.state, 'backoff');
+ expect(deriveUiState(backoff)).toMatchObject({
+ isLoading: false,
+ isReconnecting: true,
+ reconnectAttempt: 1,
+ canManualReconnect: true,
+ });
+
+ const reconnecting = transition(backoff, { type: 'RETRY_DUE' });
+ expect(deriveUiState(reconnecting.state)).toMatchObject({
+ isLoading: false,
+ isReconnecting: true,
+ });
+
+ const failed = transition(bootToNegotiating(), {
+ type: 'ATTEMPT_FAILED',
+ attemptId: bootToNegotiating().attemptId,
+ reason: 'session full',
+ terminal: 'session-full',
+ });
+ expect(deriveUiState(failed.state)).toMatchObject({
+ isLoading: false,
+ canManualReconnect: true,
+ failureReason: 'session full',
+ });
+ });
+ });
+
+ describe('describeState', () => {
+ it('produces a log-friendly line for each phase', () => {
+ expect(describeState(createInitialState())).toContain('stopped');
+ const negotiating = bootToNegotiating();
+ expect(describeState(negotiating)).toContain('negotiating');
+ expect(describeState(negotiating)).toContain('mode=connect');
+ });
+ });
+ describe('idle sessions', () => {
+ it('SESSION_IDLE while connected fails with kind=idle and tears down (manual recovery)', () => {
+ const connected = bootToConnected();
+ const result = transition(connected, { type: 'SESSION_IDLE' });
+ const failed = expectPhase(result.state, 'failed');
+ expect(failed.kind).toBe('idle');
+ expect(failed.reason).toBe('Session idle');
+ expect(effectsOfType(result.effects, 'teardown')).toEqual([
+ { type: 'teardown', preserveNativePeer: false },
+ ]);
+ expect(deriveUiState(result.state)).toMatchObject({
+ isConnected: false,
+ isLoading: false,
+ isReconnecting: false,
+ canManualReconnect: true,
+ });
+ });
+
+ it('ignores SESSION_IDLE outside connected', () => {
+ const stopped = createInitialState();
+ expect(transition(stopped, { type: 'SESSION_IDLE' })).toEqual({
+ state: stopped,
+ effects: [],
+ });
+
+ const negotiating = bootToNegotiating();
+ expect(transition(negotiating, { type: 'SESSION_IDLE' })).toEqual({
+ state: negotiating,
+ effects: [],
+ });
+
+ const fromNegotiating = bootToNegotiating();
+ const backoff = expectPhase(
+ run([{ type: 'DEADLINE', attemptId: fromNegotiating.attemptId }], fromNegotiating).state,
+ 'backoff',
+ );
+ expect(transition(backoff, { type: 'SESSION_IDLE' })).toEqual({
+ state: backoff,
+ effects: [],
+ });
+ });
+
+ it('recovers from an idle failure via USER_RECONNECT', () => {
+ const { state: failed } = run([{ type: 'SESSION_IDLE' }], bootToConnected());
+ expectPhase(failed, 'failed');
+ const result = transition(failed, { type: 'USER_RECONNECT' });
+ const negotiating = expectPhase(result.state, 'negotiating');
+ expect(negotiating.mode).toBe('connect');
+ expect(effectsOfType(result.effects, 'negotiate')).toHaveLength(1);
+ });
+ });
+
+ describe('waiting-phase hydration', () => {
+ it('attaches to a live native connection reported while waiting', () => {
+ // Socket still down -> parked in waiting; a live open channel is itself
+ // proof the peer exists, so the snapshot bypasses the gates.
+ const waiting = expectPhase(
+ run([{ type: 'START' }, { type: 'SERVICE_READY' }]).state,
+ 'waiting',
+ );
+ const result = transition(waiting, {
+ type: 'NATIVE_SNAPSHOT',
+ alive: true,
+ channelOpen: true,
+ });
+ const negotiating = expectPhase(result.state, 'negotiating');
+ expect(negotiating.mode).toBe('attach');
+ expect(effectsOfType(result.effects, 'negotiate')).toEqual([
+ { type: 'negotiate', attemptId: negotiating.attemptId, mode: 'attach' },
+ ]);
+ });
+
+ it('stays gated when the snapshot reports no live connection', () => {
+ const waiting = expectPhase(
+ run([{ type: 'START' }, { type: 'SERVICE_READY' }]).state,
+ 'waiting',
+ );
+ const result = transition(waiting, {
+ type: 'NATIVE_SNAPSHOT',
+ alive: false,
+ channelOpen: false,
+ });
+ expectPhase(result.state, 'waiting');
+ expect(result.effects).toEqual([]);
+ });
+ });
+
+});
diff --git a/__tests__/lib/ac2/idleSession.test.ts b/__tests__/lib/ac2/idleSession.test.ts
new file mode 100644
index 0000000..6fe0119
--- /dev/null
+++ b/__tests__/lib/ac2/idleSession.test.ts
@@ -0,0 +1,122 @@
+import { evaluateIdleSession } from '@/lib/ac2/idleSession';
+
+const IDLE_TIMEOUT = 60000;
+
+function baseInput(overrides: Partial[0]> = {}) {
+ const now = 1_000_000_000;
+ return {
+ now,
+ lastInboundAt: now - 1000,
+ lastLocalAt: now - 1000,
+ idleTimeoutMs: IDLE_TIMEOUT,
+ userStopped: false,
+ wasBackgrounded: false,
+ isNativeChannelOpen: () => false,
+ ...overrides,
+ };
+}
+
+describe('evaluateIdleSession', () => {
+ it('does nothing while there is recent activity (inbound or local)', () => {
+ expect(evaluateIdleSession(baseInput()).action).toBe('none');
+ // Either clock alone keeps the session alive.
+ const now = baseInput().now;
+ expect(
+ evaluateIdleSession(
+ baseInput({ lastInboundAt: now - IDLE_TIMEOUT * 10, lastLocalAt: now - 1 }),
+ ).action,
+ ).toBe('none');
+ expect(
+ evaluateIdleSession(
+ baseInput({ lastInboundAt: now - 1, lastLocalAt: now - IDLE_TIMEOUT * 10 }),
+ ).action,
+ ).toBe('none');
+ });
+
+ it('does not consult the native side while activity is recent', () => {
+ const isNativeChannelOpen = jest.fn(() => true);
+ evaluateIdleSession(baseInput({ isNativeChannelOpen }));
+ expect(isNativeChannelOpen).not.toHaveBeenCalled();
+ });
+
+ it('closes a genuine foreground idle as terminal (manual recovery)', () => {
+ const now = baseInput().now;
+ const isNativeChannelOpen = jest.fn(() => true);
+ const verdict = evaluateIdleSession(
+ baseInput({
+ lastInboundAt: now - IDLE_TIMEOUT - 1,
+ lastLocalAt: now - IDLE_TIMEOUT - 1,
+ wasBackgrounded: false,
+ isNativeChannelOpen,
+ }),
+ );
+ expect(verdict.action).toBe('close-idle');
+ // A foreground idle is judged on the clocks alone — no native query.
+ expect(isNativeChannelOpen).not.toHaveBeenCalled();
+ });
+
+ it('treats an intentional stop as a plain idle close even after backgrounding', () => {
+ const now = baseInput().now;
+ expect(
+ evaluateIdleSession(
+ baseInput({
+ lastInboundAt: now - IDLE_TIMEOUT - 1,
+ lastLocalAt: now - IDLE_TIMEOUT - 1,
+ userStopped: true,
+ wasBackgrounded: true,
+ isNativeChannelOpen: () => true,
+ }),
+ ).action,
+ ).toBe('close-idle');
+ });
+
+ it('REFRESHES (not tears down) a backgrounded-stale session whose native channel is still open', () => {
+ // Regression: after a LONG background the liveness clocks are hours old
+ // (JS timers suspended), and on resume the watchdog interval can fire
+ // BEFORE the AppState listener's snapshot reconcile. The native background
+ // service kept the peer alive the whole time — killing the session logged
+ // "Closing stale connection after background; will reconnect" and forced a
+ // full renegotiation of a verifiably healthy connection.
+ const now = baseInput().now;
+ const hoursOld = now - 3 * 60 * 60 * 1000;
+ const verdict = evaluateIdleSession(
+ baseInput({
+ lastInboundAt: hoursOld,
+ lastLocalAt: hoursOld,
+ wasBackgrounded: true,
+ isNativeChannelOpen: () => true,
+ }),
+ );
+ expect(verdict.action).toBe('refresh');
+ });
+
+ it('closes a backgrounded-stale session as recoverable when the native side is dead', () => {
+ const now = baseInput().now;
+ expect(
+ evaluateIdleSession(
+ baseInput({
+ lastInboundAt: now - IDLE_TIMEOUT - 1,
+ lastLocalAt: now - IDLE_TIMEOUT - 1,
+ wasBackgrounded: true,
+ isNativeChannelOpen: () => false,
+ }),
+ ).action,
+ ).toBe('close-stale');
+ });
+
+ it('treats a throwing native query (module unavailable) as a dead native side', () => {
+ const now = baseInput().now;
+ expect(
+ evaluateIdleSession(
+ baseInput({
+ lastInboundAt: now - IDLE_TIMEOUT - 1,
+ lastLocalAt: now - IDLE_TIMEOUT - 1,
+ wasBackgrounded: true,
+ isNativeChannelOpen: () => {
+ throw new Error('native module unavailable');
+ },
+ }),
+ ).action,
+ ).toBe('close-stale');
+ });
+});
diff --git a/__tests__/lib/ac2/nativeChannel.test.ts b/__tests__/lib/ac2/nativeChannel.test.ts
new file mode 100644
index 0000000..2b4ccac
--- /dev/null
+++ b/__tests__/lib/ac2/nativeChannel.test.ts
@@ -0,0 +1,219 @@
+import { NativeDataChannel, NativePeerConnection } from '@/lib/ac2/nativeChannel';
+import { monitorPeerConnection } from '@/lib/ac2/peerConnectionMonitor';
+
+/**
+ * Let the microtask queue drain so the shim's deferred backlog flush
+ * (`queueMicrotask`) runs before assertions.
+ */
+const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+describe('NativeDataChannel', () => {
+ it('starts connecting and forwards sends to the native channel by label', () => {
+ const send = jest.fn();
+ const channel = new NativeDataChannel('ac2-v1', send);
+
+ expect(channel.label).toBe('ac2-v1');
+ expect(channel.readyState).toBe('connecting');
+
+ channel.send('hello');
+ expect(send).toHaveBeenCalledWith('ac2-v1', 'hello');
+ });
+
+ it('maps uppercase native states to the RTCDataChannel readyState union', () => {
+ const channel = new NativeDataChannel('ac2-v1', jest.fn());
+
+ channel.setState('OPEN');
+ expect(channel.readyState).toBe('open');
+ channel.setState('CLOSING');
+ expect(channel.readyState).toBe('closing');
+ channel.setState('CLOSED');
+ expect(channel.readyState).toBe('closed');
+ });
+
+ it('fires onopen and "open" listeners on transition to open (once)', () => {
+ const channel = new NativeDataChannel('ac2-v1', jest.fn());
+ const onopen = jest.fn();
+ const listener = jest.fn();
+ channel.onopen = onopen;
+ channel.addEventListener('open', listener);
+
+ channel.setState('OPEN');
+ channel.setState('OPEN'); // no-op: already open
+
+ expect(onopen).toHaveBeenCalledTimes(1);
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it('delivers inbound messages via onmessage and "message" listeners', () => {
+ const channel = new NativeDataChannel('ac2-stream', jest.fn());
+ const onmessage = jest.fn();
+ const listener = jest.fn();
+ channel.onmessage = onmessage;
+ channel.addEventListener('message', listener);
+
+ channel.dispatchMessage('frame');
+
+ expect(onmessage).toHaveBeenCalledWith({ data: 'frame' });
+ expect(listener).toHaveBeenCalledWith({ data: 'frame' });
+ });
+
+ it('buffers messages that arrive before a consumer attaches and flushes them on onmessage', async () => {
+ const channel = new NativeDataChannel('ac2-v1', jest.fn());
+ // Messages replayed from the offline queue during hydrate arrive before the
+ // SDK client wires `onmessage` on the control channel.
+ channel.dispatchMessage('first');
+ channel.dispatchMessage('second');
+
+ const onmessage = jest.fn();
+ channel.onmessage = onmessage;
+
+ // The flush is deferred to a microtask so a consumer that wires its real
+ // handlers on the lines AFTER assigning `onmessage` (like the AC2 SDK's
+ // `rtcDataChannelTransport`) is fully ready before the backlog is replayed.
+ // Nothing is delivered synchronously.
+ expect(onmessage).not.toHaveBeenCalled();
+
+ await flushMicrotasks();
+
+ // After the microtask, the buffered messages arrive in order.
+ expect(onmessage).toHaveBeenNthCalledWith(1, { data: 'first' });
+ expect(onmessage).toHaveBeenNthCalledWith(2, { data: 'second' });
+ expect(onmessage).toHaveBeenCalledTimes(2);
+ });
+
+ it('flushes buffered messages when a "message" listener is added', async () => {
+ const channel = new NativeDataChannel('ac2-v1', jest.fn());
+ channel.dispatchMessage('buffered');
+
+ const listener = jest.fn();
+ channel.addEventListener('message', listener);
+
+ await flushMicrotasks();
+
+ expect(listener).toHaveBeenCalledWith({ data: 'buffered' });
+ });
+
+ it('preserves arrival order when a live frame arrives before the deferred flush', async () => {
+ const channel = new NativeDataChannel('ac2-v1', jest.fn());
+ // Backlog buffered before any consumer.
+ channel.dispatchMessage('backlog');
+
+ const onmessage = jest.fn();
+ channel.onmessage = onmessage;
+
+ // A live frame arrives after the consumer attaches but before the deferred
+ // flush runs — it must queue behind the backlog, not jump ahead.
+ channel.dispatchMessage('live');
+ expect(onmessage).not.toHaveBeenCalled();
+
+ await flushMicrotasks();
+
+ expect(onmessage).toHaveBeenNthCalledWith(1, { data: 'backlog' });
+ expect(onmessage).toHaveBeenNthCalledWith(2, { data: 'live' });
+ expect(onmessage).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not re-deliver buffered messages once flushed', async () => {
+ const channel = new NativeDataChannel('ac2-v1', jest.fn());
+ channel.dispatchMessage('once');
+
+ const first = jest.fn();
+ channel.onmessage = first;
+ await flushMicrotasks();
+ expect(first).toHaveBeenCalledTimes(1);
+
+ // A later handler must not see the already-flushed backlog again.
+ const second = jest.fn();
+ channel.onmessage = second;
+ await flushMicrotasks();
+ expect(second).not.toHaveBeenCalled();
+ });
+
+ it('close() flips to closed and fires close once', () => {
+ const channel = new NativeDataChannel('ac2-v1', jest.fn());
+ const onclose = jest.fn();
+ channel.onclose = onclose;
+
+ channel.close();
+ channel.close();
+
+ expect(channel.readyState).toBe('closed');
+ expect(onclose).toHaveBeenCalledTimes(1);
+ });
+
+ it('removeEventListener detaches a listener', () => {
+ const channel = new NativeDataChannel('ac2-v1', jest.fn());
+ const listener = jest.fn();
+ channel.addEventListener('open', listener);
+ channel.removeEventListener('open', listener);
+
+ channel.setState('OPEN');
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('keeps dispatching even if a consumer handler throws', () => {
+ const channel = new NativeDataChannel('ac2-v1', jest.fn());
+ const listener = jest.fn();
+ channel.onmessage = () => {
+ throw new Error('boom');
+ };
+ channel.addEventListener('message', listener);
+
+ expect(() => channel.dispatchMessage('x')).not.toThrow();
+ expect(listener).toHaveBeenCalledWith({ data: 'x' });
+ });
+});
+
+describe('NativePeerConnection', () => {
+ it('lowercases native ICE states into iceConnectionState/connectionState', () => {
+ const pc = new NativePeerConnection();
+ pc.setConnectionState('CONNECTED');
+ expect(pc.iceConnectionState).toBe('connected');
+ expect(pc.connectionState).toBe('connected');
+ });
+
+ it('notifies iceconnectionstatechange listeners', () => {
+ const pc = new NativePeerConnection();
+ const listener = jest.fn();
+ pc.addEventListener('iceconnectionstatechange', listener);
+
+ pc.setConnectionState('DISCONNECTED');
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(pc.iceConnectionState).toBe('disconnected');
+ });
+
+ it('drives the real peerConnectionMonitor to failure on FAILED', () => {
+ const pc = new NativePeerConnection();
+ const onFailed = jest.fn();
+ const dispose = monitorPeerConnection(pc as any, { onFailed });
+
+ pc.setConnectionState('FAILED');
+
+ expect(onFailed).toHaveBeenCalledWith('ice');
+ dispose();
+ });
+
+ it('lets the monitor recover a transient disconnect before the grace window', () => {
+ jest.useFakeTimers();
+ try {
+ const pc = new NativePeerConnection();
+ const onFailed = jest.fn();
+ const onRecovered = jest.fn();
+ const dispose = monitorPeerConnection(pc as any, {
+ onFailed,
+ onRecovered,
+ gracePeriodMs: 10000,
+ });
+
+ pc.setConnectionState('DISCONNECTED');
+ pc.setConnectionState('CONNECTED');
+ jest.advanceTimersByTime(10000);
+
+ expect(onRecovered).toHaveBeenCalledTimes(1);
+ expect(onFailed).not.toHaveBeenCalled();
+ dispose();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+});
diff --git a/__tests__/lib/ac2/nativeTransport.test.ts b/__tests__/lib/ac2/nativeTransport.test.ts
new file mode 100644
index 0000000..8e20581
--- /dev/null
+++ b/__tests__/lib/ac2/nativeTransport.test.ts
@@ -0,0 +1,515 @@
+import type { NativeDataChannel } from '@/lib/ac2/nativeChannel';
+import {
+ AC2_CONTROL_CHANNEL,
+ addNativeSignalingStateListener,
+ createNativeAc2Transport,
+ flushNativeQueue,
+ isSnapshotChannelOpen,
+ type LiquidAuthNativeApi,
+ nativeAuthFetch,
+ type NativeSignalingStateEvent,
+} from '@/lib/ac2/nativeTransport';
+
+/** Flush pending microtasks so awaited `start()`/`connect()` progress. */
+const flush = () => new Promise((resolve) => setImmediate(resolve));
+
+interface FakeNative {
+ api: LiquidAuthNativeApi;
+ emitMessage: (channel: string, message: string) => void;
+ emitState: (channel: string, state: string | null) => void;
+ emitConnection: (state: string) => void;
+ emitPresence: (e: { requestId: string; deviceCount: number; online: boolean }) => void;
+ emitLinkError: (e: { reason?: string; message?: string }) => void;
+ resolveConnect: () => void;
+ rejectConnect: (err: Error) => void;
+ setConnectionState: (s: {
+ connected: boolean;
+ requestId: string | null;
+ iceConnectionState: string | null;
+ channels: Record;
+ }) => void;
+ sent: Array<[string, string]>;
+ activeMessageListeners: () => number;
+}
+
+function createFakeNative(): FakeNative {
+ const message = new Set<(e: any) => void>();
+ const state = new Set<(e: any) => void>();
+ const conn = new Set<(e: any) => void>();
+ const presence = new Set<(e: any) => void>();
+ const link = new Set<(e: any) => void>();
+ const sent: Array<[string, string]> = [];
+
+ let resolveConnect: () => void = () => {};
+ let rejectConnect: (err: Error) => void = () => {};
+ let connectionState = {
+ connected: false,
+ requestId: null as string | null,
+ iceConnectionState: null as string | null,
+ channels: {} as Record,
+ };
+
+ const sub = (set: Set<(e: any) => void>, l: (e: any) => void) => {
+ set.add(l);
+ return { remove: () => set.delete(l) };
+ };
+ const emit = (set: Set<(e: any) => void>, e: any) => {
+ for (const l of set) l(e);
+ };
+
+ const api: LiquidAuthNativeApi = {
+ start: jest.fn(async () => {}),
+ connect: jest.fn(
+ () =>
+ new Promise((res, rej) => {
+ resolveConnect = res;
+ rejectConnect = rej;
+ }),
+ ),
+ cancel: jest.fn(async () => {}),
+ getConnectionState: jest.fn(() => connectionState),
+ attach: jest.fn(async () => {}),
+ setActive: jest.fn(() => {}),
+ flushQueue: jest.fn(() => {}),
+ sendToChannel: jest.fn((channel: string, m: string) => {
+ sent.push([channel, m]);
+ }),
+ disconnect: jest.fn(async () => {}),
+ addMessageListener: (l) => sub(message, l),
+ addStateChangeListener: (l) => sub(state, l),
+ addConnectionStateListener: (l) => sub(conn, l),
+ addPresenceListener: (l) => sub(presence, l),
+ addLinkErrorListener: (l) => sub(link, l),
+ request: jest.fn(async () => ({ ok: true, status: 200, statusText: 'OK', body: '' })),
+ };
+
+ return {
+ api,
+ emitMessage: (channel, m) => emit(message, { channel, message: m }),
+ emitState: (channel, s) => emit(state, { channel, state: s }),
+ emitConnection: (s) => emit(conn, { state: s }),
+ emitPresence: (e) => emit(presence, e),
+ emitLinkError: (e) => emit(link, e),
+ resolveConnect: () => resolveConnect(),
+ rejectConnect: (err) => rejectConnect(err),
+ setConnectionState: (s: typeof connectionState) => {
+ connectionState = s;
+ },
+ sent,
+ activeMessageListeners: () => message.size,
+ };
+}
+
+describe('createNativeAc2Transport', () => {
+ it('starts the service, negotiates as answerer, and resolves once ac2-v1 opens', async () => {
+ const fake = createFakeNative();
+ const sideChannels: NativeDataChannel[] = [];
+ const onPeerConnection = jest.fn();
+
+ const promise = createNativeAc2Transport({
+ url: 'https://signal.example',
+ requestId: 'req-1',
+ native: fake.api,
+ onSideChannel: (c) => sideChannels.push(c),
+ onPeerConnection,
+ });
+
+ await flush();
+
+ expect(fake.api.start).toHaveBeenCalledWith('https://signal.example');
+ const connectArgs = (fake.api.connect as jest.Mock).mock.calls[0];
+ expect(connectArgs[0]).toBe('req-1');
+ expect(connectArgs[1]).toBe('answer');
+ expect(connectArgs[3].dataChannels).toHaveProperty(AC2_CONTROL_CHANNEL);
+ // Deliverable channels are buffered natively while the app is offline;
+ // pure control (`ac2-heartbeat`) is intentionally excluded.
+ expect(connectArgs[3].queueChannels).toEqual(['ac2-v1', 'ac2-stream']);
+
+ // Heartbeat keep-alive is enabled by default so the native service answers
+ // the agent's `ping` with a `pong` while the app is backgrounded (the JS
+ // ping/pong reply is dead then), keeping the connection from being torn
+ // down by the agent's liveness watchdog.
+ expect(connectArgs[3].heartbeat).toEqual({
+ channel: 'ac2-heartbeat',
+ ping: 'ping',
+ pong: 'pong',
+ });
+
+ // Side channels are surfaced before negotiation completes.
+ expect(sideChannels.map((c) => c.label).sort()).toEqual(['ac2-heartbeat', 'ac2-stream']);
+
+ fake.emitState(AC2_CONTROL_CHANNEL, 'OPEN');
+ fake.resolveConnect();
+ const setup = await promise;
+
+ expect(setup.datachannel.label).toBe(AC2_CONTROL_CHANNEL);
+ expect(setup.datachannel.readyState).toBe('open');
+ expect(onPeerConnection).toHaveBeenCalledWith(setup.peerConnection);
+ });
+
+ it('re-attaches (no renegotiation) when the service already holds a live connection for the requestId', async () => {
+ const fake = createFakeNative();
+ fake.setConnectionState({
+ connected: true,
+ requestId: 'req-1',
+ iceConnectionState: 'CONNECTED',
+ channels: { 'ac2-v1': 'OPEN', 'ac2-stream': 'OPEN', 'ac2-heartbeat': 'OPEN' },
+ });
+
+ const promise = createNativeAc2Transport({
+ url: 'https://signal.example',
+ requestId: 'req-1',
+ native: fake.api,
+ onSideChannel: () => {},
+ });
+
+ await flush();
+
+ // The live connection is re-attached, not renegotiated.
+ expect(fake.api.attach).toHaveBeenCalledTimes(1);
+ expect(fake.api.connect).not.toHaveBeenCalled();
+
+ // attach() re-emits the current channel state so the control channel opens.
+ fake.emitState(AC2_CONTROL_CHANNEL, 'OPEN');
+ const setup = await promise;
+ expect(setup.datachannel.readyState).toBe('open');
+ });
+
+ it('delivers offline-queue messages replayed during attach() to a consumer wired AFTER setup (hydrate)', async () => {
+ const fake = createFakeNative();
+ fake.setConnectionState({
+ connected: true,
+ requestId: 'req-1',
+ iceConnectionState: 'CONNECTED',
+ channels: { 'ac2-v1': 'OPEN' },
+ });
+
+ const promise = createNativeAc2Transport({
+ url: 'https://signal.example',
+ requestId: 'req-1',
+ native: fake.api,
+ onSideChannel: () => {},
+ });
+
+ await flush();
+ expect(fake.api.attach).toHaveBeenCalledTimes(1);
+
+ // The native service drains its offline queue during attach()/handleMessages,
+ // BEFORE the SDK client wires the control channel's `onmessage` (which only
+ // happens after this factory resolves). The replayed control-plane message
+ // therefore arrives with no consumer attached yet.
+ fake.emitMessage(AC2_CONTROL_CHANNEL, 'queued-while-closed');
+ // attach() then re-emits the live channel state so the control channel opens.
+ fake.emitState(AC2_CONTROL_CHANNEL, 'OPEN');
+
+ const setup = await promise;
+
+ // The SDK client wires the consumer only now (post-resolve).
+ const raw: string[] = [];
+ setup.datachannel.onmessage = (ev) => {
+ if (typeof ev.data === 'string') raw.push(ev.data);
+ };
+
+ // The deferred flush replays the buffered message once the consumer attaches.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(raw).toEqual(['queued-while-closed']);
+ });
+
+ it('renegotiates (not attach) when the live connection is for a different requestId', async () => {
+ const fake = createFakeNative();
+ fake.setConnectionState({
+ connected: true,
+ requestId: 'other-req',
+ iceConnectionState: 'CONNECTED',
+ channels: { 'ac2-v1': 'OPEN' },
+ });
+
+ const promise = createNativeAc2Transport({
+ url: 'https://signal.example',
+ requestId: 'req-1',
+ native: fake.api,
+ onSideChannel: () => {},
+ });
+
+ await flush();
+ expect(fake.api.attach).not.toHaveBeenCalled();
+ expect(fake.api.connect).toHaveBeenCalledTimes(1);
+
+ fake.emitState(AC2_CONTROL_CHANNEL, 'OPEN');
+ fake.resolveConnect();
+ await promise;
+ });
+
+ it('routes native messages to the matching channel shim', async () => {
+ const fake = createFakeNative();
+ const streamFrames: string[] = [];
+
+ const promise = createNativeAc2Transport({
+ url: 'https://signal.example',
+ requestId: 'req-1',
+ native: fake.api,
+ onSideChannel: (c) => {
+ if (c.label === 'ac2-stream') c.onmessage = (e) => streamFrames.push(e.data);
+ },
+ });
+
+ await flush();
+ fake.emitState(AC2_CONTROL_CHANNEL, 'OPEN');
+ fake.resolveConnect();
+ const setup = await promise;
+
+ fake.emitMessage('ac2-stream', 'control-frame');
+ expect(streamFrames).toEqual(['control-frame']);
+
+ setup.datachannel.send('outbound');
+ expect(fake.sent).toContainEqual([AC2_CONTROL_CHANNEL, 'outbound']);
+ });
+
+ it('satisfies the RtcDataChannelLike surface the AC2 SDK client binds to', async () => {
+ const fake = createFakeNative();
+
+ const promise = createNativeAc2Transport({
+ url: 'https://signal.example',
+ requestId: 'req-1',
+ native: fake.api,
+ onSideChannel: () => {},
+ });
+
+ await flush();
+ fake.emitState(AC2_CONTROL_CHANNEL, 'OPEN');
+ fake.resolveConnect();
+ const setup = await promise;
+
+ // Mirror the exact wiring `rtcDataChannelTransport` performs on the channel
+ // (property-style handlers + label/readyState/send), without importing the
+ // SDK subpath (unresolvable under this repo's Jest moduleNameMapper).
+ const raw: string[] = [];
+ let opened = false;
+ const channel = setup.datachannel;
+ expect(channel.label).toBe(AC2_CONTROL_CHANNEL);
+ channel.onmessage = (ev) => {
+ if (typeof ev.data === 'string') raw.push(ev.data);
+ };
+ channel.onopen = () => {
+ opened = true;
+ };
+
+ fake.emitMessage(AC2_CONTROL_CHANNEL, 'not-json');
+ expect(raw).toEqual(['not-json']);
+
+ expect(channel.readyState).toBe('open');
+ channel.send('{"hi":true}');
+ expect(fake.sent).toContainEqual([AC2_CONTROL_CHANNEL, '{"hi":true}']);
+ // `opened` demonstrates the property handler is invocable; the channel was
+ // already open before assignment (as it is post-negotiation).
+ expect(opened).toBe(false);
+ });
+
+ it('forwards presence updates to onPresence', async () => {
+ const fake = createFakeNative();
+ const onPresence = jest.fn();
+
+ const promise = createNativeAc2Transport({
+ url: 'https://signal.example',
+ requestId: 'req-1',
+ native: fake.api,
+ onSideChannel: () => {},
+ onPresence,
+ });
+
+ await flush();
+ fake.emitState(AC2_CONTROL_CHANNEL, 'OPEN');
+ fake.resolveConnect();
+ const setup = await promise;
+
+ fake.emitPresence({ requestId: 'req-1', deviceCount: 2, online: true });
+ expect(onPresence).toHaveBeenCalledWith({ requestId: 'req-1', deviceCount: 2, online: true });
+
+ setup.disposePresence();
+ fake.emitPresence({ requestId: 'req-1', deviceCount: 1, online: true });
+ expect(onPresence).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejects immediately for an already-aborted signal and never starts', async () => {
+ const fake = createFakeNative();
+ const controller = new AbortController();
+ controller.abort();
+
+ await expect(
+ createNativeAc2Transport({
+ url: 'https://signal.example',
+ requestId: 'req-1',
+ native: fake.api,
+ onSideChannel: () => {},
+ signal: controller.signal,
+ }),
+ ).rejects.toMatchObject({ name: 'AbortError' });
+
+ expect(fake.api.start).not.toHaveBeenCalled();
+ });
+
+ it('cancels the native negotiation and detaches listeners on abort', async () => {
+ const fake = createFakeNative();
+ const controller = new AbortController();
+
+ const promise = createNativeAc2Transport({
+ url: 'https://signal.example',
+ requestId: 'req-1',
+ native: fake.api,
+ onSideChannel: () => {},
+ signal: controller.signal,
+ });
+
+ await flush();
+ controller.abort();
+
+ await expect(promise).rejects.toMatchObject({ name: 'AbortError' });
+ expect(fake.api.cancel).toHaveBeenCalled();
+ expect(fake.activeMessageListeners()).toBe(0);
+ });
+});
+
+describe('flushNativeQueue', () => {
+ it('delegates to the native flushQueue', () => {
+ const fake = createFakeNative();
+ flushNativeQueue(fake.api);
+ expect(fake.api.flushQueue).toHaveBeenCalledTimes(1);
+ });
+
+ it('is a no-op when the native module does not implement flushQueue (older binary)', () => {
+ const fake = createFakeNative();
+ delete (fake.api as any).flushQueue;
+ expect(() => flushNativeQueue(fake.api)).not.toThrow();
+ });
+});
+
+describe('isSnapshotChannelOpen', () => {
+ const snapshot = (channels: Record) => ({
+ connected: true,
+ requestId: 'req-1',
+ iceConnectionState: 'CONNECTED',
+ channels,
+ });
+
+ it('recognizes the UPPERCASE enum strings the native snapshot carries', () => {
+ // Regression: the raw snapshot reports states verbatim (`"OPEN"`), and a
+ // lowercase `=== 'open'` check misread a healthy resume as dead — forcing
+ // a spurious reconnect on every background -> foreground transition.
+ expect(isSnapshotChannelOpen(snapshot({ [AC2_CONTROL_CHANNEL]: 'OPEN' }))).toBe(true);
+ });
+
+ it('accepts an already-lowercase state', () => {
+ expect(isSnapshotChannelOpen(snapshot({ [AC2_CONTROL_CHANNEL]: 'open' }))).toBe(true);
+ });
+
+ it('rejects non-open states regardless of case', () => {
+ expect(isSnapshotChannelOpen(snapshot({ [AC2_CONTROL_CHANNEL]: 'CLOSING' }))).toBe(false);
+ expect(isSnapshotChannelOpen(snapshot({ [AC2_CONTROL_CHANNEL]: 'closed' }))).toBe(false);
+ });
+
+ it('is false when the channel is missing from the snapshot', () => {
+ expect(isSnapshotChannelOpen(snapshot({ 'ac2-stream': 'OPEN' }))).toBe(false);
+ expect(isSnapshotChannelOpen(snapshot({}))).toBe(false);
+ });
+
+ it('checks a custom channel label when given one', () => {
+ expect(isSnapshotChannelOpen(snapshot({ 'ac2-stream': 'OPEN' }), 'ac2-stream')).toBe(true);
+ });
+});
+
+describe('addNativeSignalingStateListener', () => {
+ it('delegates to the native listener and forwards events until removed', () => {
+ const listeners = new Set<(e: NativeSignalingStateEvent) => void>();
+ const fake = createFakeNative();
+ (fake.api as any).addSignalingStateListener = (l: (e: NativeSignalingStateEvent) => void) => {
+ listeners.add(l);
+ return { remove: () => listeners.delete(l) };
+ };
+
+ const events: NativeSignalingStateEvent[] = [];
+ const sub = addNativeSignalingStateListener((e) => events.push(e), fake.api);
+
+ for (const l of listeners) l({ state: 'disconnected' });
+ for (const l of listeners) l({ state: 'connected' });
+ expect(events).toEqual([{ state: 'disconnected' }, { state: 'connected' }]);
+
+ sub.remove();
+ expect(listeners.size).toBe(0);
+ });
+
+ it('returns a no-op subscription when the native module predates the event', () => {
+ const fake = createFakeNative();
+ // createFakeNative does not implement addSignalingStateListener, matching
+ // an older native binary.
+ const sub = addNativeSignalingStateListener(() => {}, fake.api);
+ expect(() => sub.remove()).not.toThrow();
+ });
+});
+
+describe('nativeAuthFetch', () => {
+ it('maps a JSON POST onto the native request and returns a Response', async () => {
+ const fake = createFakeNative();
+ (fake.api.request as jest.Mock).mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ body: '{"authenticated":true}',
+ });
+
+ const res = await nativeAuthFetch(
+ 'https://signal.example/attestation/response',
+ { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"a":1}' },
+ fake.api,
+ );
+
+ expect(fake.api.request).toHaveBeenCalledWith(
+ 'https://signal.example/attestation/response',
+ 'POST',
+ { 'Content-Type': 'application/json' },
+ '{"a":1}',
+ );
+ expect(res.ok).toBe(true);
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toEqual({ authenticated: true });
+ });
+
+ it('defaults to GET with no body and surfaces non-ok status', async () => {
+ const fake = createFakeNative();
+ (fake.api.request as jest.Mock).mockResolvedValueOnce({
+ ok: false,
+ status: 401,
+ statusText: 'Unauthorized',
+ body: '',
+ });
+
+ const res = await nativeAuthFetch('https://signal.example/auth/session', {}, fake.api);
+
+ expect(fake.api.request).toHaveBeenCalledWith(
+ 'https://signal.example/auth/session',
+ 'GET',
+ undefined,
+ undefined,
+ );
+ expect(res.ok).toBe(false);
+ expect(res.status).toBe(401);
+ });
+
+ it('flattens a Headers instance into a plain string map', async () => {
+ const fake = createFakeNative();
+ (fake.api.request as jest.Mock).mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ body: '',
+ });
+
+ await nativeAuthFetch(
+ 'https://signal.example/assertion/response',
+ { method: 'POST', headers: new Headers({ 'Content-Type': 'application/json' }), body: '{}' },
+ fake.api,
+ );
+
+ const headersArg = (fake.api.request as jest.Mock).mock.calls[0][2];
+ expect(headersArg).toMatchObject({ 'content-type': 'application/json' });
+ });
+});
diff --git a/__tests__/lib/ac2/presence.test.ts b/__tests__/lib/ac2/presence.test.ts
new file mode 100644
index 0000000..6db7057
--- /dev/null
+++ b/__tests__/lib/ac2/presence.test.ts
@@ -0,0 +1,207 @@
+import {
+ hasPeerPresence,
+ isPeerOffline,
+ isPeerUnreachableError,
+ normalizePresence,
+ PRESENCE_EVENT,
+ queryPresence,
+ subscribeToPresence,
+} from '@/lib/ac2/presence';
+
+type Ack = (data: unknown) => void;
+
+/** Minimal socket.io-like mock capturing the presence emit + broadcasts. */
+function createFakeSocket() {
+ const listeners = new Map void>>();
+ let lastEmit: { event: string; payload: unknown; ack?: Ack } | undefined;
+ return {
+ lastEmit: () => lastEmit,
+ emit(event: string, payload: unknown, ack?: Ack) {
+ lastEmit = { event, payload, ack };
+ },
+ on(event: string, listener: (...args: any[]) => void) {
+ (listeners.get(event) ?? listeners.set(event, new Set()).get(event)!).add(listener);
+ },
+ off(event: string, listener: (...args: any[]) => void) {
+ listeners.get(event)?.delete(listener);
+ },
+ broadcast(event: string, ...args: any[]) {
+ listeners.get(event)?.forEach((l) => l(...args));
+ },
+ listenerCount(event: string) {
+ return listeners.get(event)?.size ?? 0;
+ },
+ };
+}
+
+describe('normalizePresence', () => {
+ it('passes through a well-formed payload', () => {
+ expect(normalizePresence('req-1', { requestId: 'req-1', deviceCount: 2, online: true })).toEqual(
+ { requestId: 'req-1', deviceCount: 2, online: true },
+ );
+ });
+
+ it('derives online from deviceCount when missing and falls back to the queried id', () => {
+ expect(normalizePresence('req-2', { deviceCount: 3 })).toEqual({
+ requestId: 'req-2',
+ deviceCount: 3,
+ online: true,
+ });
+ expect(normalizePresence('req-3', {})).toEqual({
+ requestId: 'req-3',
+ deviceCount: 0,
+ online: false,
+ });
+ });
+
+ it('tolerates a null/undefined payload', () => {
+ expect(normalizePresence('req-4', null)).toEqual({
+ requestId: 'req-4',
+ deviceCount: 0,
+ online: false,
+ });
+ });
+});
+
+describe('queryPresence', () => {
+ it('emits the presence event and resolves with the server ack', async () => {
+ const socket = createFakeSocket();
+ const pending = queryPresence(socket, 'req-1');
+ const emit = socket.lastEmit();
+ expect(emit?.event).toBe(PRESENCE_EVENT);
+ expect(emit?.payload).toEqual({ requestId: 'req-1' });
+ emit?.ack?.({ requestId: 'req-1', deviceCount: 2, online: true });
+ await expect(pending).resolves.toEqual({ requestId: 'req-1', deviceCount: 2, online: true });
+ });
+
+ it('rejects on an empty requestId without emitting', async () => {
+ const socket = createFakeSocket();
+ await expect(queryPresence(socket, '')).rejects.toThrow(/non-empty requestId/);
+ expect(socket.lastEmit()).toBeUndefined();
+ });
+
+ it('rejects when no ack arrives within the timeout', async () => {
+ jest.useFakeTimers();
+ try {
+ const socket = createFakeSocket();
+ const pending = queryPresence(socket, 'req-1', { timeoutMs: 5000 });
+ const assertion = expect(pending).rejects.toThrow(/Timed out waiting for presence ack/);
+ jest.advanceTimersByTime(5000);
+ await assertion;
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('ignores a late ack after the timeout already rejected', async () => {
+ jest.useFakeTimers();
+ try {
+ const socket = createFakeSocket();
+ const pending = queryPresence(socket, 'req-1', { timeoutMs: 1000 });
+ const assertion = expect(pending).rejects.toThrow(/Timed out/);
+ jest.advanceTimersByTime(1000);
+ await assertion;
+ // A late ack must not throw or double-settle.
+ expect(() => socket.lastEmit()?.ack?.({ deviceCount: 1 })).not.toThrow();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('rejects if the socket emit throws', async () => {
+ const socket = {
+ emit: () => {
+ throw new Error('socket down');
+ },
+ };
+ await expect(queryPresence(socket as any, 'req-1')).rejects.toThrow('socket down');
+ });
+});
+
+describe('subscribeToPresence', () => {
+ it('forwards normalized broadcasts and unsubscribes cleanly', () => {
+ const socket = createFakeSocket();
+ const seen: unknown[] = [];
+ const unsubscribe = subscribeToPresence(socket, (p) => seen.push(p));
+
+ socket.broadcast(PRESENCE_EVENT, { requestId: 'req-1', deviceCount: 1, online: true });
+ socket.broadcast(PRESENCE_EVENT, { requestId: 'req-1', deviceCount: 0 });
+
+ expect(seen).toEqual([
+ { requestId: 'req-1', deviceCount: 1, online: true },
+ { requestId: 'req-1', deviceCount: 0, online: false },
+ ]);
+
+ unsubscribe();
+ expect(socket.listenerCount(PRESENCE_EVENT)).toBe(0);
+ });
+
+ it('is a no-op against a socket without on/off', () => {
+ const unsubscribe = subscribeToPresence({ emit: () => {} }, () => {});
+ expect(() => unsubscribe()).not.toThrow();
+ });
+
+ it('is a no-op against a null/undefined socket', () => {
+ // A SignalClient initializes its socket asynchronously, so `client.socket`
+ // can still be undefined when a caller subscribes. This must not throw
+ // "Cannot read property 'on' of undefined".
+ expect(() => subscribeToPresence(undefined, () => {})()).not.toThrow();
+ expect(() => subscribeToPresence(null, () => {})()).not.toThrow();
+ });
+});
+
+describe('isPeerOffline', () => {
+ it('treats a missing snapshot as unknown (not offline)', () => {
+ expect(isPeerOffline(null)).toBe(false);
+ expect(isPeerOffline(undefined)).toBe(false);
+ });
+
+ it('is offline when only the wallet (or nobody) is in the room', () => {
+ expect(isPeerOffline({ requestId: 'req-1', deviceCount: 0, online: false })).toBe(true);
+ expect(isPeerOffline({ requestId: 'req-1', deviceCount: 1, online: true })).toBe(true);
+ });
+
+ it('is not offline when a peer is present alongside the wallet', () => {
+ expect(isPeerOffline({ requestId: 'req-1', deviceCount: 2, online: true })).toBe(false);
+ expect(isPeerOffline({ requestId: 'req-1', deviceCount: 3, online: true })).toBe(false);
+ });
+});
+
+describe('isPeerUnreachableError', () => {
+ it('matches the answer-description signaling timeout', () => {
+ expect(
+ isPeerUnreachableError(
+ new Error(
+ 'Timed out waiting for Liquid Auth answer-description. Check that the signaling socket is authenticated and the OpenClaw peer is still linked to this requestId.',
+ ),
+ ),
+ ).toBe(true);
+ });
+
+ it('ignores unrelated errors and non-error inputs', () => {
+ expect(isPeerUnreachableError(new Error('ICE connection failed'))).toBe(false);
+ expect(isPeerUnreachableError(null)).toBe(false);
+ expect(isPeerUnreachableError(undefined)).toBe(false);
+ expect(isPeerUnreachableError({} as any)).toBe(false);
+ });
+});
+
+describe('hasPeerPresence', () => {
+ it('resolves true when at least one device is connected', async () => {
+ const socket = createFakeSocket();
+ const pending = hasPeerPresence(socket, 'req-1');
+ socket.lastEmit()?.ack?.({ requestId: 'req-1', deviceCount: 1, online: true });
+ await expect(pending).resolves.toBe(true);
+ });
+
+ it('resolves false when nobody is connected', async () => {
+ const socket = createFakeSocket();
+ const pending = hasPeerPresence(socket, 'req-1');
+ socket.lastEmit()?.ack?.({ requestId: 'req-1', deviceCount: 0, online: false });
+ await expect(pending).resolves.toBe(false);
+ });
+
+ it('swallows query errors into false', async () => {
+ await expect(hasPeerPresence({ emit: () => {} }, '')).resolves.toBe(false);
+ });
+});
diff --git a/__tests__/lib/ac2/stream.test.ts b/__tests__/lib/ac2/stream.test.ts
new file mode 100644
index 0000000..11151d5
--- /dev/null
+++ b/__tests__/lib/ac2/stream.test.ts
@@ -0,0 +1,125 @@
+import {
+ isRegistrationBlockingNotice,
+ normalizeNoticeFrame,
+ parseStreamControlFrame,
+ selectConnectionNoticeForRequest,
+ STX,
+ type ConnectionNotice,
+} from '@/lib/ac2/stream';
+
+const stx = String.fromCharCode(STX);
+
+describe('parseStreamControlFrame', () => {
+ it('returns null for a non-control-frame string', () => {
+ expect(parseStreamControlFrame('hello')).toBeNull();
+ });
+
+ it('returns undefined for an STX-prefixed but malformed payload', () => {
+ expect(parseStreamControlFrame(`${stx}not json`)).toBeUndefined();
+ });
+
+ it('parses a well-formed notice control frame', () => {
+ const raw = `${stx}${JSON.stringify({ t: 'notice', code: 'controller_locked', text: 'hi' })}`;
+ expect(parseStreamControlFrame(raw)).toEqual({
+ t: 'notice',
+ code: 'controller_locked',
+ text: 'hi',
+ });
+ });
+});
+
+describe('normalizeNoticeFrame', () => {
+ it('normalizes a complete notice frame', () => {
+ expect(
+ normalizeNoticeFrame({
+ t: 'notice',
+ code: 'controller_locked',
+ level: 'warning',
+ title: 'New wallet not registered',
+ text: 'A new wallet is trying to connect.',
+ }),
+ ).toEqual({
+ code: 'controller_locked',
+ level: 'warning',
+ title: 'New wallet not registered',
+ text: 'A new wallet is trying to connect.',
+ });
+ });
+
+ it('defaults level to warning and code to notice when omitted', () => {
+ expect(normalizeNoticeFrame({ t: 'notice', text: 'heads up' })).toEqual({
+ code: 'notice',
+ level: 'warning',
+ text: 'heads up',
+ });
+ });
+
+ it('coerces an unknown level to warning', () => {
+ const result = normalizeNoticeFrame({ t: 'notice', level: 'critical', text: 'x' });
+ expect(result?.level).toBe('warning');
+ });
+
+ it('trims the text', () => {
+ expect(normalizeNoticeFrame({ t: 'notice', text: ' spaced ' })?.text).toBe('spaced');
+ });
+
+ it('returns null when text is missing or blank', () => {
+ expect(normalizeNoticeFrame({ t: 'notice' })).toBeNull();
+ expect(normalizeNoticeFrame({ t: 'notice', text: ' ' })).toBeNull();
+ });
+
+ it('returns null for a non-notice frame or non-object input', () => {
+ expect(normalizeNoticeFrame({ t: 'finalize', text: 'x' })).toBeNull();
+ expect(normalizeNoticeFrame(null)).toBeNull();
+ expect(normalizeNoticeFrame('notice')).toBeNull();
+ });
+
+ it('omits an empty title', () => {
+ expect(normalizeNoticeFrame({ t: 'notice', title: '', text: 'x' })).toEqual({
+ code: 'notice',
+ level: 'warning',
+ text: 'x',
+ });
+ });
+});
+
+describe('selectConnectionNoticeForRequest', () => {
+ const notice: ConnectionNotice = {
+ code: 'controller_locked',
+ level: 'warning',
+ text: 'A new wallet is trying to connect.',
+ };
+
+ it('returns the notice when it belongs to the current connection', () => {
+ expect(selectConnectionNoticeForRequest({ notice, requestId: 'req-1' }, 'req-1')).toBe(notice);
+ });
+
+ it('hides the notice when the connection (requestId) differs', () => {
+ // Switching to a new connection (a new registration or a previously-paired
+ // wallet) must not inherit the previous connection's banner.
+ expect(selectConnectionNoticeForRequest({ notice, requestId: 'req-1' }, 'req-2')).toBeNull();
+ });
+
+ it('returns null when there is no stored notice', () => {
+ expect(selectConnectionNoticeForRequest(null, 'req-1')).toBeNull();
+ });
+});
+
+describe('isRegistrationBlockingNotice', () => {
+ it('is true for the locked-out foreign-wallet notice', () => {
+ expect(isRegistrationBlockingNotice('controller_locked')).toBe(true);
+ });
+
+ it('is true for the missing-identity notice', () => {
+ expect(isRegistrationBlockingNotice('identity_missing')).toBe(true);
+ });
+
+ it('is false for an ordinary notice code', () => {
+ expect(isRegistrationBlockingNotice('notice')).toBe(false);
+ });
+
+ it('is false for undefined/null', () => {
+ expect(isRegistrationBlockingNotice(undefined)).toBe(false);
+ expect(isRegistrationBlockingNotice(null)).toBe(false);
+ });
+});
diff --git a/__tests__/lib/ac2/transport.test.ts b/__tests__/lib/ac2/transport.test.ts
index 5318647..fbb795e 100644
--- a/__tests__/lib/ac2/transport.test.ts
+++ b/__tests__/lib/ac2/transport.test.ts
@@ -1,4 +1,5 @@
import {
+ attachSignalingDiagnostics,
installSignalCandidateNormalizer,
normalizeIceCandidateForReactNative,
waitForChannelOpen,
@@ -81,6 +82,74 @@ describe('installSignalCandidateNormalizer', () => {
});
});
+describe('attachSignalingDiagnostics', () => {
+ // A minimal EventEmitter-shaped SignalClient stand-in.
+ function createFakeSignalClient() {
+ const listeners = new Map void>>();
+ return {
+ on(event: string, listener: (...args: any[]) => void) {
+ (listeners.get(event) ?? listeners.set(event, new Set()).get(event)!).add(listener);
+ },
+ off(event: string, listener: (...args: any[]) => void) {
+ listeners.get(event)?.delete(listener);
+ },
+ emit(event: string, ...args: any[]) {
+ listeners.get(event)?.forEach((listener) => listener(...args));
+ },
+ listenerCount(event: string) {
+ return listeners.get(event)?.size ?? 0;
+ },
+ };
+ }
+
+ it('logs a start line and each signaling milestone', () => {
+ const client = createFakeSignalClient();
+ const logs: string[] = [];
+
+ attachSignalingDiagnostics(client as any, 'req-12345678', (m) => logs.push(m));
+
+ expect(logs.some((m) => m.includes("peer('answer') started"))).toBe(true);
+
+ client.emit('offer-description', 'sdp-offer');
+ client.emit('answer-description', { type: 'answer', sdp: 'sdp-answer' });
+
+ expect(logs.some((m) => m.includes('offer-description') && m.includes('SENT'))).toBe(true);
+ expect(logs.some((m) => m.includes('answer-description') && m.includes('RECEIVED'))).toBe(true);
+ });
+
+ it('logs only the first candidate of each kind and reports totals on dispose', () => {
+ const client = createFakeSignalClient();
+ const logs: string[] = [];
+
+ const dispose = attachSignalingDiagnostics(client as any, 'req-1', (m) => logs.push(m));
+
+ client.emit('offer-candidate', {});
+ client.emit('offer-candidate', {});
+ client.emit('offer-candidate', {});
+
+ const firstLines = logs.filter((m) => m.includes('offer-candidate'));
+ expect(firstLines).toHaveLength(1);
+ expect(firstLines[0]).toContain('(first)');
+
+ dispose();
+ expect(logs.some((m) => m.includes('diagnostics detached') && m.includes('offer-candidate=3'))).toBe(
+ true,
+ );
+ });
+
+ it('detaches every listener on dispose so a reused client does not accumulate handlers', () => {
+ const client = createFakeSignalClient();
+
+ const dispose = attachSignalingDiagnostics(client as any, 'req-1', () => {});
+ expect(client.listenerCount('answer-description')).toBe(1);
+
+ dispose();
+ expect(client.listenerCount('answer-description')).toBe(0);
+ expect(client.listenerCount('offer-description')).toBe(0);
+ expect(client.listenerCount('data-channel')).toBe(0);
+ });
+});
+
describe('waitForChannelOpen', () => {
type FakeChannel = {
readyState: string;
diff --git a/__tests__/lib/liquid-auth/flow.test.ts b/__tests__/lib/liquid-auth/flow.test.ts
new file mode 100644
index 0000000..18c2376
--- /dev/null
+++ b/__tests__/lib/liquid-auth/flow.test.ts
@@ -0,0 +1,58 @@
+// `flow.ts` pulls in native/keystore modules at load time, so they are mocked
+// here to keep the pure helper under test free of the native bridge.
+jest.mock('@/lib/keystore/auth-options', () => ({ biometricOptions: {} }));
+jest.mock('@/lib/keystore/bootstrap', () => ({ bootstrap: jest.fn() }));
+jest.mock('@/lib/keystore/wallet-account', () => ({ isWalletAccountKey: jest.fn() }));
+jest.mock('@/lib/liquid-auth/helpers', () => ({
+ credentialIdCandidates: jest.fn(() => new Set()),
+ credentialIdsFromSessionData: jest.fn(() => new Set()),
+ keyMatchesCredential: jest.fn(),
+ normalizeCredentialId: (value: string) => value,
+ originMatches: jest.fn(),
+ passkeyFromKey: jest.fn(),
+ passkeyMatchesConnection: jest.fn(),
+ passkeysFromSessionUser: jest.fn(() => []),
+ persistKeyMetadata: jest.fn(),
+}));
+jest.mock('@/stores/keystore', () => ({ keyStore: { state: { keys: [] } } }));
+jest.mock('@/stores/sessions', () => ({ updateSessionPasskeyCredentialId: jest.fn() }));
+jest.mock('@/utils/algorand', () => ({ decodeAddress: jest.fn() }));
+jest.mock('@algorandfoundation/keystore', () => ({ encodeAddress: jest.fn() }));
+jest.mock('@algorandfoundation/liquid-client', () => ({
+ assertion: { encoder: {} },
+ encoding: {},
+}));
+jest.mock('@algorandfoundation/react-native-keystore', () => ({
+ fetchSecret: jest.fn(),
+ readMasterKey: jest.fn(),
+}));
+jest.mock('@algorandfoundation/react-native-passkey-autofill', () => ({
+ __esModule: true,
+ default: { getStoredCredentials: jest.fn(() => Promise.resolve([])) },
+}));
+
+import { isRecoverableAssertionFailure } from '@/lib/liquid-auth/flow';
+
+describe('isRecoverableAssertionFailure', () => {
+ it('treats a native "cannot be validated" failure as recoverable', () => {
+ expect(
+ isRecoverableAssertionFailure({
+ error: 'Native error',
+ message: 'Error: The incoming request cannot be validated',
+ }),
+ ).toBe(true);
+ });
+
+ it('treats missing/unknown-shape errors as recoverable', () => {
+ expect(isRecoverableAssertionFailure(new Error('boom'))).toBe(true);
+ expect(isRecoverableAssertionFailure(null)).toBe(true);
+ expect(isRecoverableAssertionFailure(undefined)).toBe(true);
+ expect(isRecoverableAssertionFailure({})).toBe(true);
+ });
+
+ it('does not recover from user-driven aborts', () => {
+ expect(isRecoverableAssertionFailure({ error: 'UserCancelled' })).toBe(false);
+ expect(isRecoverableAssertionFailure({ error: 'TimedOut' })).toBe(false);
+ expect(isRecoverableAssertionFailure({ error: 'Interrupted' })).toBe(false);
+ });
+});
diff --git a/__tests__/lib/liquid-auth/helpers.test.ts b/__tests__/lib/liquid-auth/helpers.test.ts
new file mode 100644
index 0000000..0a2815f
--- /dev/null
+++ b/__tests__/lib/liquid-auth/helpers.test.ts
@@ -0,0 +1,88 @@
+// The presence-driven reconnect gate: these pure helpers decide whether an
+// existing `/auth/session` already authenticates the wallet for a requestId so
+// `useConnection` can skip a redundant passkey assertion on reconnect.
+//
+// `helpers.ts` imports native/keystore modules at load time, so they are mocked
+// here to keep the unit under test free of the native bridge.
+jest.mock('@algorandfoundation/react-native-keystore', () => ({
+ encode: jest.fn(),
+ encryptData: jest.fn(),
+ storage: { set: jest.fn() },
+}));
+jest.mock('@/stores/keystore', () => ({
+ keyStore: { state: { keys: [] }, setState: jest.fn() },
+}));
+jest.mock('@/utils/base64', () => ({
+ toUrlSafe: (value: string) => value,
+}));
+jest.mock('@algorandfoundation/keystore', () => ({
+ encodeAddress: (publicKey: Uint8Array) => `ADDR(${Array.from(publicKey).join(',')})`,
+}));
+jest.mock('@algorandfoundation/liquid-client', () => ({
+ encoding: {
+ fromBase64Url: (value: string) => new Uint8Array(Buffer.from(value, 'utf8')),
+ toBase64URL: (value: Uint8Array) => Buffer.from(value).toString('base64'),
+ },
+}));
+
+const MATCHING_ADDRESS = 'MATCHING_ADDRESS';
+const OTHER_ADDRESS = 'OTHER_ADDRESS';
+const MATCHING_PUBLIC_KEY = new Uint8Array([1, 2, 3, 4]);
+
+jest.mock('@/utils/algorand', () => ({
+ decodeAddress: (address: string) => {
+ if (address === 'MATCHING_ADDRESS') return { publicKey: new Uint8Array([1, 2, 3, 4]) };
+ return { publicKey: new Uint8Array([9, 9, 9, 9]) };
+ },
+}));
+
+import {
+ sessionAlreadyAuthenticatedForRequest,
+ sessionRequestIdFromData,
+} from '@/lib/liquid-auth/helpers';
+import type { Key } from '@algorandfoundation/keystore';
+
+const REQUEST_ID = '019097ff-bb8c-7d5d-9822-7c9eb2c0d419';
+const walletKey = { id: 'key-1', publicKey: MATCHING_PUBLIC_KEY } as unknown as Key;
+
+describe('sessionRequestIdFromData', () => {
+ it('reads the requestId nested under session', () => {
+ expect(sessionRequestIdFromData({ session: { requestId: REQUEST_ID } })).toBe(REQUEST_ID);
+ });
+
+ it('falls back to a top-level requestId', () => {
+ expect(sessionRequestIdFromData({ requestId: REQUEST_ID })).toBe(REQUEST_ID);
+ });
+
+ it('returns null when no requestId is present', () => {
+ expect(sessionRequestIdFromData({ session: {} })).toBeNull();
+ expect(sessionRequestIdFromData(null)).toBeNull();
+ });
+});
+
+describe('sessionAlreadyAuthenticatedForRequest', () => {
+ it('is true when the wallet key and requestId both match the session', () => {
+ const sessionData = { session: { wallet: MATCHING_ADDRESS, requestId: REQUEST_ID } };
+ expect(sessionAlreadyAuthenticatedForRequest(sessionData, walletKey, REQUEST_ID)).toBe(true);
+ });
+
+ it('is false when the session is bound to a different requestId', () => {
+ const sessionData = { session: { wallet: MATCHING_ADDRESS, requestId: 'other-request' } };
+ expect(sessionAlreadyAuthenticatedForRequest(sessionData, walletKey, REQUEST_ID)).toBe(false);
+ });
+
+ it('is false when the session wallet does not match the active key', () => {
+ const sessionData = { session: { wallet: OTHER_ADDRESS, requestId: REQUEST_ID } };
+ expect(sessionAlreadyAuthenticatedForRequest(sessionData, walletKey, REQUEST_ID)).toBe(false);
+ });
+
+ it('is false when the session has no wallet', () => {
+ const sessionData = { session: { requestId: REQUEST_ID } };
+ expect(sessionAlreadyAuthenticatedForRequest(sessionData, walletKey, REQUEST_ID)).toBe(false);
+ });
+
+ it('is false for an empty requestId', () => {
+ const sessionData = { session: { wallet: MATCHING_ADDRESS, requestId: '' } };
+ expect(sessionAlreadyAuthenticatedForRequest(sessionData, walletKey, '')).toBe(false);
+ });
+});
diff --git a/app.config.js b/app.config.js
index 6e303a9..63d20b4 100644
--- a/app.config.js
+++ b/app.config.js
@@ -143,6 +143,10 @@ module.exports = {
cameraPermission: cameraUsageDescription,
},
],
+ // Links expo-notifications so the app can request the POST_NOTIFICATIONS
+ // runtime permission (Android 13+) needed for the native Liquid Auth
+ // foreground-service notifications (ongoing banner + per-message alerts).
+ 'expo-notifications',
[
'expo-build-properties',
{
diff --git a/components/chat/ChatScreen.tsx b/components/chat/ChatScreen.tsx
index 5d69a29..4f7e76e 100644
--- a/components/chat/ChatScreen.tsx
+++ b/components/chat/ChatScreen.tsx
@@ -1,6 +1,7 @@
import { Modal } from '@/components/Modal';
import { ChatComposer } from '@/components/chat/ChatComposer';
import { ChatTimeline, type TimelineEntry } from '@/components/chat/ChatTimeline';
+import { ConnectionNoticeBanner } from '@/components/chat/ConnectionNoticeBanner';
import { ConnectionStatusBar } from '@/components/chat/ConnectionStatusBar';
import { ReconnectBar } from '@/components/chat/ReconnectBar';
import { ThreadBar } from '@/components/chat/ThreadBar';
@@ -47,8 +48,9 @@ function ChatScreen({ origin, requestId, allowPasskeyCreation = false }: ChatScr
isError,
isLoading,
isReconnecting,
+ peerOffline,
+ isSocketConnected,
reconnectAttempt,
- maxReconnectAttempts,
send,
sendAc2,
lastHeartbeat,
@@ -63,6 +65,9 @@ function ChatScreen({ origin, requestId, allowPasskeyCreation = false }: ChatScr
openConversation,
closeConversation,
remoteThreads,
+ connectionNotice,
+ dismissConnectionNotice,
+ isRegistered,
} = useConnection(origin, requestId, { allowPasskeyCreation });
const { approveSigning, rejectSigning, approveKey, rejectKey } = useAc2Responders({
@@ -232,27 +237,54 @@ function ChatScreen({ origin, requestId, allowPasskeyCreation = false }: ChatScr
]);
}, [origin, requestId, address]);
+ // Permanently remove this connection: messages, agent identities, session
+ // data, and tear down the transport. Shared by the manual "Forget" action and
+ // the "not registered" prompt below.
+ const deleteConnection = React.useCallback(() => {
+ clearMessagesByConnection(origin, requestId);
+ clearAc2MessagesByConnection(origin, requestId);
+ clearAgentIdentitiesByConnection(origin, requestId);
+ removeSession(requestId, origin);
+ clearCurrentConnection();
+ reset();
+ }, [origin, requestId, reset]);
+
const handleForget = React.useCallback(() => {
Alert.alert(
'Forget connection?',
'This permanently removes all messages, agent identities, and session data for this connection.',
[
{ text: 'Cancel', style: 'cancel' },
- {
- text: 'Forget',
- style: 'destructive',
- onPress: () => {
- clearMessagesByConnection(origin, requestId);
- clearAc2MessagesByConnection(origin, requestId);
- clearAgentIdentitiesByConnection(origin, requestId);
- removeSession(requestId, origin);
- clearCurrentConnection();
- reset();
- },
- },
+ { text: 'Forget', style: 'destructive', onPress: deleteConnection },
],
);
- }, [origin, requestId, reset]);
+ }, [deleteConnection]);
+
+ // When the connection isn't paired properly (a foreign wallet was locked out,
+ // or no identity was granted), the agent will only ever re-pair under a NEW
+ // `requestId` — this connection is dead. Prompt the user (once per
+ // occurrence) to delete this thread so it doesn't linger unusable. The
+ // composer is already disabled and sending is hard-blocked in `useConnection`.
+ const promptedDeleteRef = React.useRef(null);
+ React.useEffect(() => {
+ if (isConnected && !isRegistered) {
+ if (promptedDeleteRef.current === requestId) return;
+ promptedDeleteRef.current = requestId;
+ Alert.alert(
+ 'Connection not registered',
+ 'This wallet isn\u2019t paired with the agent, so it can\u2019t send messages. ' +
+ 'Re-pairing creates a new connection, so this one can be deleted.',
+ [
+ { text: 'Keep', style: 'cancel' },
+ { text: 'Delete', style: 'destructive', onPress: deleteConnection },
+ ],
+ );
+ } else if (isRegistered) {
+ // Registration recovered (or a different connection is on screen): allow
+ // the prompt to fire again if this connection later becomes unregistered.
+ if (promptedDeleteRef.current === requestId) promptedDeleteRef.current = null;
+ }
+ }, [isConnected, isRegistered, requestId, deleteConnection]);
return (
+ {connectionNotice ? (
+
+ ) : null}
- {isConnected ? (
+ {isConnected && !isRegistered ? (
+ // Connected but the wallet is not registered with the agent (a foreign
+ // wallet was locked out, or no identity has been granted yet): block
+ // new messages until registration completes.
+
+ ) : isConnected ? (
) : isReconnecting ? (
0 ? `Reconnecting (attempt ${reconnectAttempt})…` : 'Reconnecting…'
+ }
/>
) : isLoading ? (
) : (
-
+
)}
;
}
+ if (m.kind === 'task') {
+ return ;
+ }
return ;
}
diff --git a/components/chat/ConnectionNoticeBanner.tsx b/components/chat/ConnectionNoticeBanner.tsx
new file mode 100644
index 0000000..a024126
--- /dev/null
+++ b/components/chat/ConnectionNoticeBanner.tsx
@@ -0,0 +1,80 @@
+import { Text } from '@/components/ui/text';
+import type { ConnectionNotice, NoticeLevel } from '@/lib/ac2';
+import { THEME } from '@/lib/theme';
+import { MaterialIcons } from '@expo/vector-icons';
+import { useColorScheme } from 'nativewind';
+import * as React from 'react';
+import { Pressable, View } from 'react-native';
+
+interface ConnectionNoticeBannerProps {
+ notice: ConnectionNotice;
+ onDismiss: () => void;
+}
+
+const ICON_BY_LEVEL: Record = {
+ info: 'info',
+ warning: 'warning',
+ error: 'error',
+};
+
+// Severity accents. The theme only exposes `primary`, so `warning`/`error` use
+// dedicated amber/red hexes (kept local since there is no matching theme token).
+const WARNING_ACCENT = '#D97706';
+const ERROR_ACCENT = '#DC2626';
+
+// Accent (border + icon) colour per severity.
+function accentColor(level: NoticeLevel, palette: { primary: string }): string {
+ switch (level) {
+ case 'error':
+ return ERROR_ACCENT;
+ case 'info':
+ return palette.primary;
+ case 'warning':
+ default:
+ return WARNING_ACCENT;
+ }
+}
+
+/**
+ * Prominent, dismissible banner for an out-of-band agent advisory (a `notice`
+ * control frame) — e.g. the warning that a *different* wallet is connecting to
+ * an already-registered agent and cannot take it over. Rendered at the top of
+ * the chat surface, above the timeline, so it is seen immediately and is never
+ * mistaken for a chat message.
+ */
+function ConnectionNoticeBanner({ notice, onDismiss }: ConnectionNoticeBannerProps) {
+ const { colorScheme } = useColorScheme();
+ const palette = colorScheme === 'dark' ? THEME.dark : THEME.light;
+ const accent = accentColor(notice.level, palette);
+ return (
+
+
+
+ {notice.title ? (
+ {notice.title}
+ ) : null}
+ {notice.text}
+
+
+
+
+
+ );
+}
+
+export { ConnectionNoticeBanner };
diff --git a/components/chat/ReconnectBar.tsx b/components/chat/ReconnectBar.tsx
index db8c71f..83bc3f3 100644
--- a/components/chat/ReconnectBar.tsx
+++ b/components/chat/ReconnectBar.tsx
@@ -9,23 +9,41 @@ interface ReconnectBarProps {
onReconnect: () => void;
/** When true the previous attempt failed; copy reflects an error vs. a drop. */
isError?: boolean;
+ /**
+ * When true the peer isn't present in the requestId room, so we couldn't
+ * connect. Shows a clean, actionable notice asking the user to check their
+ * remote device instead of a generic "connection failed".
+ */
+ peerOffline?: boolean;
+ /**
+ * When true the signaling socket itself is disconnected from the Liquid Auth
+ * service, so nothing (presence checks, messaging, negotiation) can happen
+ * until it's back. Shows "Service unavailable" — takes priority over the
+ * peer/error/disconnected copy.
+ */
+ serviceUnavailable?: boolean;
}
// Footer shown in place of the composer when the transport has dropped. Mirrors
// the composer's bar styling so the chat surface keeps a consistent footprint,
// and gives the user an explicit affordance to re-establish the connection.
-function ReconnectBar({ onReconnect, isError }: ReconnectBarProps) {
+function ReconnectBar({ onReconnect, isError, peerOffline, serviceUnavailable }: ReconnectBarProps) {
const { colorScheme } = useColorScheme();
const palette = colorScheme === 'dark' ? THEME.dark : THEME.light;
+ const message = serviceUnavailable
+ ? 'Service unavailable. Not connected to the signaling service — tap Reconnect to try again.'
+ : peerOffline
+ ? "Can't reach the chat. Check your remote device is online, then tap Reconnect."
+ : isError
+ ? 'Connection failed.'
+ : 'Disconnected.';
return (
-
- {isError ? 'Connection failed.' : 'Disconnected.'}
-
+ {message}
0;
+
+ let statusIcon: React.ReactNode;
+ let statusColor: string;
+ let statusLabel: string;
+
+ switch (status) {
+ case 'completed':
+ statusIcon = ;
+ statusColor = colorScheme === 'dark' ? 'text-green-400' : 'text-green-600';
+ statusLabel = 'Done';
+ break;
+ case 'failed':
+ statusIcon = ;
+ statusColor = colorScheme === 'dark' ? 'text-red-400' : 'text-red-600';
+ statusLabel = 'Failed';
+ break;
+ case 'stopped':
+ statusIcon = ;
+ statusColor = 'text-muted-foreground';
+ statusLabel = 'Stopped';
+ break;
+ case 'running':
+ default:
+ statusIcon = (
+
+ );
+ statusColor = 'text-primary';
+ statusLabel = 'Running';
+ break;
+ }
+
+ return (
+
+ setExpanded((v) => !v)}
+ disabled={!hasPrompt}
+ accessibilityRole="button"
+ accessibilityLabel={expanded ? 'Collapse task details' : 'Expand task details'}
+ >
+ {statusIcon}
+
+ {title}
+
+ {statusLabel}
+
+ {formatTime(message.timestamp)}
+
+ {hasPrompt && (
+
+ )}
+
+
+ {expanded && hasPrompt && (
+
+
+ Prompt
+
+ {prompt}
+
+ )}
+
+
+ {result ? (
+
+ {result}
+
+ ) : (
+
+ Working in the background…
+
+ )}
+
+
+ );
+}
+
+export { TaskCard };
diff --git a/hooks/useConnection.ts b/hooks/useConnection.ts
index a197d88..6ce67fa 100644
--- a/hooks/useConnection.ts
+++ b/hooks/useConnection.ts
@@ -1,22 +1,59 @@
import { useProvider } from '@/hooks/useProvider';
-import type { HeartbeatMonitor, MonitoredPeerConnection } from '@/lib/ac2';
+import type {
+ ConnectionNotice,
+ HeartbeatMonitor,
+ MonitoredPeerConnection,
+ PresenceResult,
+ ScopedConnectionNotice,
+} from '@/lib/ac2';
import {
+ addNativePresenceListener,
+ addNativeSignalingStateListener,
attachHeartbeatChannel,
+ cancelNativeNegotiation,
createAc2Client,
- createAc2Transport,
createHeartbeatMonitor,
+ createNativeAc2Transport,
DEFAULT_THID,
- describeSelectedCandidatePair,
+ evaluateIdleSession,
+ flushNativeQueue,
generateThid,
+ getNativeConnectionState,
+ isPeerOffline,
+ isPeerRejectedError,
+ isPeerUnreachableError,
+ isRegistrationBlockingNotice,
+ isSnapshotChannelOpen,
monitorPeerConnection,
+ nativeAuthFetch,
+ selectConnectionNoticeForRequest,
sendConversationClose,
sendConversationOpen,
- summarizeSelectedCandidatePair,
+ setNativeActive,
+ startNativeService,
+ stopNativeService,
} from '@/lib/ac2';
+import type {
+ ConnectionEffect,
+ ConnectionEvent,
+ ConnectionState,
+ NegotiationMode,
+} from '@/lib/ac2/connectionMachine';
+import {
+ createInitialState,
+ describeState,
+ deriveUiState,
+ transition,
+} from '@/lib/ac2/connectionMachine';
import { createControlFrameHandler } from '@/lib/ac2/streamControlFrame';
import { findWalletAccount } from '@/lib/keystore/wallet-account';
import { authenticateLiquidAuth } from '@/lib/liquid-auth/flow';
-import { addressMatchesKey, sessionAddressFromData } from '@/lib/liquid-auth/helpers';
+import {
+ addressMatchesKey,
+ sessionAddressFromData,
+ sessionAlreadyAuthenticatedForRequest,
+} from '@/lib/liquid-auth/helpers';
+import { ensureNotificationPermission } from '@/lib/notifications';
import { addAc2Message, clearAc2MessagesByThread } from '@/stores/ac2Messages';
import { accountsStore } from '@/stores/accounts';
import { keyStore } from '@/stores/keystore';
@@ -31,16 +68,10 @@ import {
import { Ac2Client } from '@algorandfoundation/ac2-sdk';
import type { AC2BaseMessage as Ac2Message } from '@algorandfoundation/ac2-sdk/schema';
import { encodeAddress } from '@algorandfoundation/keystore';
-import { SignalClient } from '@algorandfoundation/liquid-client';
import { useStore } from '@tanstack/react-store';
-import { XHR as EngineIoXHR } from 'engine.io-client';
import { useCallback, useEffect, useRef, useState } from 'react';
-import { Alert, AppState, type AppStateStatus, NativeModules, Platform } from 'react-native';
+import { Alert, AppState, type AppStateStatus } from 'react-native';
-const AUTO_RECONNECT_DELAY_MS = 3000;
-// Bounded auto-reconnect budget. After this many failed automatic attempts we
-// stop retrying and fall back to the manual "Reconnect" button.
-const MAX_RECONNECT_ATTEMPTS = 3;
// Hard ceiling on any auth/session HTTP request during setup. React Native's
// `fetch` has NO default timeout, so a request issued while the network is
// still recovering from a drop (exactly when auto-reconnect fires) can stall
@@ -63,11 +94,6 @@ const HEARTBEAT_BUFFERED_WARN_BYTES = 256 * 1024;
const IDLE_SESSION_TIMEOUT_MS = 60000;
const IDLE_CHECK_INTERVAL_MS = 5000;
-// Why a connection was torn down unexpectedly. Routed through `failConnection`
-// so every detector (channel close, setup error, and — added in later phases —
-// ICE state, heartbeat watchdog, send failures) funnels into one recovery path.
-type ConnectionFailureReason = 'channel' | 'setup' | 'ice' | 'heartbeat' | 'send' | 'open';
-
interface UseConnectionResult {
session: Session | undefined;
address: string | null;
@@ -82,16 +108,38 @@ interface UseConnectionResult {
agentPresence: 'thinking' | 'tool' | 'typing' | null;
/** Optional detail for the current presence (e.g. tool name). */
agentPresenceDetail: string | null;
+ /**
+ * Signaling-server peer presence for this `requestId` (how many devices are
+ * connected). Populated from the socket's `presence` broadcasts; `null` until
+ * the first update. Distinct from `agentPresence`, which is the agent's
+ * ephemeral activity over the stream channel.
+ */
+ peerPresence: PresenceResult | null;
+ /**
+ * True when a (re)connect is parked because the peer isn't present in the
+ * `requestId` room. The chat surface shows a clean inline notice ("check your
+ * remote device") instead of a disruptive pop-up alert.
+ */
+ peerOffline: boolean;
+ /**
+ * True while the signaling socket itself is connected to the Liquid Auth
+ * service. This is independent of the p2p chat transport: the socket is kept
+ * alive across chat drops so presence checks and future renegotiation keep
+ * working. When false the chat surface shows "Service unavailable".
+ */
+ isSocketConnected: boolean;
error: Error | null;
isError: boolean;
isLoading: boolean;
isConnected: boolean;
- /** True while bounded automatic reconnect attempts are in flight. */
+ /** True while automatic reconnect attempts (with backoff) are in flight. */
isReconnecting: boolean;
- /** Current automatic reconnect attempt (1-based); `0` when not retrying. */
+ /**
+ * Consecutive automatic reconnect attempt count (1-based); `0` when not
+ * retrying. Retries are unlimited while foregrounded (exponential backoff),
+ * so there is no fixed maximum any more.
+ */
reconnectAttempt: number;
- /** Total automatic reconnect attempts before falling back to manual. */
- maxReconnectAttempts: number;
lastHeartbeat: number;
reset: () => void;
/** Tear down any stale transport and re-run the connection/auth flow. */
@@ -104,6 +152,21 @@ interface UseConnectionResult {
closeConversation: (thid: string) => void;
/** Threads the agent advertised on connect (`conversations` control frame). */
remoteThreads: { thid: string; title?: string; updatedAt?: number }[];
+ /**
+ * Out-of-band advisory the agent pushed (e.g. a warning that a *different*
+ * wallet is connecting to an already-registered agent). `null` when none.
+ */
+ connectionNotice: ConnectionNotice | null;
+ /** Dismiss the current `connectionNotice` banner. */
+ dismissConnectionNotice: () => void;
+ /**
+ * Whether the wallet is registered with the agent for the current connection.
+ * `false` once the agent pushes a registration-blocking notice (a foreign
+ * wallet locked out, or no identity granted yet); the chat composer is
+ * disabled while this is `false` so no new messages can be sent. Unlike the
+ * dismissible `connectionNotice` banner, this is not cleared by dismissing it.
+ */
+ isRegistered: boolean;
}
interface UseConnectionOptions {
@@ -115,6 +178,24 @@ interface UseConnectionOptions {
allowPasskeyCreation?: boolean;
}
+/**
+ * Connection lifecycle owner. All control flow (connect, retry, backoff,
+ * suspend/resume recovery, idle policy) is decided by the pure reducer in
+ * `lib/ac2/connectionMachine`; this hook is its I/O shell:
+ *
+ * - **Event sources** feed the machine: native presence/signaling listeners
+ * (`PEER_PRESENT`/`PEER_ABSENT`, `SOCKET_UP`/`SOCKET_DOWN`), the AppState
+ * listener (`APP_FOREGROUND`/`APP_BACKGROUND`), the liveness detectors —
+ * heartbeat watchdog, ICE monitor, channel close, send failures — (all
+ * `CONNECTION_LOST`), the idle timer (`SESSION_IDLE`), and the user
+ * (`START`/`STOP`/`USER_RECONNECT`).
+ * - **Effect handlers** interpret what the machine returns: `startService`
+ * (auth + native service bring-up), `negotiate` (p2p transport), timers
+ * (`armDeadline`/`scheduleRetry`/`cancelTimers`), `teardown`, and
+ * `queryNativeState` (snapshot reconcile after a resume).
+ * - **UI flags** (`isConnected`/`isLoading`/`isReconnecting`/…) are derived
+ * from the machine state via `deriveUiState` — one place, no boolean soup.
+ */
export function useConnection(
origin: string,
requestId: string,
@@ -123,7 +204,52 @@ export function useConnection(
const { accounts, keys, key, passkey } = useProvider();
const allowPasskeyCreation = options.allowPasskeyCreation ?? false;
- const [isConnected, setIsConnected] = useState(false);
+ // ---------------------------------------------------------------------
+ // State machine core: current state lives in a ref (so event sources and
+ // effect handlers read it synchronously); a state mirror re-renders the UI.
+ // Events are processed through a queue so an effect that dispatches
+ // synchronously (e.g. `queryNativeState` → `NATIVE_SNAPSHOT`) is handled
+ // after the current transition's effects, never re-entrantly.
+ // ---------------------------------------------------------------------
+ const machineRef = useRef(
+ createInitialState({ foreground: AppState.currentState === 'active' }),
+ );
+ const [machineState, setMachineState] = useState(machineRef.current);
+ const runEffectRef = useRef<(effect: ConnectionEffect) => void>(() => {});
+ const eventQueueRef = useRef([]);
+ const dispatchingRef = useRef(false);
+
+ const dispatch = useCallback((event: ConnectionEvent) => {
+ eventQueueRef.current.push(event);
+ if (dispatchingRef.current) return;
+ dispatchingRef.current = true;
+ try {
+ let next: ConnectionEvent | undefined;
+ while ((next = eventQueueRef.current.shift())) {
+ const result = transition(machineRef.current, next);
+ machineRef.current = result.state;
+ console.log(`[ac2] machine: ${next.type} -> ${describeState(result.state)}`);
+ setMachineState(result.state);
+ for (const effect of result.effects) {
+ runEffectRef.current(effect);
+ }
+ }
+ } finally {
+ dispatchingRef.current = false;
+ }
+ }, []);
+ const dispatchRef = useRef(dispatch);
+ dispatchRef.current = dispatch;
+
+ // Machine-owned timers (armed/cancelled via effects).
+ const deadlineTimerRef = useRef | null>(null);
+ const retryTimerRef = useRef | null>(null);
+ // Abort controller for the CURRENT negotiation attempt; `teardown` aborts it
+ // so a superseded attempt's in-flight native work is cancelled.
+ const negotiationAbortRef = useRef(null);
+ // Abort controller for the current service bring-up (auth HTTP requests).
+ const serviceAbortRef = useRef(null);
+
const [address, setAddress] = useState(null);
const addressRef = useRef(null);
@@ -132,18 +258,9 @@ export function useConnection(
}, [address]);
const [lastHeartbeat, setLastHeartbeat] = useState(Date.now());
- const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
- // Auto-reconnect progress surfaced to the UI so it can show a "Reconnecting
- // (n/max)…" state while bounded retries are in flight, and only fall back to
- // the manual Reconnect button once the budget is exhausted.
- const [isReconnecting, setIsReconnecting] = useState(false);
- const [reconnectAttempt, setReconnectAttempt] = useState(0);
- // Ref mirror of the attempt counter so the retry scheduler can read/increment
- // it synchronously without racing React state batching.
- const reconnectAttemptRef = useRef(0);
- // Bumped by `reconnect()` to re-trigger the connection effect on demand.
- const [reconnectNonce, setReconnectNonce] = useState(0);
+ // Mirrors `userStoppedRef` for rendering (initial-loading derivation below).
+ const [userStopped, setUserStopped] = useState(false);
const dataChannelRef = useRef(null);
const streamChannelRef = useRef(null);
@@ -154,30 +271,36 @@ export function useConnection(
// data channel opens and tear it down in `clearTransport`.
const peerConnectionRef = useRef(null);
const peerMonitorDisposeRef = useRef<(() => void) | null>(null);
- // Heartbeat liveness watchdog (ping/pong over `ac2-heartbeat`). Started in
- // `onOpen`, stopped in `clearTransport` / effect cleanup.
+ // Heartbeat liveness watchdog (ping/pong over `ac2-heartbeat`). Started once
+ // the channel opens, stopped in `clearTransport` / effect cleanup.
const heartbeatMonitorRef = useRef(null);
- const clientRef = useRef(null);
+ // True once the native foreground signaling service has been started (the
+ // analog of the persistent `SignalClient` socket). Kept alive across p2p chat
+ // drops; only `stopNativeService` (an explicit disconnect / unmount) clears it.
+ const nativeStartedRef = useRef(false);
+ // Detaches the CURRENT negotiation's native listeners (message/state/ICE).
+ // Set once a negotiation's transport is established; cleared on teardown.
+ const transportDisposeRef = useRef<(() => void) | null>(null);
// Last time we observed inbound traffic from the peer (frames, envelopes,
// heartbeat pongs) vs. the last local user action. Kept separate so an
// outbound keepalive can never be mistaken for peer presence.
const lastInboundActivityRef = useRef(Date.now());
const lastLocalActivityRef = useRef(Date.now());
+ // Guards against two concurrent auth flows (and therefore two blocking
+ // biometric prompts). The machine never emits two `startService` effects for
+ // one attempt, but a slow prompt can outlive a `starting` deadline, so the
+ // effect handler itself refuses to stack a second flow.
const authFlowInProgressRef = useRef(false);
- // Ignore one `onClose` when the transport was intentionally torn down.
- const deliberateCloseRef = useRef(false);
// Set when the user explicitly disconnects (`reset()`); blocks the
// foreground auto-reconnect from resurrecting a session they chose to stop.
const userStoppedRef = useRef(false);
- // One-shot delayed reconnect that mirrors pressing the Reconnect button.
- const autoReconnectTimerRef = useRef | null>(null);
// Last observed `AppState`, so the foreground listener only reacts to a real
// background/inactive -> active transition (not active -> active repeats).
const appStateRef = useRef(AppState.currentState);
// True once the app has been backgrounded/inactive and no inbound frame has
// since proven the connection still alive. Lets the inactivity close tell a
- // stale-from-background drop (auto-reconnect when foregrounded) apart from a
- // genuine foreground idle close (stays manual, so we don't churn/re-prompt).
+ // stale-from-background drop (recoverable) apart from a genuine foreground
+ // idle close (manual, so we don't churn/re-prompt).
const wasBackgroundedRef = useRef(false);
// Active conversation thread; the ref mirror lets DataChannel handlers see the live value.
@@ -191,25 +314,83 @@ export function useConnection(
// Ephemeral presence from agent stream-channel control frames.
const [agentPresence, setAgentPresence] = useState<'thinking' | 'tool' | 'typing' | null>(null);
const [agentPresenceDetail, setAgentPresenceDetail] = useState(null);
+ // Signaling-server peer presence for this requestId (how many devices are
+ // connected). Subscribed on the persistent native service; each broadcast is
+ // mapped to a `PEER_PRESENT`/`PEER_ABSENT` machine event.
+ const [peerPresence, setPeerPresence] = useState(null);
+ const peerPresenceRef = useRef(null);
+ peerPresenceRef.current = peerPresence;
+ // Whether the signaling socket itself is connected to the Liquid Auth
+ // service. Owned by the persistent service subscriptions and kept alive
+ // across p2p chat drops. Surfaced as "Service unavailable" in the chat UI
+ // when false; mapped to `SOCKET_UP`/`SOCKET_DOWN` machine events.
+ const [isSocketConnected, setIsSocketConnected] = useState(false);
+ // Disposer for the service-level `presence` subscription (lives with the
+ // service, not the transport).
+ const presenceUnsubRef = useRef<(() => void) | null>(null);
+ // Disposer for the signaling-socket connectivity subscription (lives with
+ // the service, not the transport), driving `isSocketConnected`.
+ const signalingUnsubRef = useRef<(() => void) | null>(null);
+ // Disposer for the transport-level presence listener. With the native path
+ // presence is subscribed on the persistent service, so the per-negotiation
+ // transport's presence disposer is a no-op; this ref is kept only for
+ // symmetry with the service teardown path.
+ const transportPresenceUnsubRef = useRef<(() => void) | null>(null);
// Threads the agent advertised on connect (`conversations` control frame).
const [remoteThreads, setRemoteThreads] = useState<
{ thid: string; title?: string; updatedAt?: number }[]
>([]);
+ // Out-of-band advisory the agent pushed (e.g. the locked/new-wallet warning),
+ // surfaced as a banner in the chat screen. It is bound to the `requestId` it
+ // was raised on: the chat surface is reused across connection switches (this
+ // hook is not remounted per connection), so tagging the notice with its
+ // connection is what keeps a banner from one wallet from bleeding onto
+ // another. `null` when there is nothing to show for the current connection.
+ const [connectionNoticeState, setConnectionNoticeState] = useState(
+ null,
+ );
+ // Only surface the notice for the connection it belongs to. Starting a new
+ // connection (a new registration or a previously-paired wallet reconnecting)
+ // has a different `requestId`, so the banner disappears automatically.
+ const connectionNotice = selectConnectionNoticeForRequest(connectionNoticeState, requestId);
+ const dismissConnectionNotice = useCallback(() => setConnectionNoticeState(null), []);
+ // Whether the wallet is registered with the agent for the connection on
+ // screen. Set to "not registered" (scoped to the `requestId`) when the agent
+ // pushes a registration-blocking notice (a foreign wallet locked out, or no
+ // identity granted yet). Kept SEPARATE from the dismissible banner so
+ // dismissing the notice hides the banner but still blocks new messages. It is
+ // reset at the start of each negotiation so a reconnect that succeeds in
+ // registering re-enables the composer.
+ const [notRegisteredState, setNotRegisteredState] = useState<{ requestId: string } | null>(null);
+ const isRegistered = !(notRegisteredState && notRegisteredState.requestId === requestId);
+ // Mirror `isRegistered` into a ref so the stable `send`/`sendAc2`/conversation
+ // callbacks (which close over refs, not render state) can HARD-BLOCK every
+ // outbound action while the connection is not properly paired. Disabling the
+ // composer alone is only a UI gate — this ref makes the connection truly
+ // inert so nothing can be sent over a connection that wasn't paired properly
+ // (no identity granted, or a controller/identity mismatch that locked it out).
+ const isRegisteredRef = useRef(true);
+ isRegisteredRef.current = isRegistered;
// AC2 SDK client; bound once the `ac2-v1` DataChannel opens.
const [ac2Client, setAc2Client] = useState(null);
const ac2ClientRef = useRef(null);
+ // Current `requestId`, mirrored so stable callbacks and the effect
+ // interpreter can match the background service's live connection without a
+ // stale closure.
+ const requestIdRef = useRef(requestId);
+ requestIdRef.current = requestId;
+
const session = useStore(sessionsStore, (state) =>
state.sessions.find((s) => s.id === requestId && s.origin === origin),
);
// Close and null out every transport ref (AC2 client, data/stream/heartbeat
- // channels, and the signal client). Leaving `clientRef`/`dataChannelRef` set
- // after a drop is what previously wedged the connection effect's guard
- // (`if (clientRef.current || isConnected) return`) and left the UI stuck on
+ // channels, monitors). Leaving stale refs set after a drop is what previously
+ // wedged the old connection effect's guard and left the UI stuck on
// "Connecting…" with no way to recover.
- const clearTransport = useCallback(() => {
+ const clearTransport = useCallback((options?: { preserveNativePeer?: boolean }) => {
// Stop the liveness watchdog and detach the connectivity monitor before
// anything closes the peer, so a deliberate teardown can't be misread as a
// heartbeat timeout or an ICE failure.
@@ -255,188 +436,137 @@ export function useConnection(
}
heartbeatChannelRef.current = null;
}
- if (clientRef.current) {
+ // Tear down ONLY the p2p peer, keeping the persistent native signaling
+ // service (and its presence subscription) alive so the app stays connected
+ // to the service after a chat drop — enabling presence checks and
+ // renegotiation without a fresh auth/passkey. The service itself is owned
+ // by `closeSocket`.
+ //
+ // Detach this negotiation's native listeners (message/state/ICE) so reusing
+ // the service for the next attempt doesn't accumulate duplicate handlers.
+ if (transportDisposeRef.current) {
try {
- // `SignalClient.close()` never tears down the WebRTC peer connection, so
- // close it explicitly. A leaked `RTCPeerConnection` keeps the ICE
- // session to the agent alive, so the agent still treats the old peer as
- // active for this requestId and ignores the fresh offer a reconnect
- // sends — leaving negotiation hung after `setLocalDescription`.
- clientRef.current.peerClient?.close();
+ transportDisposeRef.current();
} catch {
/* noop */
}
+ transportDisposeRef.current = null;
+ }
+ // Ask the native service to cancel the (in-flight or established) peer
+ // negotiation WITHOUT dropping the signaling socket, so the next attempt
+ // reuses the same service. A leaked native peer would keep the ICE session
+ // to the agent alive, so the agent would ignore the fresh offer a reconnect
+ // sends. Best-effort; a fresh negotiation supersedes any lingering peer.
+ //
+ // EXCEPTION — hydration: when re-attaching to a connection the background
+ // service kept alive across a relaunch/foreground (`preserveNativePeer`),
+ // we must NOT cancel it. Cancelling here is exactly what closed the live
+ // peer on relaunch and forced a full renegotiation instead of the cheap
+ // `attach()` hydrate path in `createNativeAc2Transport`.
+ if (nativeStartedRef.current && !options?.preserveNativePeer) {
+ cancelNativeNegotiation().catch(() => {
+ /* best-effort */
+ });
+ }
+ }, []);
+
+ // Fully tear down the persistent signaling service (and its presence
+ // subscription). Only used on an explicit disconnect (`reset`) or when the
+ // hook unmounts / the origin+requestId changes — NOT on a chat drop, so the
+ // service survives p2p reconnects.
+ const closeSocket = useCallback(() => {
+ if (presenceUnsubRef.current) {
+ try {
+ presenceUnsubRef.current();
+ } catch {
+ /* noop */
+ }
+ presenceUnsubRef.current = null;
+ }
+ if (signalingUnsubRef.current) {
+ try {
+ signalingUnsubRef.current();
+ } catch {
+ /* noop */
+ }
+ signalingUnsubRef.current = null;
+ }
+ if (transportPresenceUnsubRef.current) {
try {
- // `close(true)` also disconnects the underlying signaling socket. The
- // default `close()` only detaches listeners and leaves the socket.io
- // connection alive, which would then collide with the socket a
- // subsequent (re)connect opens to the same origin — wedging signaling.
- clientRef.current.close(true);
+ transportPresenceUnsubRef.current();
} catch {
/* noop */
}
- clientRef.current = null;
+ transportPresenceUnsubRef.current = null;
+ }
+ setIsSocketConnected(false);
+ if (nativeStartedRef.current) {
+ nativeStartedRef.current = false;
+ // Fully tears down the native foreground service: disconnects the
+ // signaling socket and the WebRTC peer.
+ stopNativeService().catch(() => {
+ /* best-effort teardown */
+ });
}
}, []);
// Best-effort, read-only diagnostic: log which ICE path the peer selected
// (direct `host` / STUN `srflx` / TURN `relay`). Never logs addresses. Call
// before teardown — it reads the peer synchronously and resolves async.
- const logCandidatePair = useCallback((context: string) => {
- const pc = clientRef.current?.peerClient;
- if (!pc || typeof pc.getStats !== 'function') return;
- pc.getStats()
- .then((report: any) => {
- const summary = summarizeSelectedCandidatePair(report);
- if (summary) {
- console.log(`[ac2] ${context} path: ${describeSelectedCandidatePair(summary)}`);
- }
- })
- .catch(() => {
- /* diagnostics only */
- });
+ const logCandidatePair = useCallback((_context: string) => {
+ // The native background service owns the WebRTC peer, so per-session ICE
+ // candidate-pair stats (`getStats`) are not available to JS. Kept as a
+ // no-op so the established call sites stay in place, ready to re-enable if
+ // the native module later surfaces peer stats.
}, []);
const logCandidatePairRef = useRef(logCandidatePair);
logCandidatePairRef.current = logCandidatePair;
const reset = useCallback(() => {
- deliberateCloseRef.current = true;
userStoppedRef.current = true;
- reconnectAttemptRef.current = 0;
- if (autoReconnectTimerRef.current) {
- clearTimeout(autoReconnectTimerRef.current);
- autoReconnectTimerRef.current = null;
- }
- clearTransport();
+ setUserStopped(true);
+ // `STOP` cancels the machine's timers and tears down the p2p transport.
+ dispatchRef.current({ type: 'STOP' });
+ // An explicit user disconnect also drops the persistent signaling service:
+ // the user is leaving the session, so there is nothing to stay present for.
+ closeSocket();
setActiveStreamText('');
setAgentPresence(null);
setAgentPresenceDetail(null);
- setIsConnected(false);
- setIsLoading(false);
- setIsReconnecting(false);
- setReconnectAttempt(0);
setError(null);
updateSessionStatus(requestId, origin, 'closed');
- }, [requestId, origin, clearTransport]);
-
- // Core reconnect primitive: tear down any stale transport (so the connection
- // effect's guard doesn't short-circuit), flip into the loading/connecting
- // state, and bump the nonce to re-run setup. Deliberately does NOT touch the
- // retry budget so it can back both manual and automatic reconnects.
- const performReconnect = useCallback(() => {
- deliberateCloseRef.current = true;
- if (autoReconnectTimerRef.current) {
- clearTimeout(autoReconnectTimerRef.current);
- autoReconnectTimerRef.current = null;
- }
- clearTransport();
- authFlowInProgressRef.current = false;
- // Reset both liveness clocks so the fresh attempt isn't judged idle.
- lastInboundActivityRef.current = Date.now();
- lastLocalActivityRef.current = Date.now();
- setError(null);
- setIsConnected(false);
- setIsLoading(true);
- setReconnectNonce((n) => n + 1);
- }, [clearTransport]);
- const performReconnectRef = useRef(performReconnect);
- performReconnectRef.current = performReconnect;
+ }, [requestId, origin, closeSocket]);
// Manually re-establish a dropped connection (user pressed "Reconnect").
- // Resets the automatic-retry budget so the user always gets a fresh set of
- // attempts, and clears any exhausted/failed reconnecting state.
+ // From `stopped` this is a full restart (auth + service); from `backoff` /
+ // `failed` the machine starts a fresh attempt with the delay counter reset.
const reconnect = useCallback(() => {
userStoppedRef.current = false;
- reconnectAttemptRef.current = 0;
- setReconnectAttempt(0);
- setIsReconnecting(false);
- performReconnect();
- }, [performReconnect]);
+ setUserStopped(false);
+ setError(null);
+ if (machineRef.current.phase === 'stopped') {
+ dispatchRef.current({ type: 'START' });
+ return;
+ }
+ dispatchRef.current({ type: 'USER_RECONNECT' });
+ // `waiting` deliberately ignores USER_RECONNECT (retrying while gated on
+ // the peer/socket is pointless), but the tap should still re-check the
+ // native truth: if the background service actually holds a live
+ // connection, the snapshot reconcile attaches to it.
+ if (machineRef.current.phase === 'waiting') {
+ runEffectRef.current({ type: 'queryNativeState' });
+ }
+ }, []);
const reconnectRef = useRef(reconnect);
reconnectRef.current = reconnect;
- // Schedule the next bounded, serialized automatic reconnect attempt. Returns
- // false when we've exhausted the budget (or the user stopped the session), so
- // callers can fall back to the manual Reconnect button. Retries never
- // overlap: a new attempt is only ever scheduled from a prior attempt's
- // terminal event (transport `onClose` or a setup failure), and the pending
- // timer/in-flight guards below make double-scheduling impossible — which also
- // guarantees we never launch two connection attempts (and two blocking
- // biometric prompts) at once.
- const scheduleAutoReconnect = useCallback((): boolean => {
- if (userStoppedRef.current) return false;
- // A retry is already pending or an attempt is mid-flight — don't stack.
- if (autoReconnectTimerRef.current) return true;
- if (reconnectAttemptRef.current >= MAX_RECONNECT_ATTEMPTS) return false;
-
- const attempt = reconnectAttemptRef.current + 1;
- reconnectAttemptRef.current = attempt;
- setReconnectAttempt(attempt);
- setIsReconnecting(true);
- setIsLoading(false);
- autoReconnectTimerRef.current = setTimeout(() => {
- autoReconnectTimerRef.current = null;
- performReconnectRef.current();
- }, AUTO_RECONNECT_DELAY_MS);
- return true;
- }, []);
- const scheduleAutoReconnectRef = useRef(scheduleAutoReconnect);
- scheduleAutoReconnectRef.current = scheduleAutoReconnect;
-
- // Single funnel for an unexpected connection failure. Later async detectors
- // (ICE state, heartbeat watchdog, send errors) all route through this so
- // teardown + bounded reconnect happen in exactly one place. `isCurrent`
- // closes over the observing setup run's `active` flag (set false by that
- // run's cleanup before any newer run starts), so a stale/superseded callback
- // is ignored and can't tear down or reschedule on top of a newer run.
- const failConnection = useCallback(
- (reason: ConnectionFailureReason, isCurrent: () => boolean, error?: Error) => {
- if (!isCurrent()) return;
- if (!error) console.log(`Connection lost (${reason})`);
- // Capture which ICE path this session used before tearing the peer down,
- // to correlate failures with relay/direct usage (best-effort).
- logCandidatePairRef.current(`failed(${reason})`);
- setIsConnected(false);
- // Drop every stale transport ref so the next reconnect isn't blocked by
- // the connection effect's guard.
- clearTransport();
- // Kick off (or continue) the bounded, serialized retry sequence. Once the
- // budget is spent, fall back to the manual Reconnect bar (surfacing the
- // terminal error only for a setup failure).
- if (!scheduleAutoReconnectRef.current()) {
- setIsReconnecting(false);
- setIsLoading(false);
- if (error) {
- setError(error);
- Alert.alert(
- 'Connection Failed',
- error.message || 'Failed to setup connection to the peer',
- [{ text: 'OK' }],
- );
- }
- }
- },
- [clearTransport],
- );
- const failConnectionRef = useRef(failConnection);
- failConnectionRef.current = failConnection;
-
- // Live mirrors of the connection state so the AppState listener can read the
- // current values without being torn down/re-subscribed on every change (and
- // without capturing a stale closure).
- const isConnectedRef = useRef(isConnected);
- isConnectedRef.current = isConnected;
- const isLoadingRef = useRef(isLoading);
- isLoadingRef.current = isLoading;
- const isReconnectingRef = useRef(isReconnecting);
- isReconnectingRef.current = isReconnecting;
-
// Automatically resume a dropped connection when the app returns to the
// foreground. Subscribed once per session (keyed on origin/requestId); all
- // decision state is read from refs so there is no stale-closure risk. The
- // in-progress guards below are what prevent a second connection attempt from
- // launching a duplicate — and therefore a duplicate blocking biometric
- // prompt, since the passkey assertion/attestation step is what triggers it.
+ // decision state is read from refs/the machine so there is no stale-closure
+ // risk. `APP_FOREGROUND` makes the machine pull the native truth (snapshot
+ // reconcile), which either re-attaches to a surviving native peer or
+ // abandons stale in-flight work and starts a fresh attempt — the fix for the
+ // stuck-"Connecting…" bug on suspend → resume.
useEffect(() => {
const handleAppStateChange = (nextState: AppStateStatus) => {
const prevState = appStateRef.current;
@@ -447,6 +577,37 @@ export function useConnection(
// inactivity close distinguish that from a genuine foreground idle.
if (nextState === 'background' || nextState === 'inactive') {
wasBackgroundedRef.current = true;
+ if (prevState === 'active') {
+ dispatchRef.current({ type: 'APP_BACKGROUND' });
+ }
+ }
+
+ // Keep the native background service's delivery gate in sync with our
+ // foreground state. Going background flips it offline, so inbound
+ // requests are buffered natively (and surfaced as notifications) instead
+ // of dropped. The service itself keeps running regardless (it survives
+ // app close).
+ if (nativeStartedRef.current) {
+ try {
+ setNativeActive(nextState === 'active');
+ } catch {
+ /* native module may not implement setActive on every platform yet */
+ }
+ // Returning to the foreground with a live transport: its channel
+ // handlers are still wired in this same runtime, so ask the service to
+ // replay anything it buffered while we were backgrounded. `setActive`
+ // itself deliberately does NOT replay — on a relaunch it fires before
+ // the fresh listeners exist, and the replay would be swallowed by the
+ // previous session's stale handlers. Without a live transport we leave
+ // the queue buffered: the next negotiation replays it once fresh
+ // handlers are wired.
+ if (nextState === 'active' && machineRef.current.phase === 'connected') {
+ try {
+ flushNativeQueue();
+ } catch {
+ /* best-effort; the post-negotiation flush also covers this */
+ }
+ }
}
// Only react to a genuine (background|inactive) -> active transition.
@@ -455,31 +616,26 @@ export function useConnection(
// Respect an explicit user disconnect; don't resurrect a stopped session.
if (userStoppedRef.current) return;
- // A healthy connection needs nothing.
- if (isConnectedRef.current) return;
-
- // Never start a second connection while one is already in flight. Any of
- // these means "busy": an auth flow (blocking biometric prompt) is open,
- // a SignalClient is already set up, we're in the loading/connecting
- // state, an auto-reconnect sequence is running, or a retry timer is
- // already pending.
- if (
- authFlowInProgressRef.current ||
- clientRef.current ||
- isLoadingRef.current ||
- isReconnectingRef.current ||
- autoReconnectTimerRef.current
- ) {
- return;
- }
-
// Only resume sessions we still track (not forgotten by the user).
const existingSession = sessionsStore.state.sessions.find(
(s) => s.id === requestId && s.origin === origin,
);
if (!existingSession) return;
- reconnectRef.current();
+ dispatchRef.current({ type: 'APP_FOREGROUND' });
+
+ // A terminal failure normally waits for the user, but foregrounding the
+ // app IS a user action: resume recoverable failures (auth/service/
+ // generic). Idle closes stay manual by design (no churn/re-prompt for a
+ // session nobody is using), and "session full" can't be fixed by
+ // retrying.
+ const state = machineRef.current;
+ if (
+ state.phase === 'failed' &&
+ (state.kind === 'auth' || state.kind === 'service' || state.kind === 'generic')
+ ) {
+ dispatchRef.current({ type: 'USER_RECONNECT' });
+ }
};
const subscription = AppState.addEventListener('change', handleAppStateChange);
@@ -488,6 +644,13 @@ export function useConnection(
const send = useCallback(
(text: string) => {
+ // Hard-block: a connection that wasn't paired properly (no identity, or a
+ // controller/identity mismatch that locked it out) is inert — never put a
+ // message on the wire, regardless of any UI gate.
+ if (!isRegisteredRef.current) {
+ console.warn('Refusing to send message: connection is not registered.');
+ return;
+ }
const channel = streamChannelRef.current || dataChannelRef.current;
if (text.trim() && channel && channel.readyState === 'open' && address) {
const thid = activeThidRef.current;
@@ -498,10 +661,11 @@ export function useConnection(
channel.send(JSON.stringify({ thid, text: text.trim() }));
} catch (err) {
// A send can throw even on an "open" channel once the underlying peer
- // has died (a zombie transport). Route through the single recovery
- // funnel instead of crashing, and don't echo a frame we never sent.
+ // has died (a zombie transport). Route through the machine instead of
+ // crashing, and don't echo a frame we never sent. The machine only
+ // reacts while `connected`, so a stale/racing failure is a no-op.
console.warn('Failed to send message; treating as a dropped connection', err);
- failConnectionRef.current('send', () => isConnectedRef.current);
+ dispatchRef.current({ type: 'CONNECTION_LOST', reason: 'send failed' });
return;
}
addMessage({
@@ -523,6 +687,12 @@ export function useConnection(
// envelopes (see `lib/ac2/conversations.ts`) and tracks the active `thid`.
const openConversation = useCallback(
(thid?: string, title?: string): string => {
+ // Hard-block: don't drive the conversation control-plane on a connection
+ // that wasn't paired properly. Return the requested/active thid unchanged.
+ if (!isRegisteredRef.current) {
+ console.warn('Refusing to open conversation: connection is not registered.');
+ return thid && thid.length > 0 ? thid : activeThidRef.current;
+ }
const nextThid = thid && thid.length > 0 ? thid : generateThid();
sendConversationOpen(
{ getClient: () => ac2ClientRef.current, getAddress: () => address },
@@ -558,6 +728,12 @@ export function useConnection(
const sendAc2 = useCallback(
(message: Ac2Message) => {
+ // Hard-block: an unregistered/locked connection must not emit protocol
+ // envelopes either (e.g. approvals, conversation control). Throw so the
+ // caller doesn't record the envelope as sent.
+ if (!isRegisteredRef.current) {
+ throw new Error('Connection is not registered; refusing to send AC2 envelope.');
+ }
const client = ac2ClientRef.current;
if (!client) {
throw new Error('AC2 client not ready (DataChannel not open)');
@@ -566,10 +742,9 @@ export function useConnection(
client.send(message);
} catch (err) {
// Surface the failure to the caller (so it won't record the envelope as
- // sent) AND route through the recovery funnel to reconnect the dead
- // transport.
+ // sent) AND route through the machine to reconnect the dead transport.
console.warn('Failed to send AC2 envelope; treating as a dropped connection', err);
- failConnectionRef.current('send', () => isConnectedRef.current);
+ dispatchRef.current({ type: 'CONNECTION_LOST', reason: 'send failed' });
throw err;
}
addAc2Message({
@@ -587,69 +762,107 @@ export function useConnection(
[origin, requestId, address],
);
+ // Idle-session watchdog. Any traffic keeps the session alive: measure from
+ // the most recent of inbound peer traffic (frames/pongs) or local user
+ // action. A dead transport is caught far sooner by the ICE monitor /
+ // heartbeat watchdog; this only fires for a genuinely quiet session or one
+ // that went stale while backgrounded.
+ const isConnectedNow = machineState.phase === 'connected';
useEffect(() => {
- let active = true;
- let inactivityInterval: any = null;
-
- if (isConnected) {
- inactivityInterval = setInterval(() => {
- const now = Date.now();
- // Any traffic keeps the session alive: measure from the most recent of
- // inbound peer traffic (frames/pongs) or local user action. A dead
- // transport is caught far sooner by the ICE monitor / heartbeat
- // watchdog; this only fires for a genuinely quiet session or one that
- // went stale while backgrounded.
- const lastActivity = Math.max(lastInboundActivityRef.current, lastLocalActivityRef.current);
- const inactiveTime = now - lastActivity;
- if (inactiveTime >= IDLE_SESSION_TIMEOUT_MS) {
- // A stale connection whose idleness is explained by the app having
- // been backgrounded (timers suspended, no heartbeats) should recover
- // automatically; a genuine foreground idle should not (avoids churn /
- // repeated biometric prompts). An intentional disconnect never
- // resumes.
- const resumeAfterBackground = !userStoppedRef.current && wasBackgroundedRef.current;
- console.log(
- resumeAfterBackground
- ? 'Closing stale connection after background; will reconnect'
- : 'Closing idle session (no activity)',
+ if (!isConnectedNow) return;
+
+ const inactivityInterval = setInterval(() => {
+ // The verdict logic is extracted (and unit-tested) in
+ // `lib/ac2/idleSession.ts`. The crucial case: after a LONG background the
+ // clocks are hours old, and on resume this interval can fire BEFORE the
+ // AppState listener's snapshot reconcile runs — so when the idleness is
+ // explained by a background gap, the native truth is consulted before
+ // tearing anything down (the background service keeps the peer alive
+ // independently of JS).
+ const verdict = evaluateIdleSession({
+ now: Date.now(),
+ lastInboundAt: lastInboundActivityRef.current,
+ lastLocalAt: lastLocalActivityRef.current,
+ idleTimeoutMs: IDLE_SESSION_TIMEOUT_MS,
+ userStopped: userStoppedRef.current,
+ wasBackgrounded: wasBackgroundedRef.current,
+ isNativeChannelOpen: () => {
+ const snapshot = getNativeConnectionState();
+ return (
+ !!snapshot.connected &&
+ snapshot.requestId === requestId &&
+ isSnapshotChannelOpen(snapshot)
);
- // Fully tear down so the connection effect's guard doesn't keep a
- // stale client around — otherwise a later reconnect is impossible.
- deliberateCloseRef.current = true;
- clearTransport();
- updateSessionStatus(requestId, origin, 'closed');
- if (active) {
- setIsConnected(false);
- setIsLoading(false);
- }
- // If we're already back in the foreground, reconnect now (the
- // foreground AppState handler already ran while we still looked
- // connected, so it won't fire again). If the close happened while
- // still backgrounded, that handler resumes us on the next activation.
- if (resumeAfterBackground && AppState.currentState === 'active') {
- wasBackgroundedRef.current = false;
- reconnectRef.current();
- }
- }
- }, IDLE_CHECK_INTERVAL_MS);
+ },
+ });
+ if (verdict.action === 'none') return;
+ if (verdict.action === 'refresh') {
+ // Verified alive: the background gap explained the silence. Count the
+ // native confirmation as fresh liveness evidence, exactly like an
+ // inbound heartbeat, and keep the session.
+ wasBackgroundedRef.current = false;
+ lastInboundActivityRef.current = Date.now();
+ heartbeatMonitorRef.current?.noteInbound();
+ return;
+ }
+ console.log(
+ verdict.action === 'close-stale'
+ ? 'Closing stale connection after background; will reconnect'
+ : 'Closing idle session (no activity)',
+ );
+ updateSessionStatus(requestId, origin, 'closed');
+ if (verdict.action === 'close-stale') {
+ wasBackgroundedRef.current = false;
+ // Recoverable: the machine tears down and retries with backoff (or
+ // pauses until foregrounded, where the snapshot reconcile takes over).
+ dispatchRef.current({ type: 'CONNECTION_LOST', reason: 'stale after background' });
+ } else {
+ // Terminal until the user acts (the manual Reconnect bar).
+ dispatchRef.current({ type: 'SESSION_IDLE' });
+ }
+ }, IDLE_CHECK_INTERVAL_MS);
+
+ return () => clearInterval(inactivityInterval);
+ }, [isConnectedNow, origin, requestId]);
+
+ // ---------------------------------------------------------------------
+ // Effect handler: `startService` — Liquid Auth + native service bring-up.
+ // Runs the auth flow (reusing an existing session when possible), starts the
+ // persistent native foreground service, and installs the service-lifetime
+ // subscriptions (presence, signaling connectivity). Reports back to the
+ // machine with SERVICE_READY / SERVICE_FAILED.
+ // ---------------------------------------------------------------------
+ const startService = useCallback(async () => {
+ if (!origin || !requestId) {
+ console.error('Missing origin or requestId');
+ dispatchRef.current({ type: 'SERVICE_FAILED', reason: 'Missing origin or requestId' });
+ return;
}
- return () => {
- active = false;
- if (inactivityInterval) clearInterval(inactivityInterval);
- };
- }, [isConnected, clearTransport, origin, requestId]);
+ if (authFlowInProgressRef.current) {
+ console.log('Auth flow already in progress, skipping duplicate service start');
+ return;
+ }
- useEffect(() => {
- let active = true;
- // Aborts every in-flight setup request when this run is superseded (a newer
- // reconnect/nonce bump) or unmounted, so a stalled request from an obsolete
- // attempt can't linger and race a fresh one.
+ // Aborts every in-flight setup request when this run is superseded or the
+ // session stops, so a stalled request from an obsolete attempt can't
+ // linger and race a fresh one.
const runAbort = new AbortController();
-
- // `fetch` with a per-request timeout, also wired to this run's abort signal.
- // A timeout or supersession rejects the request so the outer catch can hand
- // off to the bounded auto-reconnect scheduler instead of hanging.
+ serviceAbortRef.current?.abort();
+ serviceAbortRef.current = runAbort;
+ const active = () => !runAbort.signal.aborted;
+
+ // Liquid Auth HTTP with a per-request timeout, also wired to this run's
+ // abort signal. A timeout or supersession rejects the request so the outer
+ // catch can hand off to the machine instead of hanging.
+ //
+ // Requests are routed through the native background service's shared
+ // cookie-jar client (`nativeAuthFetch`) rather than JS `fetch`, so the
+ // `connect.sid` session cookie set by the FIDO ceremony is captured
+ // natively and authenticates the signaling socket (D9). The native call has
+ // no abort signal of its own, so the timeout/supersession is enforced here
+ // by racing it against an abort rejection; the abandoned native request's
+ // result is simply ignored.
const fetchWithTimeout = (
input: string,
init: RequestInit = {},
@@ -660,110 +873,108 @@ export function useConnection(
if (runAbort.signal.aborted) controller.abort();
else runAbort.signal.addEventListener('abort', onRunAbort);
const timer = setTimeout(() => controller.abort(), timeoutMs);
- return fetch(input, { ...init, signal: controller.signal }).finally(() => {
+ const aborted = new Promise((_, reject) => {
+ const fail = () => {
+ const err = new Error('The operation was aborted.');
+ err.name = 'AbortError';
+ reject(err);
+ };
+ if (controller.signal.aborted) fail();
+ else controller.signal.addEventListener('abort', fail);
+ });
+ return Promise.race([nativeAuthFetch(input, init), aborted]).finally(() => {
clearTimeout(timer);
runAbort.signal.removeEventListener('abort', onRunAbort);
});
};
- async function setupConnection() {
- if (!origin || !requestId) {
- console.error('Missing origin or requestId');
- setIsLoading(false);
- return;
- }
+ authFlowInProgressRef.current = true;
- if (authFlowInProgressRef.current) {
- console.log('Auth flow already in progress, skipping duplicate setup');
- return;
- }
+ // Coarse phase timing so a hang is attributable to auth vs. service start.
+ const setupStartedAt = Date.now();
- if (!findWalletAccount(accountsStore.state.accounts, keyStore.state.keys)) {
- console.log('Waiting for accounts and keys to load...');
- // If it's been loading for more than a few seconds, it might really be empty
- // but typically it's better to wait for them to be non-empty.
- return;
- }
+ try {
+ const currentSessions = sessionsStore.state.sessions;
+ const currentKeys = keyStore.state.keys;
+ const currentAccounts = accountsStore.state.accounts;
- // If we are already connecting or connected, don't start again
- if (clientRef.current || isConnected) {
- return;
+ const existingSession = currentSessions.find(
+ (s) => s.id === requestId && s.origin === origin,
+ );
+ if (!existingSession) {
+ // Persist the connection durably (no TTL) so it survives app
+ // restarts and can be reconnected/renegotiated later using the same
+ // requestId — mirroring how the OpenClaw plugin persists connections.
+ addSession({ id: requestId, origin, status: 'active' });
+ } else if (existingSession.status !== 'active') {
+ updateSessionStatus(requestId, origin, 'active');
}
- setIsLoading(true);
- setError(null);
- authFlowInProgressRef.current = true;
-
- // Coarse phase timing so a hang is attributable to auth vs. WebRTC
- // negotiation vs. the channel-open wait.
- const setupStartedAt = Date.now();
-
- try {
- const currentSessions = sessionsStore.state.sessions;
- const currentKeys = keyStore.state.keys;
- const currentAccounts = accountsStore.state.accounts;
-
- const existingSession = currentSessions.find(
- (s) => s.id === requestId && s.origin === origin,
+ const walletAccount = findWalletAccount(currentAccounts, currentKeys);
+ const foundKey = walletAccount?.key;
+
+ if (!foundKey || !foundKey.publicKey) {
+ console.error(
+ 'No key found for attestation. Keys:',
+ JSON.stringify(
+ currentKeys.map((k) => ({ id: k.id, type: k.type })),
+ null,
+ 2,
+ ),
);
- if (!existingSession) {
- // Persist the connection durably (no TTL) so it survives app
- // restarts and can be reconnected/renegotiated later using the same
- // requestId — mirroring how the OpenClaw plugin persists connections.
- addSession({ id: requestId, origin, status: 'active' });
- } else if (existingSession.status !== 'active') {
- updateSessionStatus(requestId, origin, 'active');
- }
-
- const walletAccount = findWalletAccount(currentAccounts, currentKeys);
- const foundKey = walletAccount?.key;
-
- if (!foundKey || !foundKey.publicKey) {
- console.error(
- 'No key found for attestation. Keys:',
- JSON.stringify(
- currentKeys.map((k) => ({ id: k.id, type: k.type })),
- null,
- 2,
- ),
- );
- console.error(
- 'Accounts:',
- JSON.stringify(
- currentAccounts.map((a) => ({ address: a.address, keyId: a.metadata?.keyId })),
- null,
- 2,
- ),
- );
- throw new Error('No key found for attestation');
- }
+ console.error(
+ 'Accounts:',
+ JSON.stringify(
+ currentAccounts.map((a) => ({ address: a.address, keyId: a.metadata?.keyId })),
+ null,
+ 2,
+ ),
+ );
+ throw new Error('No key found for attestation');
+ }
- const walletAddress = encodeAddress(foundKey.publicKey);
- console.log('Found key for attestation:', foundKey.id, foundKey.type);
+ const walletAddress = encodeAddress(foundKey.publicKey);
+ console.log('Found key for attestation:', foundKey.id, foundKey.type);
- const sessionCheck = await fetchWithTimeout(`${origin}/auth/session`);
- if (!active) return;
- let initialSessionData: any = null;
- let initialSessionAddress: string | null = null;
- console.log('Initial session status:', sessionCheck.ok);
+ const sessionCheck = await fetchWithTimeout(`${origin}/auth/session`);
+ if (!active()) return;
+ let initialSessionData: any = null;
+ let initialSessionAddress: string | null = null;
+ console.log('Initial session status:', sessionCheck.ok);
- if (sessionCheck.ok) {
- try {
- const sessionData = await sessionCheck.json();
- initialSessionData = sessionData;
- if (!active) return;
- initialSessionAddress = sessionAddressFromData(sessionData);
- if (initialSessionAddress && addressMatchesKey(initialSessionAddress, foundKey)) {
- setAddress(initialSessionAddress);
- addressRef.current = initialSessionAddress;
- } else if (initialSessionAddress) {
- console.warn('Ignoring session address that does not match the active wallet key');
- }
- } catch (error) {
- console.warn('Unable to parse existing auth session response:', error);
+ if (sessionCheck.ok) {
+ try {
+ const sessionData = await sessionCheck.json();
+ initialSessionData = sessionData;
+ if (!active()) return;
+ initialSessionAddress = sessionAddressFromData(sessionData);
+ if (initialSessionAddress && addressMatchesKey(initialSessionAddress, foundKey)) {
+ setAddress(initialSessionAddress);
+ addressRef.current = initialSessionAddress;
+ } else if (initialSessionAddress) {
+ console.warn('Ignoring session address that does not match the active wallet key');
}
+ } catch (error) {
+ console.warn('Unable to parse existing auth session response:', error);
}
+ }
+ // Reuse an existing valid session for this requestId instead of
+ // re-prompting for the passkey on every reconnect. When the session
+ // already authenticates this wallet for this exact requestId, the
+ // signaling socket is authenticated by cookie and the server
+ // re-announces presence for the requestId on the socket's reconnect —
+ // which resolves the waiting peer's `link` — so both parties can
+ // renegotiate over the socket without a fresh FIDO2 assertion.
+ if (sessionAlreadyAuthenticatedForRequest(initialSessionData, foundKey, requestId)) {
+ console.log(
+ '[ac2] Reusing existing Liquid Auth session for this requestId; skipping passkey assertion',
+ );
+ if (initialSessionAddress) {
+ setAddress(initialSessionAddress);
+ addressRef.current = initialSessionAddress;
+ }
+ } else {
const authResult = await authenticateLiquidAuth({
origin,
requestId,
@@ -780,64 +991,206 @@ export function useConnection(
addressRef,
authFlowInProgressRef,
fetchWithTimeout,
- isActive: () => active,
+ isActive: () => active(),
});
- if (authResult.superseded || !active) return;
- console.log(`[ac2] auth phase done in ${Date.now() - setupStartedAt}ms`);
+ if (authResult.superseded || !active()) return;
+ }
+ console.log(`[ac2] auth phase done in ${Date.now() - setupStartedAt}ms`);
- // Final validation of the session before connecting
- const finalSessionCheck = await fetchWithTimeout(`${origin}/auth/session`);
+ // Final validation of the session before connecting
+ const finalSessionCheck = await fetchWithTimeout(`${origin}/auth/session`);
- if (!active) return;
+ if (!active()) return;
- if (finalSessionCheck.ok) {
- const sessionData = await finalSessionCheck.json();
+ if (finalSessionCheck.ok) {
+ const sessionData = await finalSessionCheck.json();
- if (!active) return;
+ if (!active()) return;
- const sessionAddress = sessionAddressFromData(sessionData);
- if (sessionAddress && addressMatchesKey(sessionAddress, foundKey)) {
- setAddress(sessionAddress);
- addressRef.current = sessionAddress;
- } else if (sessionAddress) {
- console.warn(
- 'Ignoring final session address that does not match the active wallet key',
- );
- }
- } else {
- console.log('Session validation failed (ignored for debugging)');
+ const sessionAddress = sessionAddressFromData(sessionData);
+ if (sessionAddress && addressMatchesKey(sessionAddress, foundKey)) {
+ setAddress(sessionAddress);
+ addressRef.current = sessionAddress;
+ } else if (sessionAddress) {
+ console.warn('Ignoring final session address that does not match the active wallet key');
}
+ } else {
+ console.log('Session validation failed (ignored for debugging)');
+ }
- let options: any = {
- autoConnect: true,
- transportOptions: {},
- withCredentials: true,
- };
-
- if (Platform.OS === 'ios') {
- options.transports = [EngineIoXHR];
- } else if (NativeModules.CookieModule) {
- const cookie = await NativeModules.CookieModule.getCookie(origin);
+ // Ensure the runtime notification permission (Android 13+ / iOS) BEFORE
+ // starting the foreground service, so its ongoing "connected" banner and
+ // the per-message notifications can actually be shown. Non-fatal: if the
+ // user denies it the service still runs, just silently.
+ await ensureNotificationPermission();
+ if (!active()) return;
+
+ // Start (or reuse) the native foreground signaling service. It owns the
+ // signaling socket + WebRTC peer inside a background service, so the
+ // connection no longer goes stale when the app is backgrounded. The auth
+ // phase above has already established the Liquid Auth session
+ // server-side; the native service connects using it. Idempotent on the
+ // native side, so a reused service is fine.
+ await startNativeService(origin);
+ if (!active()) return;
+ nativeStartedRef.current = true;
+
+ // Sync the service's delivery gate with the real foreground state. Note
+ // this does NOT replay any messages the service buffered while the app
+ // was closed — at this point the fresh runtime hasn't wired its channel
+ // handlers yet, and (because the JS VM can survive a relaunch) the
+ // previous session's stale handlers may still be attached and would
+ // swallow the replay. The buffered messages are replayed once a
+ // negotiation/attach completes and the fresh handlers are wired.
+ try {
+ setNativeActive(AppState.currentState === 'active');
+ } catch {
+ /* native module may not implement setActive on every platform yet */
+ }
- if (!active) return;
+ // Presence lives with the persistent service (outside a single p2p
+ // negotiation) so it keeps working across chat drops and drives the
+ // machine's presence gate: peers must both be present in the requestId
+ // room before negotiating. The native service forwards the server's
+ // `presence` broadcasts (and re-broadcasts on its own socket reconnect).
+ // NOTE: there is no active presence *query*, so the first negotiation
+ // decision is made once the first broadcast arrives (`peerPresent ===
+ // null` means "unknown" and is allowed to try).
+ const presenceSub = addNativePresenceListener((e) => {
+ const presence: PresenceResult = {
+ requestId: e.requestId,
+ deviceCount: e.deviceCount,
+ online: e.online,
+ };
+ console.log(
+ `[ac2] presence for ${presence.requestId}: ${presence.deviceCount} device(s), online=${presence.online}`,
+ );
+ setPeerPresence(presence);
+ peerPresenceRef.current = presence;
+ // A presence broadcast only reaches us while the signaling server is
+ // up and delivering, so when it is connected the server's view of who
+ // is in the requestId room is authoritative — more accurate than
+ // inferring liveness from the p2p data channel. The machine trusts it:
+ // a `PEER_ABSENT` while connected/negotiating tears the p2p transport
+ // down (a stale data channel to a departed peer is worse than
+ // reconnecting when they return); a `PEER_PRESENT` while waiting
+ // (re)starts negotiation. A signaling-server outage produces NO
+ // broadcast (the socket is gone), so a live p2p connection deliberately
+ // outlives it.
+ dispatchRef.current({ type: isPeerOffline(presence) ? 'PEER_ABSENT' : 'PEER_PRESENT' });
+ });
+ presenceUnsubRef.current = () => presenceSub.remove();
+
+ // Track the signaling socket's REAL connectivity so the chat surface can
+ // show "Service unavailable" while the signaling server is unreachable.
+ // Crucially, a signaling drop must NOT tear down the p2p transport — the
+ // data channels deliberately outlive signaling disruptions (the machine
+ // stays `connected` on SOCKET_DOWN) — it only gates (re)negotiation and
+ // the UI state. When the socket (re)connects, the server rejoins us to
+ // the requestId room and rebroadcasts presence; `SOCKET_UP` also resumes
+ // a waiting reconnect promptly in case a broadcast was missed.
+ const signalingSub = addNativeSignalingStateListener((e) => {
+ const connected = e.state === 'connected';
+ console.log(`[ac2] signaling socket ${e.state}`);
+ setIsSocketConnected(connected);
+ dispatchRef.current({ type: connected ? 'SOCKET_UP' : 'SOCKET_DOWN' });
+ });
+ signalingUnsubRef.current = () => signalingSub.remove();
- if (cookie) {
- options.extraHeaders = { Cookie: cookie };
- options.transports = ['polling'];
- options.transportOptions = {
- polling: { extraHeaders: { Cookie: cookie } },
- };
- }
- }
+ // Seed the signaling state from the service snapshot (the events above
+ // keep it fresh from here on). An older native binary that doesn't
+ // report `signalingConnected` (undefined) is treated as connected,
+ // matching the previous always-optimistic behavior.
+ let signalingConnected = true;
+ try {
+ signalingConnected = getNativeConnectionState().signalingConnected !== false;
+ } catch {
+ /* native module unavailable (tests / web) — stay optimistic */
+ }
+ setIsSocketConnected(signalingConnected);
+ console.log(
+ `[ac2] service ready in ${Date.now() - setupStartedAt}ms (signaling ${
+ signalingConnected ? 'connected' : 'connecting…'
+ })`,
+ );
- const client = new SignalClient(origin, options);
+ dispatchRef.current({ type: signalingConnected ? 'SOCKET_UP' : 'SOCKET_DOWN' });
+ dispatchRef.current({ type: 'SERVICE_READY' });
+
+ // Cold-relaunch hydration: if the machine parked in `waiting` (gates not
+ // known open yet) but the background service ALREADY holds a live
+ // connection for this `requestId` (it survived app close/backgrounding),
+ // feed it a snapshot so it attaches immediately. A live open channel is
+ // itself proof both peers exist, and on a cold relaunch presence can't
+ // even reach us until attach() rebinds the listener — waiting for a
+ // broadcast here would deadlock the hydration.
+ if (machineRef.current.phase === 'waiting') {
+ runEffectRef.current({ type: 'queryNativeState' });
+ }
+ } catch (err: any) {
+ // A superseded run (teardown fired, or a request was aborted) must do
+ // nothing: a newer run owns recovery.
+ if (!active() || err?.name === 'AbortError') return;
+ console.error('Failed to establish the signaling service:', err);
+ updateSessionStatus(requestId, origin, 'failed');
+ setIsSocketConnected(false);
+ // Surface the auth/network failure. Peer-presence gating (the peer
+ // simply not being online) is handled by the machine's presence gate.
+ setError(err);
+ dispatchRef.current({
+ type: 'SERVICE_FAILED',
+ reason: err?.message || 'Failed to establish the signaling service',
+ kind: 'auth',
+ });
+ } finally {
+ // Only release the auth lock if this run is still the active one.
+ if (active()) authFlowInProgressRef.current = false;
+ }
+ }, [origin, requestId, allowPasskeyCreation, key, passkey]);
+ const startServiceRef = useRef(startService);
+ startServiceRef.current = startService;
+
+ // ---------------------------------------------------------------------
+ // Effect handler: `negotiate` — one p2p transport attempt over the
+ // PERSISTENT native service. Each attempt carries the machine's `attemptId`;
+ // its result events are stamped with it so a superseded/abandoned attempt
+ // can never corrupt newer state. Reuses the started native service and NEVER
+ // stops it — only the peer/data-channels are torn down between chats.
+ // ---------------------------------------------------------------------
+ const negotiate = useCallback(
+ async (attemptId: number, mode: NegotiationMode) => {
+ if (!nativeStartedRef.current) {
+ // Shouldn't happen (the machine only negotiates after SERVICE_READY),
+ // but fail the attempt cleanly rather than crash if it ever does.
+ dispatchRef.current({
+ type: 'ATTEMPT_FAILED',
+ attemptId,
+ reason: 'native service not started',
+ });
+ return;
+ }
- if (!active) return;
+ const runAbort = new AbortController();
+ negotiationAbortRef.current?.abort();
+ negotiationAbortRef.current = runAbort;
+ // This attempt stays "current" until the machine's next teardown (which
+ // aborts/replaces the controller) — including while `connected`, so the
+ // liveness detectors wired below stay live and a stale run's late
+ // callbacks are no-ops.
+ const isCurrent = () => negotiationAbortRef.current === runAbort && !runAbort.signal.aborted;
- clientRef.current = client;
- //@ts-ignore
- client.authenticated = true;
+ const setupStartedAt = Date.now();
+ console.log(`[ac2] negotiate: opening p2p transport (attempt=${attemptId}, mode=${mode})`);
+ setError(null);
+ // Clear any prior "not registered" state so a reconnect that succeeds in
+ // registering re-enables the composer. If the agent is still unregistered
+ // it re-pushes the blocking notice on connect, which re-sets the flag.
+ setNotRegisteredState(null);
+ // Reset both liveness clocks so the fresh attempt isn't judged idle.
+ lastInboundActivityRef.current = Date.now();
+ lastLocalActivityRef.current = Date.now();
+ try {
// Apply one STX-prefixed control frame from the agent's stream channel.
// See `lib/ac2/streamControlFrame.ts` / `lib/ac2/stream.ts` for the frame
// shapes. Returns true when `raw` was a control frame (recognized or
@@ -853,36 +1206,60 @@ export function useConnection(
setActiveStreamText,
setLastHeartbeat,
setRemoteThreads,
+ // Tag every pushed notice with the connection it was raised on so the
+ // banner is scoped to this `requestId` and can't leak onto another
+ // connection the user later switches to.
+ setConnectionNotice: (notice) => {
+ setConnectionNoticeState(notice ? { notice, requestId } : null);
+ // A registration-blocking notice (foreign wallet locked out, or no
+ // identity granted yet) means the wallet is not registered: flag it
+ // scoped to this connection so the composer stays disabled even if
+ // the user dismisses the banner.
+ if (notice && isRegistrationBlockingNotice(notice.code)) {
+ setNotRegisteredState({ requestId });
+ }
+ },
});
- const { datachannel } = await createAc2Transport({
+ // Presence is subscribed on the persistent native service (see
+ // `startService`), so it is intentionally NOT re-subscribed per
+ // negotiation. `createNativeAc2Transport` drives the native background
+ // service (`start` -> `connect('answer', …)` -> wait for `ac2-v1` open)
+ // and routes native events into `RTCDataChannel`-shaped shims. In
+ // `attach` mode the preceding machine teardown preserved the live
+ // native peer, so the transport takes its `attach()` (no-renegotiate)
+ // hydrate path.
+ const transport = await createNativeAc2Transport({
+ url: origin,
requestId,
- signalClient: client,
signal: runAbort.signal,
onPeerConnection: (pc) => {
- // Stash the peer connection; the connectivity monitor is attached in
- // `onOpen`, once the channel is actually live (establishment-phase
- // failures are already covered by the transport's open deadline).
+ // Stash the peer connection; the connectivity monitor is attached
+ // once the channel is actually live (establishment-phase failures
+ // are already covered by the transport's open deadline).
peerConnectionRef.current = pc as unknown as MonitoredPeerConnection;
},
onSideChannel: (channel) => {
console.log(`[ac2] Discovered channel: ${channel.label}`);
if (channel.label === 'ac2-heartbeat') {
- heartbeatChannelRef.current = attachHeartbeatChannel(channel, {
- onInbound: () => {
- if (!active) return;
- wasBackgroundedRef.current = false;
- lastInboundActivityRef.current = Date.now();
- heartbeatMonitorRef.current?.noteInbound();
- setLastHeartbeat(Date.now());
+ heartbeatChannelRef.current = attachHeartbeatChannel(
+ channel as unknown as RTCDataChannel,
+ {
+ onInbound: () => {
+ if (!isCurrent()) return;
+ wasBackgroundedRef.current = false;
+ lastInboundActivityRef.current = Date.now();
+ heartbeatMonitorRef.current?.noteInbound();
+ setLastHeartbeat(Date.now());
+ },
},
- });
+ );
return;
}
if (channel.label === 'ac2-stream') {
- streamChannelRef.current = channel;
+ streamChannelRef.current = channel as unknown as RTCDataChannel;
channel.onmessage = (event) => {
- if (!active) return;
+ if (!isCurrent()) return;
// Any inbound stream frame is proof of peer liveness.
heartbeatMonitorRef.current?.noteInbound();
if (typeof event.data === 'string') applyControlFrame(event.data);
@@ -892,18 +1269,34 @@ export function useConnection(
}
},
});
+ const { datachannel } = transport;
- if (!active) {
- // This setup run was superseded while negotiation was still winding
- // down. Avoid hard-closing the native peer here: Android's WebRTC
- // bridge may still be asynchronously applying the remote
- // description, and tearing the peer down races that work and can
- // crash with a null `PeerConnectionObserver`.
- client.close(true);
+ // Track this negotiation's listener disposer so `clearTransport` can
+ // detach the native message/state/ICE listeners. Replace any prior
+ // disposer first so a superseded run cannot leak one.
+ if (transportDisposeRef.current) {
+ try {
+ transportDisposeRef.current();
+ } catch {
+ /* noop */
+ }
+ }
+ transportDisposeRef.current = transport.dispose;
+ // The native presence listener is persistent (service-lifetime); the
+ // transport's own presence disposer is a no-op here, tracked only for
+ // symmetry with the service teardown path.
+ transportPresenceUnsubRef.current = transport.disposePresence;
+
+ if (!isCurrent()) {
+ // This run was superseded while negotiation was still winding down.
+ // Avoid hard-closing the native peer here: Android's WebRTC bridge may
+ // still be asynchronously applying the remote description, and tearing
+ // the peer down races that work and can crash with a null
+ // `PeerConnectionObserver`. NEVER touch the persistent service here.
return;
}
- dataChannelRef.current = datachannel;
+ dataChannelRef.current = datachannel as unknown as RTCDataChannel;
console.log(`[ac2] transport negotiated in ${Date.now() - setupStartedAt}ms`);
// Wallet-side responders (`onSigningRequest` / `onKeyRequest`) are
@@ -911,12 +1304,13 @@ export function useConnection(
// `ac2MessagesStore` by `createAc2Client` and `app/chat.tsx` handles
// approve/reject interactively against the visible store entry.
const { client: ac2 } = createAc2Client({
- datachannel,
+ datachannel: datachannel as unknown as RTCDataChannel,
origin,
requestId,
getAddress: () => addressRef.current,
getActiveThid: () => activeThidRef.current,
onInboundEnvelope: () => {
+ console.log('[ac2] client received inbound AC2 envelope on ac2-v1');
updateSessionActivity(requestId, origin);
wasBackgroundedRef.current = false;
lastInboundActivityRef.current = Date.now();
@@ -924,6 +1318,7 @@ export function useConnection(
setLastHeartbeat(Date.now());
},
onRawMessage: (raw: string) => {
+ console.log(`[ac2] client received raw message on ac2-v1 (len=${raw.length})`);
if (applyControlFrame(raw)) return;
if (!raw.trim() || !addressRef.current) return;
addMessage({
@@ -942,165 +1337,321 @@ export function useConnection(
},
onOpen: () => {
console.log(`Data channel opened in ${Date.now() - setupStartedAt}ms`);
- if (active) {
- deliberateCloseRef.current = false;
- wasBackgroundedRef.current = false;
- // Log the negotiated ICE path (direct/STUN/TURN) for this session.
- logCandidatePairRef.current('connected');
- // Watch the peer for connectivity loss (ICE disconnected/failed)
- // the SDK never surfaces — the DataChannel can stay "open" while
- // the underlying transport is dead. Route a failure through the
- // single recovery funnel; `() => active` makes a late callback
- // from a superseded run a no-op.
- if (peerMonitorDisposeRef.current) peerMonitorDisposeRef.current();
- peerMonitorDisposeRef.current = peerConnectionRef.current
- ? monitorPeerConnection(peerConnectionRef.current, {
- onFailed: (reason) => failConnectionRef.current(reason, () => active),
- })
- : null;
- // Start the liveness watchdog. It pings on `ac2-heartbeat` and
- // fails if the peer stops responding (a silent stall) even while
- // ICE still reads "connected". Over the `ac2-v1` fallback there is
- // no pong contract, so run keepalives without a timeout.
- if (heartbeatMonitorRef.current) heartbeatMonitorRef.current.stop();
- heartbeatMonitorRef.current = createHeartbeatMonitor({
- intervalMs: HEARTBEAT_INTERVAL_MS,
- timeoutMs: heartbeatChannelRef.current ? HEARTBEAT_TIMEOUT_MS : Infinity,
- send: () => {
- const hb = heartbeatChannelRef.current;
- const dc = dataChannelRef.current;
- const channel =
- hb && hb.readyState === 'open'
- ? hb
- : dc && dc.readyState === 'open'
- ? dc
- : null;
- if (!channel) return;
- // A growing send buffer means frames aren't draining to the
- // peer — an early signal the transport is stalling before ICE
- // even flips state.
- if (channel.bufferedAmount > HEARTBEAT_BUFFERED_WARN_BYTES) {
- console.warn(
- `Heartbeat send buffer high (${channel.bufferedAmount} bytes) — transport may be stalling`,
- );
- }
- try {
- channel.send(channel === hb ? 'ping' : '');
- } catch (err) {
- console.warn('Heartbeat send failed; treating as a dropped connection', err);
- failConnectionRef.current('send', () => active);
+ if (!isCurrent()) return;
+ wasBackgroundedRef.current = false;
+ // Log the negotiated ICE path (direct/STUN/TURN) for this session.
+ logCandidatePairRef.current('connected');
+ // Watch the peer for connectivity loss (ICE disconnected/failed)
+ // the SDK never surfaces — the DataChannel can stay "open" while
+ // the underlying transport is dead. Route a failure through the
+ // machine; it only reacts while `connected`, so a late callback
+ // from a superseded run is a no-op.
+ if (peerMonitorDisposeRef.current) peerMonitorDisposeRef.current();
+ peerMonitorDisposeRef.current = peerConnectionRef.current
+ ? monitorPeerConnection(peerConnectionRef.current, {
+ onFailed: (reason) => {
+ if (!isCurrent()) return;
+ dispatchRef.current({ type: 'CONNECTION_LOST', reason: `ice ${reason}` });
+ },
+ })
+ : null;
+ // Start the liveness watchdog. It pings on `ac2-heartbeat` and
+ // fails if the peer stops responding (a silent stall) even while
+ // ICE still reads "connected". Over the `ac2-v1` fallback there is
+ // no pong contract, so run keepalives without a timeout.
+ if (heartbeatMonitorRef.current) heartbeatMonitorRef.current.stop();
+ heartbeatMonitorRef.current = createHeartbeatMonitor({
+ intervalMs: HEARTBEAT_INTERVAL_MS,
+ timeoutMs: heartbeatChannelRef.current ? HEARTBEAT_TIMEOUT_MS : Infinity,
+ send: () => {
+ const hb = heartbeatChannelRef.current;
+ const dc = dataChannelRef.current;
+ const channel =
+ hb && hb.readyState === 'open' ? hb : dc && dc.readyState === 'open' ? dc : null;
+ if (!channel) return;
+ // A growing send buffer means frames aren't draining to the
+ // peer — an early signal the transport is stalling before ICE
+ // even flips state.
+ if (channel.bufferedAmount > HEARTBEAT_BUFFERED_WARN_BYTES) {
+ console.warn(
+ `Heartbeat send buffer high (${channel.bufferedAmount} bytes) — transport may be stalling`,
+ );
+ }
+ try {
+ channel.send(channel === hb ? 'ping' : '');
+ } catch (err) {
+ console.warn('Heartbeat send failed; treating as a dropped connection', err);
+ if (isCurrent()) {
+ dispatchRef.current({
+ type: 'CONNECTION_LOST',
+ reason: 'heartbeat send failed',
+ });
}
- },
- onTimeout: () => failConnectionRef.current('heartbeat', () => active),
- });
- heartbeatMonitorRef.current.start();
- if (autoReconnectTimerRef.current) {
- clearTimeout(autoReconnectTimerRef.current);
- autoReconnectTimerRef.current = null;
- }
- // Successful connection — clear the automatic-retry budget so a
- // future drop starts a fresh set of attempts.
- reconnectAttemptRef.current = 0;
- setReconnectAttempt(0);
- setIsReconnecting(false);
- setIsConnected(true);
- setIsLoading(false);
- setAc2Client(ac2);
- updateSessionStatus(requestId, origin, 'active');
- }
+ }
+ },
+ onTimeout: () => {
+ if (isCurrent()) {
+ dispatchRef.current({ type: 'CONNECTION_LOST', reason: 'heartbeat timeout' });
+ }
+ },
+ });
+ heartbeatMonitorRef.current.start();
+ setAc2Client(ac2);
+ updateSessionStatus(requestId, origin, 'active');
+ dispatchRef.current({ type: 'ATTEMPT_OK', attemptId });
},
onClose: () => {
console.log('Data channel closed');
+ // A deliberate teardown already aborted/replaced this attempt's
+ // controller, so `isCurrent()` is false and the close is expected.
+ // Everything else funnels into the machine's single recovery path.
+ if (!isCurrent()) return;
updateSessionStatus(requestId, origin, 'closed');
- // A deliberate teardown (reset / performReconnect / idle close) is
- // expected — consume the one-shot flag and don't treat it as a
- // failure. Everything else funnels through the single failure path.
- if (deliberateCloseRef.current) {
- deliberateCloseRef.current = false;
- return;
- }
- failConnectionRef.current('channel', () => active);
+ dispatchRef.current({ type: 'CONNECTION_LOST', reason: 'data channel closed' });
},
});
ac2ClientRef.current = ac2;
+
+ // Every channel consumer is wired now (the SDK client on `ac2-v1`, the
+ // stream/heartbeat handlers via `onSideChannel`), so ask the native
+ // service to replay anything it buffered while the app was offline.
+ // The replay is consumer-driven (it no longer piggybacks on
+ // `setActive(true)`) precisely so it can't fire before this point and
+ // be swallowed by a stale session's handlers; the channel shims buffer
+ // anything that arrives before `onmessage` is attached, so even a
+ // replay racing this wiring is preserved. No-op when nothing is
+ // buffered.
+ try {
+ flushNativeQueue();
+ } catch {
+ /* native module may not implement flushQueue on every platform yet */
+ }
} catch (err: any) {
- // A superseded run (cleanup/reconnect fired, or the transport was
- // aborted) must do nothing: `clientRef` now points at the newer run's
- // client, so tearing it down here would kill a healthy connection and
- // clobber its session status. The newer run owns all recovery.
- if (!active || err?.name === 'AbortError') return;
- console.error('Failed to setup connection:', err);
+ // A superseded run (teardown fired, or the transport was aborted) must
+ // do nothing: the newer run owns all recovery.
+ if (!isCurrent() || err?.name === 'AbortError') return;
+ console.error('Failed to negotiate transport:', err);
updateSessionStatus(requestId, origin, 'failed');
- // Funnel through the single failure path: it tears down the
- // partially-established transport (peer + socket included, via
- // `clearTransport`) and hands off to the bounded auto-reconnect
- // scheduler, surfacing the terminal error + manual fallback only once
- // the retry budget is exhausted.
- failConnectionRef.current('setup', () => active, err);
- } finally {
- // Only release the auth lock if this run is still the active one.
- // If cleanup already ran (`active = false`), it has already reset the
- // lock and a new run may have acquired it — clearing it here would
- // unblock a spurious third attempt.
- if (active) authFlowInProgressRef.current = false;
+ // A room refusal (two-peer lockdown: the session already has its two
+ // devices) is TERMINAL — retrying can't free a slot, so surface an
+ // actionable "session full" message at once instead of hammering the
+ // server with rejected attempts. The machine short-circuits into
+ // `failed` on a terminal result.
+ const terminal = isPeerRejectedError(err) ? ('session-full' as const) : undefined;
+ if (terminal) {
+ setError(err);
+ Alert.alert(
+ 'Session Full',
+ err?.message ||
+ 'This session already has the maximum number of devices connected. Ask the other device to disconnect and try again.',
+ [{ text: 'OK' }],
+ );
+ }
+ dispatchRef.current({
+ type: 'ATTEMPT_FAILED',
+ attemptId,
+ reason: err?.message || 'Failed to negotiate transport',
+ terminal,
+ });
+ // The negotiation timed out waiting for the peer's answer-description:
+ // the peer simply isn't there. Mark it absent so the machine parks in
+ // `waiting` (with the inline "check your remote device" notice) instead
+ // of hammering retries; the next presence broadcast re-arms it.
+ if (isPeerUnreachableError(err)) {
+ dispatchRef.current({ type: 'PEER_ABSENT' });
+ }
}
+ },
+ [origin, requestId],
+ );
+ const negotiateRef = useRef(negotiate);
+ negotiateRef.current = negotiate;
+
+ // ---------------------------------------------------------------------
+ // Effect interpreter: everything the pure machine asks for is executed
+ // here — timers, teardown, native queries, and the two async flows above.
+ // Assigned via a ref each render so `dispatch` (stable) always runs the
+ // latest closures.
+ // ---------------------------------------------------------------------
+ runEffectRef.current = (effect: ConnectionEffect) => {
+ switch (effect.type) {
+ case 'startService':
+ void startServiceRef.current();
+ break;
+ case 'negotiate':
+ void negotiateRef.current(effect.attemptId, effect.mode);
+ break;
+ case 'armDeadline':
+ if (deadlineTimerRef.current) clearTimeout(deadlineTimerRef.current);
+ deadlineTimerRef.current = setTimeout(() => {
+ deadlineTimerRef.current = null;
+ dispatchRef.current({ type: 'DEADLINE', attemptId: effect.attemptId });
+ }, effect.ms);
+ break;
+ case 'scheduleRetry':
+ if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
+ retryTimerRef.current = setTimeout(() => {
+ retryTimerRef.current = null;
+ dispatchRef.current({ type: 'RETRY_DUE' });
+ }, effect.delayMs);
+ break;
+ case 'cancelTimers':
+ if (deadlineTimerRef.current) {
+ clearTimeout(deadlineTimerRef.current);
+ deadlineTimerRef.current = null;
+ }
+ if (retryTimerRef.current) {
+ clearTimeout(retryTimerRef.current);
+ retryTimerRef.current = null;
+ }
+ break;
+ case 'teardown':
+ // Abort the current attempt's in-flight native work (the transport
+ // races the signal and cancels itself) and mark it stale for every
+ // late callback, then drop the JS-side transport refs.
+ if (negotiationAbortRef.current) {
+ negotiationAbortRef.current.abort();
+ negotiationAbortRef.current = null;
+ }
+ clearTransport({ preserveNativePeer: effect.preserveNativePeer });
+ break;
+ case 'queryNativeState': {
+ // Pull the native truth (push events can be lost while the JS runtime
+ // is suspended) and reconcile: a surviving native peer with an open
+ // control channel is re-attached; anything else starts over.
+ let alive = false;
+ let channelOpen = false;
+ try {
+ const snapshot = getNativeConnectionState();
+ alive = !!snapshot.connected && snapshot.requestId === requestIdRef.current;
+ // NOTE: the raw snapshot carries the native enum strings verbatim
+ // (UPPERCASE, e.g. `"OPEN"`), so the helper compares
+ // case-insensitively — a plain `=== 'open'` here misread a healthy
+ // resume as dead and forced a spurious reconnect.
+ channelOpen = alive && isSnapshotChannelOpen(snapshot);
+ } catch {
+ /* native module unavailable (tests / web) — treat as dead */
+ }
+ if (channelOpen) {
+ // The snapshot just proved the native connection survived — treat it
+ // as fresh liveness evidence, exactly like an inbound heartbeat.
+ // After a long background the liveness clocks are hours old (JS
+ // timers were suspended), and without this the idle watchdog fires
+ // on resume BEFORE the first post-resume heartbeat arrives, tearing
+ // down the verified-live connection as "stale after background". If
+ // the peer is in fact gone despite the open channel, the heartbeat
+ // watchdog / ICE monitor catch it within their (much shorter)
+ // windows.
+ wasBackgroundedRef.current = false;
+ lastInboundActivityRef.current = Date.now();
+ heartbeatMonitorRef.current?.noteInbound();
+ }
+ dispatchRef.current({ type: 'NATIVE_SNAPSHOT', alive, channelOpen });
+ break;
+ }
+ }
+ };
+
+ // Session lifecycle: START once the prerequisites are in place (wallet
+ // account + keys loaded), STOP on a genuine teardown (session change /
+ // unmount while the app is active). `START` in any non-stopped phase is a
+ // no-op, so re-runs from the dependency flips are safe.
+ const hasAccounts = accounts.length > 0;
+ const hasKeys = keys.length > 0;
+ useEffect(() => {
+ if (!origin || !requestId) {
+ console.error('Missing origin or requestId');
+ return;
+ }
+ // Never resurrect a session the user explicitly disconnected.
+ if (userStoppedRef.current) {
+ return;
+ }
+ if (!findWalletAccount(accountsStore.state.accounts, keyStore.state.keys)) {
+ console.log('Waiting for accounts and keys to load...');
+ // Typically the stores are still hydrating; the effect re-runs once the
+ // account/key counts flip.
+ return;
}
- setupConnection();
+ dispatch({ type: 'START' });
return () => {
- active = false;
- // Release the auth lock before the new run starts so it isn't blocked by
- // the guard in `setupConnection`. The `finally` block is guarded by
- // `active` and will not clobber the new run's lock once it acquires it.
- authFlowInProgressRef.current = false;
- // Stop the watchdog and detach the connectivity monitor before the peer
- // is closed below, so neither observes the teardown as a failure and no
- // timers/listeners dangle.
- if (heartbeatMonitorRef.current) {
- heartbeatMonitorRef.current.stop();
- heartbeatMonitorRef.current = null;
- }
- if (peerMonitorDisposeRef.current) {
- peerMonitorDisposeRef.current();
- peerMonitorDisposeRef.current = null;
- }
- peerConnectionRef.current = null;
- const hadEstablishedTransport = !!(dataChannelRef.current || ac2ClientRef.current);
- runAbort.abort();
- if (autoReconnectTimerRef.current) {
- clearTimeout(autoReconnectTimerRef.current);
- autoReconnectTimerRef.current = null;
- }
- if (ac2ClientRef.current) {
- try {
- ac2ClientRef.current.close();
- } catch {
- /* noop */
+ // If this cleanup fires because the app is being backgrounded / destroyed
+ // (swipe-away, screen off) rather than a genuine session change or
+ // explicit disconnect, PRESERVE the live connection: the background
+ // foreground-service keeps the peer alive so a relaunch/foreground can
+ // re-attach and hydrate from it (the `attach()` path). Tearing the
+ // transport + service down here is exactly what dropped the connection on
+ // swipe-away. Only this run's JS-side wiring is detached — the JS VM can
+ // survive a relaunch, and leaving it subscribed would accumulate dead
+ // listeners per relaunch that swallow the offline-queue replay and
+ // duplicate events. The watchdog/ICE monitor are stopped for the same
+ // reason — a stale watchdog firing on the dead tree could cancel the very
+ // peer being preserved.
+ if (AppState.currentState !== 'active') {
+ if (heartbeatMonitorRef.current) {
+ heartbeatMonitorRef.current.stop();
+ heartbeatMonitorRef.current = null;
}
- ac2ClientRef.current = null;
- }
- if (dataChannelRef.current) {
- dataChannelRef.current.close();
- dataChannelRef.current = null;
- }
- if (clientRef.current) {
- // Only hard-close the peer once this effect still owns an established
- // transport. For a superseded setup run, `runAbort.abort()` above has
- // already cancelled the logical attempt; force-closing the native peer
- // here can race Android's in-flight `setRemoteDescription` and crash.
- if (hadEstablishedTransport) {
+ if (peerMonitorDisposeRef.current) {
+ peerMonitorDisposeRef.current();
+ peerMonitorDisposeRef.current = null;
+ }
+ if (transportDisposeRef.current) {
try {
- clientRef.current.peerClient?.close();
+ transportDisposeRef.current();
} catch {
/* noop */
}
+ transportDisposeRef.current = null;
}
- clientRef.current.close(true);
- clientRef.current = null;
+ if (presenceUnsubRef.current) {
+ try {
+ presenceUnsubRef.current();
+ } catch {
+ /* noop */
+ }
+ presenceUnsubRef.current = null;
+ }
+ if (signalingUnsubRef.current) {
+ try {
+ signalingUnsubRef.current();
+ } catch {
+ /* noop */
+ }
+ signalingUnsubRef.current = null;
+ }
+ return;
}
+ // A real teardown (deps changed while the app is active, or unmount):
+ // abort the in-flight service bring-up, stop the machine (cancels timers
+ // + tears down the p2p transport), then drop the persistent service.
+ serviceAbortRef.current?.abort();
+ serviceAbortRef.current = null;
+ authFlowInProgressRef.current = false;
+ dispatch({ type: 'STOP' });
+ closeSocket();
};
- }, [origin, requestId, accounts.length > 0, keys.length > 0, reconnectNonce]);
+ }, [origin, requestId, hasAccounts, hasKeys, dispatch, closeSocket]);
+
+ // ---------------------------------------------------------------------
+ // UI projection: phase → flags, in exactly one place.
+ // ---------------------------------------------------------------------
+ const ui = deriveUiState(machineState);
+ // The peer isn't in the requestId room and the machine is parked (waiting)
+ // or between retries. Surfaced inline in the chat window (a clean banner
+ // over the composer) rather than as a disruptive pop-up.
+ const peerOffline =
+ (machineState.phase === 'waiting' || machineState.phase === 'backoff') &&
+ machineState.peerPresent === false;
+ // `stopped` before START is dispatched (stores still hydrating / first
+ // render) reads as loading, matching the old initial `isLoading = true`;
+ // after an explicit user disconnect it does not. A `waiting` that is only
+ // gated on the absent peer shows the peer-offline notice, not the spinner.
+ const isLoading =
+ !userStopped &&
+ ((ui.isLoading && !peerOffline) || (machineState.phase === 'stopped' && !error));
return {
session,
@@ -1111,13 +1662,15 @@ export function useConnection(
activeStreamText,
agentPresenceDetail,
agentPresence,
+ peerPresence,
+ peerOffline,
+ isSocketConnected,
error,
isError: !!error,
isLoading,
- isConnected,
- isReconnecting,
- reconnectAttempt,
- maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS,
+ isConnected: ui.isConnected,
+ isReconnecting: ui.isReconnecting,
+ reconnectAttempt: ui.reconnectAttempt,
lastHeartbeat,
reset,
reconnect,
@@ -1125,5 +1678,8 @@ export function useConnection(
openConversation,
closeConversation,
remoteThreads,
+ connectionNotice,
+ dismissConnectionNotice,
+ isRegistered,
};
}
diff --git a/jest.config.js b/jest.config.js
index cb365bd..6f2dabe 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -3,11 +3,17 @@ module.exports = {
setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest.setup.js'],
testPathIgnorePatterns: [],
transformIgnorePatterns: [
- 'node_modules/(?!(\\.pnpm|((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|expo-router|@scure/.*|@noble/.*|react-native-reanimated|react-native-nitro-modules|@algorandfoundation/.*|before-after-hook|nativewind|react-native-css-interop))',
+ 'node_modules/(?!(\\.pnpm|((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|expo-router|@scure/.*|@noble/.*|react-native-reanimated|react-native-nitro-modules|@algorandfoundation/.*|before-after-hook|nativewind|react-native-css-interop|uuid))',
],
moduleNameMapper: {
'^@/(.*)$': '/$1',
'^@algorandfoundation/(.*)$': '/node_modules/@algorandfoundation/$1',
+ // `react-native-liquid-auth` is vendored as an Expo local module under
+ // `modules/` and resolved at runtime via a Metro alias; mirror that here so
+ // the specifier resolves in Jest too. (Harmless in practice — the hook is
+ // factory-mocked and the transport takes an injected fake, so the real
+ // module is never imported by the suite.)
+ '^react-native-liquid-auth$': '/modules/react-native-liquid-auth/src',
'^(\\.{1,2}/.*)\\.js$': '$1',
},
};
diff --git a/lib/ac2/README.md b/lib/ac2/README.md
index 45cc4c2..197deb6 100644
--- a/lib/ac2/README.md
+++ b/lib/ac2/README.md
@@ -45,7 +45,22 @@ consume.
sender + active `thid` bookkeeping.
- `stream.ts` — STX-prefixed control-frame parser for `ac2-stream`
(`preview` / `finalize` / `discard` / `conversations` / `tool` /
- `history`).
+ `history` / `notice`). A `notice` frame is an out-of-band advisory
+ (`normalizeNoticeFrame`) rendered by `components/chat/ConnectionNoticeBanner.tsx`
+ as a dismissible banner — e.g. the agent's "a different wallet is
+ connecting and cannot take over" warning. The banner is scoped to the
+ connection it was raised on (`selectConnectionNoticeForRequest` matches the
+ stored `requestId`), so it disappears when the user switches to another
+ connection — which may be a new registration or a previously-paired wallet.
+ A subset of notice codes (`isRegistrationBlockingNotice` —
+ `controller_locked` and `identity_missing`) also means the wallet is **not
+ registered** with the agent: `hooks/useConnection.ts` tracks this per
+ connection and exposes `isRegistered`. While not registered the connection is
+ made **inert** — `send`, `sendAc2`, and `openConversation` are hard-blocked in
+ `useConnection` (not just the disabled composer), so nothing can be sent over
+ a connection that wasn't paired properly. `ChatScreen` also prompts the user
+ to delete the connection (re-pairing generates a new `requestId`, so the old
+ one is dead).
- `heartbeat.ts` — `ac2-heartbeat` channel handlers.
## Message flow at a glance
@@ -60,6 +75,17 @@ consume.
account, and returns `ac2/SigningResponse` (or `SigningRejected`).
4. Free-text chat + tool/preview frames flow on `ac2-stream`; liveness
on `ac2-heartbeat`.
+5. If a *different* wallet connects to an already-registered agent, the
+ agent refuses the takeover (it will not reuse or regenerate its key)
+ and pushes a `notice` frame; the wallet shows the banner explaining
+ the operator must clear the agent's keys (`ac2 forget`) before a new
+ wallet can register. The banner belongs to that connection's
+ `requestId` and clears automatically when a different connection is
+ opened. While the wallet is not registered (locked out, or no identity
+ granted yet) the connection is inert — the composer is disabled and
+ `send`/`sendAc2`/`openConversation` are hard-blocked — and the app prompts
+ the user to delete the connection, since re-pairing generates a new
+ `requestId`.
For the agent-side counterpart see
[`ac2-open-claw-reference`](https://github.com/algorandfoundation/ac2/tree/master/packages/ac2-open-claw-reference).
diff --git a/lib/ac2/VENDORED.md b/lib/ac2/VENDORED.md
new file mode 100644
index 0000000..41b1fbd
--- /dev/null
+++ b/lib/ac2/VENDORED.md
@@ -0,0 +1,47 @@
+# Vendored RTC adapter shim — `nativeChannel.ts`
+
+> Provenance note for the Liquid Auth consolidation (see
+> `react-native-liquid-auth/docs/CONSOLIDATION_PLAN.md`, decisions **D1** / **D7**).
+
+`lib/ac2/nativeChannel.ts` (`NativeDataChannel` / `NativePeerConnection`) is
+**vendored from `react-native-liquid-auth`**, which now owns the canonical copy
+so the wallet and any other consumer share the same adapter base. The shims
+present the package's *event-based* native background service
+(`onMessage` / `onStateChange` / `onConnectionStateChange` / ...) as the
+`RTCDataChannel`- and `RTCPeerConnection`-shaped objects the wallet's
+connection code (the AC2 SDK client, heartbeat, stream, and
+`peerConnectionMonitor`) already consumes.
+
+| | |
+| --- | --- |
+| **Upstream repo** | `react-native-liquid-auth` |
+| **Upstream path** | `src/nativeChannel.ts` |
+| **Target commit** | branch `chore/consolidation` (based on `3c2acd9`) |
+| **Portion vendored** | The RTC shims only (`NativeDataChannel`, `NativePeerConnection`, `DataChannelReadyState`, `DataChannelMessageEvent`) |
+
+## Sync direction
+
+**Upstream → vendored copy (one-way).** `lib/ac2/nativeChannel.ts` is
+**byte-identical** to `react-native-liquid-auth/src/nativeChannel.ts`. Any change
+to the shim must be made **upstream** in the package first, then re-copied here;
+never edit this copy directly.
+
+Verify the copy is in sync:
+
+```sh
+diff -u ../react-native-liquid-auth/src/nativeChannel.ts lib/ac2/nativeChannel.ts
+```
+
+## Why a copy (not a package import)
+
+`react-native-liquid-auth` is not yet a resolvable dependency of the wallet
+(unpublished `0.0.1`, unscoped, outside the wallet's pnpm workspace). Vendoring a
+byte-identical copy keeps `pnpm install` and the Jest suite green today while the
+package remains the single source of truth. Replacing this copy with a real
+`import { NativeDataChannel, NativePeerConnection } from 'react-native-liquid-auth'`
+is an on-device-pass step, once the package is published/scoped (e.g.
+`@algorandfoundation/react-native-liquid-auth`) and added as a wallet dependency.
+
+The AC2-specific transport factory that drives these shims
+(`lib/ac2/nativeTransport.ts`, `createNativeAc2Transport`) intentionally stays in
+the wallet — only the generic RTC shims were upstreamed.
diff --git a/lib/ac2/connectionMachine.ts b/lib/ac2/connectionMachine.ts
new file mode 100644
index 0000000..2cef42d
--- /dev/null
+++ b/lib/ac2/connectionMachine.ts
@@ -0,0 +1,627 @@
+/**
+ * Pure, deterministic connection state machine for the wallet's ac2 link.
+ *
+ * `useConnection` historically coordinated auth, negotiation, retries and
+ * app-state handling through a soup of booleans and refs; five independent
+ * detectors raced each other and a suspended attempt could leave the UI stuck
+ * on "Connecting…" forever. This module replaces that with ONE explicit
+ * reducer: `transition(state, event) -> { state, effects }`.
+ *
+ * Design notes:
+ * - **Attempt ids.** Every negotiation (and service start) gets a fresh,
+ * monotonically increasing `attemptId`. Results (`ATTEMPT_OK`,
+ * `ATTEMPT_FAILED`, `DEADLINE`) carrying a stale id are ignored, so a
+ * superseded/abandoned attempt can never corrupt newer state — the root of
+ * the old non-determinism.
+ * - **Deadlines.** Every in-flight phase arms a deadline (`armDeadline`
+ * effect). Nothing can hang forever with `isLoading = true`: either the
+ * attempt resolves or its deadline funnels it into backoff.
+ * - **Snapshot reconcile.** Native push events can be lost while the JS
+ * runtime is suspended. On foregrounding, the machine *pulls* the truth via
+ * the `queryNativeState` effect and reconciles on `NATIVE_SNAPSHOT`:
+ * attach to a surviving native peer, or abandon any stale in-flight attempt
+ * and start a fresh connect immediately. This is the fix for the
+ * stuck-"Connecting" bug on suspend → resume.
+ * - **Retry policy.** Exponential backoff (2s → 4s → 8s → 16s → 30s cap),
+ * unlimited attempts while foregrounded, paused while backgrounded.
+ *
+ * Pure TypeScript: no React, no react-native, no native modules. All timing
+ * is injected via events — the owning hook runs the timers and feeds
+ * `RETRY_DUE` / `DEADLINE` back in. Effects are returned as data.
+ */
+
+/** Deadline for auth + native service bring-up (`starting` phase). */
+export const STARTING_DEADLINE_MS = 45_000;
+/** Deadline for a single negotiation attempt (`negotiating` phase). */
+export const NEGOTIATING_DEADLINE_MS = 30_000;
+/** First retry delay; doubles on every consecutive failure. */
+export const BACKOFF_BASE_MS = 2_000;
+/** Retry delay ceiling. */
+export const BACKOFF_MAX_MS = 30_000;
+
+/** Terminal failure classification (drives copy + whether retry is offered). */
+export type FailureKind = 'session-full' | 'auth' | 'service' | 'idle' | 'generic';
+
+/** How a negotiation attempt reaches the peer. */
+export type NegotiationMode = 'connect' | 'attach';
+
+/**
+ * Context shared by every phase. Gate flags mirror the latest native events;
+ * `peerPresent === null` means "unknown" and is allowed to try (matching the
+ * existing presence semantics).
+ */
+interface ConnectionContext {
+ /** Native foreground service is up (auth + start completed). */
+ serviceUp: boolean;
+ /** Signaling socket reported connected. */
+ socketReady: boolean;
+ /** Last known peer presence; `null` = unknown → allowed to try. */
+ peerPresent: boolean | null;
+ /** A session connected successfully at least once (drives isReconnecting). */
+ hadSession: boolean;
+ /** App is foregrounded (retries only run while foregrounded). */
+ foreground: boolean;
+ /** Next attempt id to hand out; strictly monotonic for the machine's life. */
+ nextAttemptId: number;
+ /** Consecutive failed attempts; 0 after a success or a snapshot reset. */
+ retryCount: number;
+}
+
+export type ConnectionState =
+ /** User stopped / not started. Only `START` leaves this phase. */
+ | ({ phase: 'stopped' } & ConnectionContext)
+ /** Auth + native service bring-up in flight (deadline-guarded). */
+ | ({ phase: 'starting'; attemptId: number } & ConnectionContext)
+ /** Service up but gated on `socketReady` / `peerPresent`. */
+ | ({ phase: 'waiting' } & ConnectionContext)
+ /** One negotiation attempt in flight (deadline-guarded). */
+ | ({ phase: 'negotiating'; attemptId: number; mode: NegotiationMode } & ConnectionContext)
+ /** Data channel open and healthy. */
+ | ({ phase: 'connected' } & ConnectionContext)
+ /** Waiting out an exponential-backoff delay before the next attempt. */
+ | ({
+ phase: 'backoff';
+ attempt: number;
+ delayMs: number;
+ reason: string;
+ pausedInBackground: boolean;
+ } & ConnectionContext)
+ /** Terminal until user action (e.g. session full, auth rejected). */
+ | ({ phase: 'failed'; reason: string; kind: FailureKind } & ConnectionContext);
+
+export type ConnectionPhase = ConnectionState['phase'];
+
+export type ConnectionEvent =
+ | { type: 'START' }
+ | { type: 'STOP' }
+ | { type: 'SERVICE_READY' }
+ | { type: 'SERVICE_FAILED'; reason: string; kind?: 'auth' | 'service' }
+ | { type: 'SOCKET_UP' }
+ | { type: 'SOCKET_DOWN' }
+ | { type: 'PEER_PRESENT' }
+ | { type: 'PEER_ABSENT' }
+ | { type: 'ATTEMPT_OK'; attemptId: number }
+ | { type: 'ATTEMPT_FAILED'; attemptId: number; reason: string; terminal?: FailureKind }
+ | { type: 'DEADLINE'; attemptId: number }
+ | { type: 'CONNECTION_LOST'; reason: string }
+ | { type: 'SESSION_IDLE' }
+ | { type: 'RETRY_DUE' }
+ | { type: 'APP_BACKGROUND' }
+ | { type: 'APP_FOREGROUND' }
+ | { type: 'NATIVE_SNAPSHOT'; alive: boolean; channelOpen: boolean }
+ | { type: 'USER_RECONNECT' };
+
+export type ConnectionEffect =
+ | { type: 'startService' }
+ | { type: 'negotiate'; attemptId: number; mode: NegotiationMode }
+ | { type: 'armDeadline'; attemptId: number; ms: number }
+ | { type: 'scheduleRetry'; delayMs: number }
+ | { type: 'cancelTimers' }
+ | { type: 'teardown'; preserveNativePeer: boolean }
+ | { type: 'queryNativeState' };
+
+export interface TransitionResult {
+ state: ConnectionState;
+ effects: ConnectionEffect[];
+}
+
+/** UI projection consumed by the hook — the single place phase → UI maps. */
+export interface ConnectionUiState {
+ isConnected: boolean;
+ isLoading: boolean;
+ isReconnecting: boolean;
+ /** Current retry attempt number (0 when not retrying). */
+ reconnectAttempt: number;
+ /** Whether a manual "reconnect" affordance should be offered. */
+ canManualReconnect: boolean;
+ /** Terminal failure reason, or `null` outside the `failed` phase. */
+ failureReason: string | null;
+}
+
+/** Creates the machine's initial (stopped) state. */
+export function createInitialState(options?: { foreground?: boolean }): ConnectionState {
+ return {
+ phase: 'stopped',
+ serviceUp: false,
+ socketReady: false,
+ peerPresent: null,
+ hadSession: false,
+ foreground: options?.foreground ?? true,
+ nextAttemptId: 1,
+ retryCount: 0,
+ };
+}
+
+/** Exponential backoff schedule: 2s, 4s, 8s, 16s, 30s, 30s, … */
+export function backoffDelayMs(attempt: number): number {
+ const exponent = Math.max(1, attempt) - 1;
+ return Math.min(BACKOFF_BASE_MS * 2 ** exponent, BACKOFF_MAX_MS);
+}
+
+/** Log-friendly one-line description of a state. */
+export function describeState(state: ConnectionState): string {
+ const gates = `socket=${state.socketReady} peer=${String(state.peerPresent)} fg=${state.foreground}`;
+ switch (state.phase) {
+ case 'stopped':
+ return `stopped (${gates})`;
+ case 'starting':
+ return `starting attempt=${state.attemptId} (${gates})`;
+ case 'waiting':
+ return `waiting (${gates})`;
+ case 'negotiating':
+ return `negotiating attempt=${state.attemptId} mode=${state.mode} (${gates})`;
+ case 'connected':
+ return `connected (${gates})`;
+ case 'backoff':
+ return `backoff attempt=${state.attempt} delay=${state.delayMs}ms paused=${state.pausedInBackground} reason=${state.reason} (${gates})`;
+ case 'failed':
+ return `failed kind=${state.kind} reason=${state.reason} (${gates})`;
+ }
+}
+
+/** Maps a machine state to the flags the UI renders. */
+export function deriveUiState(state: ConnectionState): ConnectionUiState {
+ const retrying = state.phase === 'negotiating' || state.phase === 'backoff';
+ return {
+ isConnected: state.phase === 'connected',
+ // First-time bring-up (no prior session) shows the "Connecting…" spinner;
+ // this includes first-time backoff so the UI never goes blank mid-retry.
+ isLoading:
+ state.phase === 'starting' || state.phase === 'waiting' || (retrying && !state.hadSession),
+ isReconnecting: retrying && state.hadSession,
+ reconnectAttempt: state.phase === 'backoff' ? state.attempt : state.retryCount,
+ canManualReconnect: state.phase === 'failed' || state.phase === 'backoff',
+ failureReason: state.phase === 'failed' ? state.reason : null,
+ };
+}
+
+/** Extracts the shared context, dropping phase-specific fields. */
+function contextOf(state: ConnectionState): ConnectionContext {
+ const { serviceUp, socketReady, peerPresent, hadSession, foreground, nextAttemptId, retryCount } =
+ state;
+ return { serviceUp, socketReady, peerPresent, hadSession, foreground, nextAttemptId, retryCount };
+}
+
+function ignore(state: ConnectionState): TransitionResult {
+ return { state, effects: [] };
+}
+
+/** Same phase, patched context flags, no effects. */
+function patch(state: ConnectionState, changes: Partial): TransitionResult {
+ return { state: { ...state, ...changes }, effects: [] };
+}
+
+/** Enters `starting`: kick the native service and arm its deadline. */
+function beginStarting(ctx: ConnectionContext): TransitionResult {
+ const attemptId = ctx.nextAttemptId;
+ return {
+ state: {
+ ...ctx,
+ phase: 'starting',
+ attemptId,
+ serviceUp: false,
+ nextAttemptId: attemptId + 1,
+ },
+ effects: [
+ { type: 'startService' },
+ { type: 'armDeadline', attemptId, ms: STARTING_DEADLINE_MS },
+ ],
+ };
+}
+
+/** Enters `negotiating` with a fresh attempt id and an armed deadline. */
+function beginNegotiation(ctx: ConnectionContext, mode: NegotiationMode): TransitionResult {
+ const attemptId = ctx.nextAttemptId;
+ return {
+ state: {
+ ...ctx,
+ phase: 'negotiating',
+ attemptId,
+ mode,
+ nextAttemptId: attemptId + 1,
+ },
+ effects: [
+ { type: 'negotiate', attemptId, mode },
+ { type: 'armDeadline', attemptId, ms: NEGOTIATING_DEADLINE_MS },
+ ],
+ };
+}
+
+/**
+ * Enters `backoff` after a failure. Increments the consecutive-failure count,
+ * schedules the retry while foregrounded, or enters paused when backgrounded
+ * (the foreground snapshot reconcile takes over from there).
+ */
+function enterBackoff(
+ ctx: ConnectionContext,
+ reason: string,
+ options?: { teardown?: boolean },
+): TransitionResult {
+ const attempt = ctx.retryCount + 1;
+ const delayMs = backoffDelayMs(attempt);
+ const pausedInBackground = !ctx.foreground;
+ const effects: ConnectionEffect[] = [{ type: 'cancelTimers' }];
+ if (options?.teardown) {
+ effects.push({ type: 'teardown', preserveNativePeer: false });
+ }
+ if (!pausedInBackground) {
+ effects.push({ type: 'scheduleRetry', delayMs });
+ }
+ return {
+ state: {
+ ...ctx,
+ phase: 'backoff',
+ attempt,
+ delayMs,
+ reason,
+ pausedInBackground,
+ retryCount: attempt,
+ },
+ effects,
+ };
+}
+
+/**
+ * Starts a fresh attempt, routing by the current gates: restart the service
+ * if it is down, park in `waiting` while gated (socket down / peer known
+ * absent), otherwise negotiate a fresh `connect`.
+ */
+function beginFreshAttempt(ctx: ConnectionContext): TransitionResult {
+ if (!ctx.serviceUp) {
+ return beginStarting(ctx);
+ }
+ if (!ctx.socketReady || ctx.peerPresent === false) {
+ return { state: { ...ctx, phase: 'waiting' }, effects: [] };
+ }
+ return beginNegotiation(ctx, 'connect');
+}
+
+/** Prepends effects to a transition result. */
+function withEffects(effects: ConnectionEffect[], result: TransitionResult): TransitionResult {
+ return { state: result.state, effects: [...effects, ...result.effects] };
+}
+
+/**
+ * Reconciles against the native truth after a resume (or manual query reply).
+ * A surviving native peer is re-attached; anything else abandons stale
+ * in-flight work and starts over immediately with the delay counter reset.
+ */
+function reconcileSnapshot(
+ ctx: ConnectionContext,
+ event: { alive: boolean; channelOpen: boolean },
+ options?: { teardownStaleAttempt?: boolean },
+): TransitionResult {
+ const fresh: ConnectionContext = { ...ctx, retryCount: 0 };
+ const effects: ConnectionEffect[] = [{ type: 'cancelTimers' }];
+ if (event.alive && event.channelOpen) {
+ if (options?.teardownStaleAttempt) {
+ effects.push({ type: 'teardown', preserveNativePeer: true });
+ }
+ return withEffects(effects, beginNegotiation(fresh, 'attach'));
+ }
+ if (options?.teardownStaleAttempt) {
+ effects.push({ type: 'teardown', preserveNativePeer: false });
+ }
+ return withEffects(effects, beginFreshAttempt(fresh));
+}
+
+function transitionStarting(
+ state: Extract,
+ event: ConnectionEvent,
+): TransitionResult {
+ switch (event.type) {
+ case 'SERVICE_READY': {
+ const ctx = { ...contextOf(state), serviceUp: true };
+ if (ctx.socketReady && ctx.peerPresent !== false) {
+ return withEffects([{ type: 'cancelTimers' }], beginNegotiation(ctx, 'connect'));
+ }
+ return { state: { ...ctx, phase: 'waiting' }, effects: [{ type: 'cancelTimers' }] };
+ }
+ case 'DEADLINE':
+ if (event.attemptId !== state.attemptId) return ignore(state);
+ return enterBackoff(contextOf(state), 'service start timed out', { teardown: true });
+ case 'SOCKET_UP':
+ return patch(state, { socketReady: true });
+ case 'SOCKET_DOWN':
+ return patch(state, { socketReady: false });
+ case 'PEER_PRESENT':
+ return patch(state, { peerPresent: true });
+ case 'PEER_ABSENT':
+ return patch(state, { peerPresent: false });
+ case 'APP_BACKGROUND':
+ return patch(state, { foreground: false });
+ case 'APP_FOREGROUND':
+ return patch(state, { foreground: true });
+ default:
+ return ignore(state);
+ }
+}
+
+function transitionWaiting(
+ state: Extract,
+ event: ConnectionEvent,
+): TransitionResult {
+ switch (event.type) {
+ case 'SOCKET_UP': {
+ const ctx = { ...contextOf(state), socketReady: true };
+ if (ctx.peerPresent !== false) {
+ return beginNegotiation(ctx, 'connect');
+ }
+ return { state: { ...ctx, phase: 'waiting' }, effects: [] };
+ }
+ case 'SOCKET_DOWN':
+ return patch(state, { socketReady: false });
+ case 'PEER_PRESENT': {
+ const ctx = { ...contextOf(state), peerPresent: true };
+ if (ctx.socketReady) {
+ return beginNegotiation(ctx, 'connect');
+ }
+ return { state: { ...ctx, phase: 'waiting' }, effects: [] };
+ }
+ case 'PEER_ABSENT':
+ return patch(state, { peerPresent: false });
+ case 'SERVICE_READY':
+ return patch(state, { serviceUp: true });
+ case 'APP_BACKGROUND':
+ return patch(state, { foreground: false });
+ case 'APP_FOREGROUND':
+ return patch(state, { foreground: true });
+ case 'NATIVE_SNAPSHOT':
+ // The native service still holds a live connection (it survived a
+ // relaunch/background): attach to it instead of waiting out the gates —
+ // a live open channel is itself proof both peers exist.
+ if (event.alive && event.channelOpen) {
+ return withEffects(
+ [{ type: 'cancelTimers' }],
+ beginNegotiation({ ...contextOf(state), retryCount: 0 }, 'attach'),
+ );
+ }
+ return ignore(state);
+ default:
+ return ignore(state);
+ }
+}
+
+function transitionNegotiating(
+ state: Extract,
+ event: ConnectionEvent,
+): TransitionResult {
+ switch (event.type) {
+ case 'ATTEMPT_OK': {
+ if (event.attemptId !== state.attemptId) return ignore(state);
+ const ctx = { ...contextOf(state), hadSession: true, retryCount: 0 };
+ return { state: { ...ctx, phase: 'connected' }, effects: [{ type: 'cancelTimers' }] };
+ }
+ case 'ATTEMPT_FAILED': {
+ if (event.attemptId !== state.attemptId) return ignore(state);
+ if (event.terminal) {
+ return {
+ state: {
+ ...contextOf(state),
+ phase: 'failed',
+ reason: event.reason,
+ kind: event.terminal,
+ },
+ effects: [{ type: 'cancelTimers' }, { type: 'teardown', preserveNativePeer: false }],
+ };
+ }
+ return enterBackoff(contextOf(state), event.reason, { teardown: true });
+ }
+ case 'DEADLINE':
+ if (event.attemptId !== state.attemptId) return ignore(state);
+ return enterBackoff(contextOf(state), 'negotiation deadline expired', { teardown: true });
+ case 'SOCKET_DOWN':
+ return enterBackoff({ ...contextOf(state), socketReady: false }, 'signaling socket lost', {
+ teardown: true,
+ });
+ case 'PEER_ABSENT':
+ return enterBackoff({ ...contextOf(state), peerPresent: false }, 'peer went offline', {
+ teardown: true,
+ });
+ case 'SOCKET_UP':
+ return patch(state, { socketReady: true });
+ case 'PEER_PRESENT':
+ return patch(state, { peerPresent: true });
+ case 'SERVICE_READY':
+ return patch(state, { serviceUp: true });
+ case 'APP_BACKGROUND':
+ return patch(state, { foreground: false });
+ case 'APP_FOREGROUND':
+ return {
+ state: { ...state, foreground: true },
+ effects: [{ type: 'queryNativeState' }],
+ };
+ case 'NATIVE_SNAPSHOT':
+ // Abandon the (possibly wedged) in-flight attempt and follow the truth.
+ return reconcileSnapshot(contextOf(state), event, { teardownStaleAttempt: true });
+ default:
+ return ignore(state);
+ }
+}
+
+function transitionConnected(
+ state: Extract,
+ event: ConnectionEvent,
+): TransitionResult {
+ switch (event.type) {
+ case 'CONNECTION_LOST':
+ return enterBackoff(contextOf(state), event.reason, { teardown: true });
+ case 'SESSION_IDLE':
+ // A genuinely quiet session is closed for good: auto-retrying would
+ // churn connections nobody is using, so recovery is the manual
+ // Reconnect bar (USER_RECONNECT).
+ return {
+ state: { ...contextOf(state), phase: 'failed', reason: 'Session idle', kind: 'idle' },
+ effects: [{ type: 'cancelTimers' }, { type: 'teardown', preserveNativePeer: false }],
+ };
+ case 'PEER_ABSENT':
+ return enterBackoff({ ...contextOf(state), peerPresent: false }, 'peer went offline', {
+ teardown: true,
+ });
+ case 'SOCKET_UP':
+ return patch(state, { socketReady: true });
+ case 'SOCKET_DOWN':
+ // The p2p link can outlive the signaling socket; stay connected and let
+ // the liveness detectors (heartbeat/ICE) report a real loss.
+ return patch(state, { socketReady: false });
+ case 'PEER_PRESENT':
+ return patch(state, { peerPresent: true });
+ case 'SERVICE_READY':
+ return patch(state, { serviceUp: true });
+ case 'APP_BACKGROUND':
+ return patch(state, { foreground: false });
+ case 'APP_FOREGROUND':
+ return {
+ state: { ...state, foreground: true },
+ effects: [{ type: 'queryNativeState' }],
+ };
+ case 'NATIVE_SNAPSHOT':
+ if (event.alive && event.channelOpen) return ignore(state);
+ // Snapshot says the native side died while we were suspended.
+ return enterBackoff(contextOf(state), 'native connection dead after resume', {
+ teardown: true,
+ });
+ default:
+ return ignore(state);
+ }
+}
+
+function transitionBackoff(
+ state: Extract,
+ event: ConnectionEvent,
+): TransitionResult {
+ switch (event.type) {
+ case 'RETRY_DUE':
+ if (state.pausedInBackground) return ignore(state);
+ return beginFreshAttempt(contextOf(state));
+ case 'APP_BACKGROUND':
+ return {
+ state: { ...state, foreground: false, pausedInBackground: true },
+ effects: [{ type: 'cancelTimers' }],
+ };
+ case 'APP_FOREGROUND':
+ return {
+ state: { ...state, foreground: true },
+ effects: [{ type: 'queryNativeState' }],
+ };
+ case 'NATIVE_SNAPSHOT':
+ return reconcileSnapshot(contextOf(state), event);
+ case 'USER_RECONNECT':
+ return withEffects(
+ [{ type: 'cancelTimers' }],
+ beginFreshAttempt({ ...contextOf(state), retryCount: 0 }),
+ );
+ case 'SOCKET_UP':
+ return patch(state, { socketReady: true });
+ case 'SOCKET_DOWN':
+ return patch(state, { socketReady: false });
+ case 'PEER_PRESENT':
+ return patch(state, { peerPresent: true });
+ case 'PEER_ABSENT':
+ return patch(state, { peerPresent: false });
+ case 'SERVICE_READY':
+ return patch(state, { serviceUp: true });
+ default:
+ return ignore(state);
+ }
+}
+
+function transitionFailed(
+ state: Extract,
+ event: ConnectionEvent,
+): TransitionResult {
+ switch (event.type) {
+ case 'USER_RECONNECT':
+ return beginFreshAttempt({ ...contextOf(state), retryCount: 0 });
+ case 'SOCKET_UP':
+ return patch(state, { socketReady: true });
+ case 'SOCKET_DOWN':
+ return patch(state, { socketReady: false });
+ case 'PEER_PRESENT':
+ return patch(state, { peerPresent: true });
+ case 'PEER_ABSENT':
+ return patch(state, { peerPresent: false });
+ case 'APP_BACKGROUND':
+ return patch(state, { foreground: false });
+ case 'APP_FOREGROUND':
+ return patch(state, { foreground: true });
+ default:
+ return ignore(state);
+ }
+}
+
+/**
+ * The reducer. Deterministic and side-effect free: interpreting the returned
+ * effects (timers, native calls, teardown) is the caller's job.
+ */
+export function transition(state: ConnectionState, event: ConnectionEvent): TransitionResult {
+ if (state.phase === 'stopped') {
+ if (event.type === 'START') {
+ return beginStarting(contextOf(state));
+ }
+ return ignore(state);
+ }
+
+ if (event.type === 'STOP') {
+ return {
+ state: {
+ ...contextOf(state),
+ phase: 'stopped',
+ serviceUp: false,
+ socketReady: false,
+ peerPresent: null,
+ hadSession: false,
+ retryCount: 0,
+ },
+ effects: [{ type: 'cancelTimers' }, { type: 'teardown', preserveNativePeer: false }],
+ };
+ }
+
+ if (event.type === 'SERVICE_FAILED') {
+ return {
+ state: {
+ ...contextOf(state),
+ phase: 'failed',
+ reason: event.reason,
+ kind: event.kind ?? 'service',
+ serviceUp: false,
+ },
+ effects: [{ type: 'cancelTimers' }, { type: 'teardown', preserveNativePeer: false }],
+ };
+ }
+
+ switch (state.phase) {
+ case 'starting':
+ return transitionStarting(state, event);
+ case 'waiting':
+ return transitionWaiting(state, event);
+ case 'negotiating':
+ return transitionNegotiating(state, event);
+ case 'connected':
+ return transitionConnected(state, event);
+ case 'backoff':
+ return transitionBackoff(state, event);
+ case 'failed':
+ return transitionFailed(state, event);
+ }
+}
diff --git a/lib/ac2/idleSession.ts b/lib/ac2/idleSession.ts
new file mode 100644
index 0000000..63eb9be
--- /dev/null
+++ b/lib/ac2/idleSession.ts
@@ -0,0 +1,82 @@
+/**
+ * Idle-session policy for `useConnection`'s inactivity watchdog, extracted as a
+ * pure function so the resume-after-background behavior is unit-testable.
+ *
+ * Liveness is owned by the ICE monitor + heartbeat watchdog (they detect a
+ * dead transport within seconds and auto-reconnect); the idle watchdog is a
+ * slower secondary safety net that (a) tears down a genuinely idle session and
+ * (b) recovers a connection that went stale while backgrounded (JS timers are
+ * suspended there, so the fast detectors can't fire until foreground).
+ *
+ * The subtlety this module exists for: after a LONG background the liveness
+ * clocks are hours old — but that gap says nothing about the connection's
+ * actual health, because the native background service keeps the peer alive
+ * independently of the JS runtime. On resume, the watchdog's suspended
+ * interval can fire BEFORE the AppState listener's snapshot reconcile runs,
+ * so judging by the stale clocks alone tore down verified-live connections
+ * ("Closing stale connection after background" on a session whose heartbeat
+ * channel was still delivering). When the idleness is explained by the
+ * background gap, the native truth must be consulted first: an open control
+ * channel means the session is NOT stale — refresh the clocks and let the
+ * heartbeats resume.
+ */
+
+/** What the inactivity watchdog should do on this tick. */
+export type IdleSessionVerdict =
+ /** Recent activity — nothing to do. */
+ | { action: 'none' }
+ /**
+ * The clocks are stale only because of a background gap and the native
+ * service still holds the open channel: the session is alive. Reset the
+ * liveness clocks (and the backgrounded flag) instead of tearing down.
+ */
+ | { action: 'refresh' }
+ /**
+ * Went stale while backgrounded and the native side is dead too —
+ * recoverable; tear down and let the machine auto-reconnect.
+ */
+ | { action: 'close-stale' }
+ /**
+ * A genuine foreground idle (or an intentional stop) — terminal until the
+ * user acts (avoids churn / repeated biometric prompts).
+ */
+ | { action: 'close-idle' };
+
+export interface IdleSessionInput {
+ now: number;
+ /** Last inbound peer traffic (frames / heartbeat pongs). */
+ lastInboundAt: number;
+ /** Last local user action. */
+ lastLocalAt: number;
+ idleTimeoutMs: number;
+ /** The user explicitly disconnected — never auto-resume. */
+ userStopped: boolean;
+ /** The app was backgrounded since the last inbound heartbeat. */
+ wasBackgrounded: boolean;
+ /**
+ * Pulls the native truth: does the background service still hold a live
+ * connection with an open control channel for THIS session? Only consulted
+ * when the idleness is explained by a background gap; a throw (native
+ * module unavailable) is treated as "not open".
+ */
+ isNativeChannelOpen: () => boolean;
+}
+
+export function evaluateIdleSession(input: IdleSessionInput): IdleSessionVerdict {
+ const lastActivity = Math.max(input.lastInboundAt, input.lastLocalAt);
+ if (input.now - lastActivity < input.idleTimeoutMs) return { action: 'none' };
+
+ // A stale session whose idleness is explained by the app having been
+ // backgrounded should recover automatically; a genuine foreground idle
+ // should not. An intentional disconnect never resumes.
+ const staleFromBackground = !input.userStopped && input.wasBackgrounded;
+ if (!staleFromBackground) return { action: 'close-idle' };
+
+ let channelOpen = false;
+ try {
+ channelOpen = input.isNativeChannelOpen();
+ } catch {
+ /* native module unavailable (tests / web) — treat as dead */
+ }
+ return channelOpen ? { action: 'refresh' } : { action: 'close-stale' };
+}
diff --git a/lib/ac2/index.ts b/lib/ac2/index.ts
index 9c5b3d2..f7cdd0c 100644
--- a/lib/ac2/index.ts
+++ b/lib/ac2/index.ts
@@ -18,15 +18,68 @@ export { attachHeartbeatChannel } from './heartbeat';
export type { HeartbeatChannelOptions } from './heartbeat';
export { createHeartbeatMonitor } from './heartbeatMonitor';
export type { HeartbeatMonitor, HeartbeatMonitorOptions } from './heartbeatMonitor';
+export { evaluateIdleSession } from './idleSession';
+export type { IdleSessionInput, IdleSessionVerdict } from './idleSession';
export { monitorPeerConnection } from './peerConnectionMonitor';
export type {
MonitoredPeerConnection,
MonitorPeerConnectionOptions,
PeerConnectionFailureReason,
} from './peerConnectionMonitor';
-export { parseStreamControlFrame, STX } from './stream';
-export type { AgentPresence, StreamControlFrame } from './stream';
-export { createAc2Transport } from './transport';
-export type { Ac2TransportSetup, CreateAc2TransportOptions } from './transport';
+export {
+ hasPeerPresence,
+ isPeerOffline,
+ isPeerRejectedError,
+ isPeerUnreachableError,
+ normalizePresence,
+ PRESENCE_EVENT,
+ queryPresence,
+ subscribeToPresence,
+} from './presence';
+export type { PresenceResult, PresenceSocket } from './presence';
+export {
+ isRegistrationBlockingNotice,
+ normalizeNoticeFrame,
+ parseStreamControlFrame,
+ REGISTRATION_BLOCKING_NOTICE_CODES,
+ selectConnectionNoticeForRequest,
+ STX,
+} from './stream';
+export type {
+ AgentPresence,
+ ConnectionNotice,
+ NoticeLevel,
+ ScopedConnectionNotice,
+ StreamControlFrame,
+} from './stream';
+export { NativeDataChannel, NativePeerConnection } from './nativeChannel';
+export type { DataChannelMessageEvent, DataChannelReadyState } from './nativeChannel';
+export {
+ AC2_CONTROL_CHANNEL,
+ addNativePresenceListener,
+ addNativeSignalingStateListener,
+ cancelNativeNegotiation,
+ createNativeAc2Transport,
+ DEFAULT_AC2_QUEUE_CHANNELS,
+ flushNativeQueue,
+ getNativeConnectionState,
+ isSnapshotChannelOpen,
+ nativeAuthFetch,
+ setNativeActive,
+ startNativeService,
+ stopNativeService,
+} from './nativeTransport';
+export type {
+ CreateNativeAc2TransportOptions,
+ LiquidAuthNativeApi,
+ NativeAc2TransportSetup,
+ NativeConnectionStateSnapshot,
+ NativeDataChannelInit,
+ NativeIceServer,
+ NativeLinkErrorEvent,
+ NativePresenceEvent,
+ NativeSignalingStateEvent,
+ NativeSubscription,
+} from './nativeTransport';
export type { AC2BaseMessage as Ac2Message } from '@algorandfoundation/ac2-sdk/schema';
diff --git a/lib/ac2/nativeChannel.ts b/lib/ac2/nativeChannel.ts
new file mode 100644
index 0000000..05e1683
--- /dev/null
+++ b/lib/ac2/nativeChannel.ts
@@ -0,0 +1,331 @@
+/**
+ * Adapter shims that present `react-native-liquid-auth`'s *event-based* native
+ * background service as the `RTCDataChannel`- and `RTCPeerConnection`-shaped
+ * objects that connection code written against `react-native-webrtc` already
+ * consumes.
+ *
+ * The native module owns the signaling socket + WebRTC peer in a foreground
+ * service and only surfaces messages/state as events (`onMessage`,
+ * `onStateChange`, `onConnectionStateChange`, ...). Consumers, however, are
+ * frequently written against live `RTCDataChannel` objects from
+ * `react-native-webrtc` (`.send`, `.readyState`, `.onmessage`,
+ * `.addEventListener('open')`, `.bufferedAmount`) and a monitored
+ * `RTCPeerConnection` (`.iceConnectionState`, `.addEventListener`).
+ *
+ * These shims bridge that gap so adopting the native service changes downstream
+ * consumers minimally: a transport routes native events into per-channel shim
+ * instances, and the shims re-emit them through the classic
+ * DataChannel/PeerConnection APIs.
+ *
+ * The native side stringifies WebRTC enums verbatim, so states arrive
+ * UPPERCASE (`OPEN`, `CLOSING`, `CONNECTED`, `FAILED`, ...); both shims
+ * lowercase them to match the `RTCDataChannel.readyState` union and the ICE
+ * states an ICE connection-state monitor expects.
+ */
+
+/** The `RTCDataChannel.readyState` union a transport wrapper expects. */
+export type DataChannelReadyState = 'connecting' | 'open' | 'closing' | 'closed';
+
+/** Shape of the message event a `.onmessage` handler reads. */
+export interface DataChannelMessageEvent {
+ data: string;
+}
+
+type ChannelEventType = 'open' | 'close' | 'error' | 'message';
+type PeerEventType = 'iceconnectionstatechange' | 'connectionstatechange';
+
+/**
+ * Lowercase a native WebRTC enum string (e.g. `"OPEN"` -> `"open"`), tolerating
+ * a `null`/`undefined` state by treating it as `closed` (the native side sends
+ * `null` when a channel/peer has no meaningful state left).
+ */
+function normalizeState(state: string | null | undefined): string {
+ return (state ?? 'closed').toLowerCase();
+}
+
+/**
+ * An `RTCDataChannel`-shaped adapter backed by the native background service.
+ *
+ * A single instance represents one named channel (e.g. `ac2-v1`). The owning
+ * transport factory feeds it native events via {@link dispatchMessage} /
+ * {@link setState}; consumers interact with it exactly as they would a real
+ * `RTCDataChannel`.
+ *
+ * Both the property-style handlers (`onopen`/`onmessage`/...) and the
+ * `addEventListener` style are supported.
+ */
+export class NativeDataChannel {
+ readonly label: string;
+
+ /** Mirrors `RTCDataChannel.bufferedAmount`; the native path never buffers in JS. */
+ bufferedAmount = 0;
+
+ onopen: ((ev?: unknown) => void) | null = null;
+ onclose: ((ev?: unknown) => void) | null = null;
+ onerror: ((ev?: unknown) => void) | null = null;
+
+ private _readyState: DataChannelReadyState = 'connecting';
+ private readonly _send: (label: string, message: string) => void;
+ private readonly _listeners = new Map void>>();
+ // Backing field for the `onmessage` accessor. Using a setter (rather than a
+ // plain field) lets us flush any messages buffered before a consumer
+ // attached — see `dispatchMessage`.
+ private _onmessage: ((ev: DataChannelMessageEvent) => void) | null = null;
+ // Messages that arrived before a consumer (`onmessage` or a `message`
+ // listener) was attached. This happens on the hydrate/attach path: the
+ // background service replays its offline queue during `attach()`, which can
+ // fire before the SDK client wires `onmessage` on the control channel. We
+ // buffer here and flush once a consumer attaches so no replayed request
+ // (message received while the app was closed) is ever lost.
+ private readonly _pending: string[] = [];
+ // Whether a flush of `_pending` is already scheduled on the microtask queue.
+ // The flush is deferred (not synchronous) because a consumer such as the AC2
+ // SDK's `rtcDataChannelTransport` assigns `onmessage` FIRST and only then
+ // registers its real inbound handlers (`onMessage`/`onRawMessage`) on the
+ // very next lines. A synchronous flush would replay the buffered messages
+ // through that `onmessage` bridge while its downstream handlers are still
+ // null, silently dropping them. Deferring to a microtask lets the consumer
+ // finish wiring (all synchronous) before the backlog is delivered.
+ private _flushScheduled = false;
+
+ constructor(label: string, send: (label: string, message: string) => void) {
+ this.label = label;
+ this._send = send;
+ }
+
+ get readyState(): DataChannelReadyState {
+ return this._readyState;
+ }
+
+ get onmessage(): ((ev: DataChannelMessageEvent) => void) | null {
+ return this._onmessage;
+ }
+
+ /** Attaching a message handler flushes anything buffered before it existed. */
+ set onmessage(handler: ((ev: DataChannelMessageEvent) => void) | null) {
+ this._onmessage = handler;
+ if (handler) this._scheduleFlush();
+ }
+
+ /** Send a frame over this channel through the native service. */
+ send(data: string): void {
+ this._send(this.label, data);
+ }
+
+ /**
+ * Locally mark the channel closed. There is no per-channel native close
+ * (teardown happens via the service's `disconnect`), so this only flips the
+ * local state and fires `close`, matching how consumers observe a closed
+ * channel.
+ */
+ close(): void {
+ if (this._readyState === 'closed') return;
+ this._readyState = 'closed';
+ this._fireClose();
+ }
+
+ addEventListener(type: ChannelEventType, listener: (ev?: unknown) => void): void {
+ const set = this._listeners.get(type) ?? new Set();
+ set.add(listener);
+ this._listeners.set(type, set);
+ // A newly attached message consumer flushes anything buffered before it.
+ if (type === 'message') this._scheduleFlush();
+ }
+
+ removeEventListener(type: ChannelEventType, listener: (ev?: unknown) => void): void {
+ this._listeners.get(type)?.delete(listener);
+ }
+
+ /** Route a native `onMessage` frame for this channel to the consumer. */
+ dispatchMessage(message: string): void {
+ // Buffer when there is no consumer yet (e.g. the SDK client hasn't wired
+ // `onmessage` on the control channel during hydrate) OR when a deferred
+ // flush of earlier buffered messages is still pending — appending keeps
+ // delivery in strict arrival order rather than letting a live frame jump
+ // ahead of the backlog. The scheduled flush drains everything in order.
+ if (!this._hasMessageConsumer() || this._pending.length > 0 || this._flushScheduled) {
+ this._pending.push(message);
+ console.log(
+ `[ac2-native] channel[${this.label}] buffered message (no consumer / flush pending); pending=${this._pending.length}`,
+ );
+ if (this._hasMessageConsumer()) this._scheduleFlush();
+ return;
+ }
+ console.log(`[ac2-native] channel[${this.label}] delivering message live`);
+ this._deliver(message);
+ }
+
+ /** Deliver a single message to the attached consumer(s). */
+ private _deliver(message: string): void {
+ const event: DataChannelMessageEvent = { data: message };
+ console.log(
+ `[ac2-native] channel[${this.label}] _deliver -> onmessage=${this._onmessage != null} ` +
+ `listeners=${this._listeners.get('message')?.size ?? 0}`,
+ );
+ try {
+ this._onmessage?.(event);
+ } catch {
+ /* consumer handler threw; do not break dispatch */
+ }
+ this._emit('message', event);
+ }
+
+ /** Whether a message handler (`onmessage` or a `message` listener) exists. */
+ private _hasMessageConsumer(): boolean {
+ return this._onmessage != null || (this._listeners.get('message')?.size ?? 0) > 0;
+ }
+
+ /**
+ * Schedule a deferred flush of the buffered backlog. Deferred to a microtask
+ * (not synchronous) so a consumer that assigns `onmessage` and only then
+ * wires its real inbound handlers — like the AC2 SDK's
+ * `rtcDataChannelTransport` — has finished all of its synchronous setup
+ * before the backlog is replayed, otherwise the replay would hit not-yet-set
+ * handlers and be dropped. No-op if nothing is buffered or a flush is already
+ * pending.
+ */
+ private _scheduleFlush(): void {
+ if (this._flushScheduled || this._pending.length === 0) return;
+ this._flushScheduled = true;
+ const run = () => {
+ this._flushScheduled = false;
+ this._flushPending();
+ };
+ if (typeof queueMicrotask === 'function') {
+ queueMicrotask(run);
+ } else {
+ void Promise.resolve().then(run);
+ }
+ }
+
+ /** Flush (and clear) any messages buffered before a consumer attached. */
+ private _flushPending(): void {
+ if (this._pending.length === 0) return;
+ const buffered = this._pending.splice(0, this._pending.length);
+ console.log(
+ `[ac2-native] channel[${this.label}] flushing ${buffered.length} buffered message(s) to consumer`,
+ );
+ for (const message of buffered) this._deliver(message);
+ }
+
+ /**
+ * Apply a native `onStateChange` for this channel. Transitions to `open`
+ * fire `open`; transitions to `closed` fire `close`. The uppercase native
+ * enum is lowercased to the `RTCDataChannel.readyState` union.
+ */
+ setState(state: string | null | undefined): void {
+ const next = normalizeState(state);
+ const readyState = (['connecting', 'open', 'closing', 'closed'] as const).includes(
+ next as DataChannelReadyState,
+ )
+ ? (next as DataChannelReadyState)
+ : 'closed';
+ if (readyState === this._readyState) return;
+ this._readyState = readyState;
+ if (readyState === 'open') this._fireOpen();
+ else if (readyState === 'closed') this._fireClose();
+ }
+
+ /** Surface a native transport error to the consumer. */
+ dispatchError(err?: unknown): void {
+ try {
+ this.onerror?.(err);
+ } catch {
+ /* consumer handler threw; do not break dispatch */
+ }
+ this._emit('error', err);
+ }
+
+ private _fireOpen(): void {
+ try {
+ this.onopen?.();
+ } catch {
+ /* noop */
+ }
+ this._emit('open');
+ }
+
+ private _fireClose(): void {
+ try {
+ this.onclose?.();
+ } catch {
+ /* noop */
+ }
+ this._emit('close');
+ }
+
+ private _emit(type: ChannelEventType, ev?: unknown): void {
+ const set = this._listeners.get(type);
+ if (!set) return;
+ for (const listener of [...set]) {
+ try {
+ listener(ev);
+ } catch {
+ /* listener threw; keep dispatching */
+ }
+ }
+ }
+}
+
+/**
+ * An `RTCPeerConnection`-shaped adapter exposing only the surface an ICE
+ * connection-state monitor reads: `iceConnectionState`, `connectionState`, and
+ * `addEventListener`/`removeEventListener` for the two state-change events.
+ *
+ * The native service reports a single ICE connection state string via
+ * `onConnectionStateChange`; it is lowercased into `iceConnectionState` (and
+ * mirrored into `connectionState`) and re-emitted so the monitor's failure
+ * detection works unchanged.
+ */
+export class NativePeerConnection {
+ iceConnectionState = 'new';
+ connectionState = 'new';
+
+ private readonly _listeners = new Map void>>();
+
+ addEventListener(type: string, listener: (ev?: unknown) => void): void {
+ const key = type as PeerEventType;
+ const set = this._listeners.get(key) ?? new Set();
+ set.add(listener);
+ this._listeners.set(key, set);
+ }
+
+ removeEventListener(type: string, listener: (ev?: unknown) => void): void {
+ this._listeners.get(type as PeerEventType)?.delete(listener);
+ }
+
+ /**
+ * Apply a native ICE connection-state change. The monitor treats
+ * `iceConnectionState` as authoritative and only reads `connectionState` for
+ * the terminal `failed`/`closed` states, so mirroring the same lowercased
+ * value into both (and firing both events) satisfies it exactly.
+ */
+ setConnectionState(state: string | null | undefined): void {
+ const next = normalizeState(state);
+ this.iceConnectionState = next;
+ this.connectionState = next;
+ this._emit('iceconnectionstatechange');
+ this._emit('connectionstatechange');
+ }
+
+ /** Mark the peer closed locally and notify the monitor. */
+ close(): void {
+ if (this.iceConnectionState === 'closed' && this.connectionState === 'closed') return;
+ this.iceConnectionState = 'closed';
+ this.connectionState = 'closed';
+ this._emit('iceconnectionstatechange');
+ this._emit('connectionstatechange');
+ }
+
+ private _emit(type: PeerEventType): void {
+ const set = this._listeners.get(type);
+ if (!set) return;
+ for (const listener of [...set]) {
+ try {
+ listener();
+ } catch {
+ /* listener threw; keep dispatching */
+ }
+ }
+ }
+}
diff --git a/lib/ac2/nativeTransport.ts b/lib/ac2/nativeTransport.ts
new file mode 100644
index 0000000..0a018d9
--- /dev/null
+++ b/lib/ac2/nativeTransport.ts
@@ -0,0 +1,653 @@
+/**
+ * Native-backed AC2 transport: the Phase-4 replacement for the in-process
+ * `@algorandfoundation/liquid-client` + `react-native-webrtc` path in
+ * `./transport`.
+ *
+ * Instead of running signaling/WebRTC inside the JS runtime (which goes stale
+ * when the app is backgrounded), this drives `react-native-liquid-auth`'s
+ * native foreground `SignalService` and routes its events
+ * (`onMessage`/`onStateChange`/`onConnectionStateChange`/`onPresence`/
+ * `onLinkError`) into the `RTCDataChannel`/`RTCPeerConnection`-shaped shims in
+ * `./nativeChannel`. Downstream consumers (the AC2 SDK client, heartbeat,
+ * stream, and the connectivity monitor) keep working against those shims
+ * unchanged.
+ *
+ * The negotiation resolves once the control channel (`ac2-v1`) is `open`,
+ * mirroring `createAc2Transport`'s post-negotiation `waitForChannelOpen` guard
+ * so a peer whose ICE never establishes fails fast into the caller's retry path
+ * rather than hanging.
+ */
+
+import { NativeDataChannel, NativePeerConnection } from './nativeChannel';
+import type { PresenceResult } from './presence';
+import {
+ CHANNEL_OPEN_TIMEOUT_MS,
+ DEFAULT_DATA_CHANNELS,
+ DEFAULT_ICE_SERVERS,
+ waitForChannelOpen,
+} from './transport';
+
+/** The AC2 control-plane channel label the SDK client binds to. */
+export const AC2_CONTROL_CHANNEL = 'ac2-v1' as const;
+
+/** A single ICE server, matching `react-native-liquid-auth`'s `IceServer`. */
+export interface NativeIceServer {
+ urls: string | string[];
+ username?: string;
+ credential?: string;
+}
+
+/** Options for a single named data channel (mirrors `RTCDataChannelInit`). */
+export interface NativeDataChannelInit {
+ ordered?: boolean;
+ maxRetransmits?: number;
+ maxPacketLifeTime?: number;
+ protocol?: string;
+ negotiated?: boolean;
+ id?: number;
+}
+
+/** A single notification template (mirrors the module's `NotificationTemplate`). */
+export interface NativeNotificationTemplate {
+ title?: string;
+ body?: string;
+}
+
+/**
+ * Status copy for the single ongoing foreground-service notification (mirrors
+ * the module's `NotificationStatus`). The native service renders these while
+ * the app is backgrounded — even when the JS runtime is suspended/killed — so
+ * the copy must live here (wallet-owned), not in the shared library. The
+ * notification text reflects the service state:
+ * - `connected`: the app is foreground / attached.
+ * - `idle`: the app is closed with nothing waiting ("tap to open").
+ * - `messages`: message(s) arrived while the app was closed.
+ */
+export interface NativeNotificationConfig {
+ /**
+ * Channel labels whose inbound messages do NOT flip the notification into
+ * the `messages` state (control traffic). They are still buffered/replayed,
+ * just not announced.
+ */
+ suppressChannels?: string[];
+ /** Ongoing notification while the app is foreground/connected. */
+ connected?: NativeNotificationTemplate;
+ /** Ongoing notification while the app is closed with no pending messages. */
+ idle?: NativeNotificationTemplate;
+ /** Ongoing notification while the app is closed with pending messages. */
+ messages?: NativeNotificationTemplate;
+}
+
+/**
+ * The wallet's default notification copy for the background service. The
+ * ongoing notification reflects the service state: connected (app open),
+ * "Tap to open the app" (closed, idle), or "You have new messages" (closed,
+ * message[s] arrived). Only `ac2-heartbeat` is suppressed (it is pure liveness
+ * ping/pong); inbound traffic on ANY other channel (`ac2-v1`, `ac2-stream`, …)
+ * flips the notification into the "new messages" state.
+ */
+export const DEFAULT_AC2_NOTIFICATIONS: NativeNotificationConfig = {
+ suppressChannels: ['ac2-heartbeat'],
+ connected: {
+ title: 'AC2 Wallet',
+ body: 'Connected to the signaling service',
+ },
+ idle: {
+ title: 'AC2 Wallet',
+ body: 'Tap to open the app.',
+ },
+ messages: {
+ title: 'AC2 Wallet',
+ body: 'You have new messages.',
+ },
+};
+
+/**
+ * The wallet's default set of channels the native service buffers while the
+ * app is offline (and replays via `onMessage` once it comes back online). The
+ * deliverable channels carry app requests: `ac2-v1` (the SDK control plane)
+ * and `ac2-stream` (control frames / messages to deliver). `ac2-heartbeat` is
+ * intentionally excluded — it is pure liveness ping/pong, not a deliverable
+ * request. Any inbound activity on ANY channel still counts as liveness.
+ */
+export const DEFAULT_AC2_QUEUE_CHANNELS: string[] = ['ac2-v1', 'ac2-stream'];
+
+/** Heartbeat keep-alive configuration (mirrors the native `HeartbeatConfig`). */
+export interface NativeHeartbeatConfig {
+ channel: string;
+ ping?: string;
+ pong?: string;
+}
+
+/**
+ * The wallet's heartbeat keep-alive. While the app is offline (backgrounded /
+ * closed) the JS ping/pong reply in `attachHeartbeatChannel` is dead, so the
+ * native background service itself answers the agent's `ping` on the
+ * `ac2-heartbeat` channel with a `pong`. This keeps the agent's liveness
+ * watchdog satisfied so it does not close the p2p connection while the app is
+ * away — the whole point of the survive-app-close service. The JS path still
+ * handles ping/pong (and interval pinging) while the app is foregrounded.
+ */
+export const DEFAULT_AC2_HEARTBEAT: NativeHeartbeatConfig = {
+ channel: 'ac2-heartbeat',
+ ping: 'ping',
+ pong: 'pong',
+};
+
+/** Native-broadcast presence payload (mirrors {@link PresenceResult}). */
+export interface NativePresenceEvent {
+ requestId: string;
+ deviceCount: number;
+ online: boolean;
+}
+
+/**
+ * Native signaling-socket connectivity payload (`connected`/`disconnected`),
+ * including socket.io auto-reconnects. Independent of the p2p connection —
+ * data channels deliberately survive signaling disruptions — so the app can
+ * show a dedicated "signaling server offline" state.
+ */
+export interface NativeSignalingStateEvent {
+ state: 'connected' | 'disconnected';
+}
+
+/** Native signaling link-error payload (e.g. the two-peer lockdown refusal). */
+export interface NativeLinkErrorEvent {
+ event?: string;
+ requestId?: string;
+ reason?: string;
+ message?: string;
+}
+
+/** A removable native event subscription (Expo's `EventSubscription`). */
+export interface NativeSubscription {
+ remove(): void;
+}
+
+/**
+ * A snapshot of the background service's CURRENT connection (mirrors the
+ * module's `LiquidAuthConnectionState`), so a re-attaching app can hydrate
+ * instead of assuming a fresh start / renegotiating.
+ */
+export interface NativeConnectionStateSnapshot {
+ connected: boolean;
+ requestId: string | null;
+ iceConnectionState: string | null;
+ channels: Record;
+ /**
+ * Whether the persistent signaling socket is currently connected. Optional
+ * so older native binaries / test fakes without the field keep working
+ * (treat `undefined` as unknown).
+ */
+ signalingConnected?: boolean;
+}
+
+/**
+ * The subset of the `react-native-liquid-auth` module this factory uses.
+ * Declared as an injectable interface so the transport is unit-testable with a
+ * fake and does not hard-depend on the native package at module load time.
+ */
+export interface LiquidAuthNativeApi {
+ start(url: string): Promise;
+ connect(
+ requestId: string,
+ type: 'offer' | 'answer',
+ iceServers?: NativeIceServer[],
+ options?: {
+ dataChannels?: Record;
+ notifications?: NativeNotificationConfig;
+ queueChannels?: string[];
+ heartbeat?: NativeHeartbeatConfig;
+ },
+ ): Promise;
+ cancel(): Promise;
+ getConnectionState(): NativeConnectionStateSnapshot;
+ attach(options?: {
+ dataChannels?: Record;
+ notifications?: NativeNotificationConfig;
+ queueChannels?: string[];
+ heartbeat?: NativeHeartbeatConfig;
+ }): Promise;
+ setActive(active: boolean): void;
+ /**
+ * Explicitly replay the service's offline message queue through `onMessage`
+ * (in arrival order). Optional so older native binaries / test fakes without
+ * the method keep working.
+ */
+ flushQueue?(): void;
+ sendToChannel(channel: string, message: string): void;
+ disconnect(): Promise;
+ addMessageListener(
+ listener: (e: { channel: string; message: string }) => void,
+ ): NativeSubscription;
+ addStateChangeListener(
+ listener: (e: { channel: string; state: string | null }) => void,
+ ): NativeSubscription;
+ addConnectionStateListener(listener: (e: { state: string }) => void): NativeSubscription;
+ addPresenceListener(listener: (e: NativePresenceEvent) => void): NativeSubscription;
+ addLinkErrorListener(listener: (e: NativeLinkErrorEvent) => void): NativeSubscription;
+ /**
+ * Subscribe to signaling-socket connectivity changes. Optional so older
+ * native binaries / test fakes without the event keep working.
+ */
+ addSignalingStateListener?(listener: (e: NativeSignalingStateEvent) => void): NativeSubscription;
+ request(
+ url: string,
+ method: string,
+ headers?: Record,
+ body?: string,
+ ): Promise<{ ok: boolean; status: number; statusText: string; body: string }>;
+}
+
+export interface CreateNativeAc2TransportOptions {
+ /** Signaling origin, e.g. `https://debug.liquidauth.com`. */
+ url: string;
+ requestId: string;
+ /** Called for each negotiated side-channel (`ac2-stream`, `ac2-heartbeat`). */
+ onSideChannel: (channel: NativeDataChannel) => void;
+ /**
+ * Called once with the peer-connection shim after `ac2-v1` opens, so the
+ * caller can attach the connectivity monitor (as with the JS path).
+ */
+ onPeerConnection?: (peerConnection: NativePeerConnection) => void;
+ /** Optional abort signal; cancels the in-flight native negotiation. */
+ signal?: AbortSignal;
+ /** Optional presence listener for server-broadcast device counts. */
+ onPresence?: (presence: PresenceResult) => void;
+ /** Optional link-error listener (fail fast on room refusal). */
+ onLinkError?: (error: NativeLinkErrorEvent) => void;
+ /** ICE servers; defaults to the shared AC2 STUN/TURN config. */
+ iceServers?: NativeIceServer[];
+ /** Named data channels to open; defaults to the AC2 spec set. */
+ dataChannels?: Record;
+ /**
+ * Per-message-type notification content the native service shows while the
+ * app is backgrounded; defaults to {@link DEFAULT_AC2_NOTIFICATIONS}.
+ */
+ notifications?: NativeNotificationConfig;
+ /**
+ * Channels the native service buffers while the app is offline (replayed via
+ * `onMessage` once online); defaults to {@link DEFAULT_AC2_QUEUE_CHANNELS}.
+ */
+ queueChannels?: string[];
+ /**
+ * Heartbeat keep-alive the native service performs while the app is offline
+ * (answers the peer's `ping` with a `pong`); defaults to
+ * {@link DEFAULT_AC2_HEARTBEAT}. Pass `null` to disable.
+ */
+ heartbeat?: NativeHeartbeatConfig | null;
+ /** Injected native module (defaults to the real `react-native-liquid-auth`). */
+ native?: LiquidAuthNativeApi;
+}
+
+export interface NativeAc2TransportSetup {
+ /** The AC2 control-plane channel shim (`ac2-v1`). */
+ datachannel: NativeDataChannel;
+ /** All negotiated channel shims, keyed by label. */
+ channels: Map;
+ /** The peer-connection shim fed by native ICE connection-state events. */
+ peerConnection: NativePeerConnection;
+ /** Detach the presence listener (see {@link CreateNativeAc2TransportOptions.onPresence}). */
+ disposePresence: () => void;
+ /** Detach every native listener this transport installed. */
+ dispose: () => void;
+}
+
+/**
+ * Lazily resolve the real `react-native-liquid-auth` module and adapt its
+ * named exports to {@link LiquidAuthNativeApi}. Deferred (via `require`) so this
+ * file can be imported — and unit-tested with an injected `native` — without
+ * the native package being installed/resolvable.
+ */
+function getDefaultNativeApi(): LiquidAuthNativeApi {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const mod = require('react-native-liquid-auth');
+ return {
+ start: mod.start,
+ connect: mod.connect,
+ cancel: mod.cancel,
+ getConnectionState: mod.getConnectionState,
+ attach: mod.attach,
+ sendToChannel: mod.sendToChannel,
+ disconnect: mod.disconnect,
+ addMessageListener: mod.addMessageListener,
+ addStateChangeListener: mod.addStateChangeListener,
+ addConnectionStateListener: mod.addConnectionStateListener,
+ addPresenceListener: mod.addPresenceListener,
+ addLinkErrorListener: mod.addLinkErrorListener,
+ addSignalingStateListener: mod.addSignalingStateListener,
+ request: mod.request,
+ setActive: mod.setActive,
+ flushQueue: mod.flushQueue,
+ };
+}
+
+/** Flatten a `HeadersInit` into the plain string map the native `request` takes. */
+function normalizeHeaders(headers?: HeadersInit): Record | undefined {
+ if (!headers) return undefined;
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
+ const out: Record = {};
+ headers.forEach((value, key) => {
+ out[key] = value;
+ });
+ return out;
+ }
+ if (Array.isArray(headers)) {
+ return Object.fromEntries(headers as [string, string][]);
+ }
+ return { ...(headers as Record) };
+}
+
+/**
+ * `fetch`-shaped wrapper that routes an HTTP request through the native
+ * module's shared cookie-jar client, so the Liquid Auth session cookie
+ * (`connect.sid`) is captured natively and rides the background signaling
+ * socket (D9). Returns a standard {@link Response} so existing consumers
+ * (`.ok`/`.status`/`.json()`) are unchanged. The native module is injectable
+ * for tests.
+ */
+export async function nativeAuthFetch(
+ input: string,
+ init: RequestInit = {},
+ native: LiquidAuthNativeApi = getDefaultNativeApi(),
+): Promise {
+ const method = (init.method ?? 'GET').toString().toUpperCase();
+ const headers = normalizeHeaders(init.headers);
+ const body =
+ init.body == null ? undefined : typeof init.body === 'string' ? init.body : String(init.body);
+ const res = await native.request(input, method, headers, body);
+ return new Response(res.body, { status: res.status, statusText: res.statusText });
+}
+
+/**
+ * Start the native foreground signaling service and connect its signaling
+ * socket. Idempotent on the native side (a running foreground service is
+ * reused), so it is safe to call once when the persistent service comes up and
+ * again per negotiation. The native module is injectable for tests.
+ */
+export async function startNativeService(
+ url: string,
+ native: LiquidAuthNativeApi = getDefaultNativeApi(),
+): Promise {
+ await native.start(url);
+}
+
+/**
+ * Fully tear down the native foreground service (disconnects the signaling
+ * socket and the WebRTC peer). The native analog of dropping the persistent
+ * `SignalClient` socket — use it only on an explicit disconnect / unmount.
+ */
+export async function stopNativeService(
+ native: LiquidAuthNativeApi = getDefaultNativeApi(),
+): Promise {
+ await native.disconnect();
+}
+
+/**
+ * Cancel the in-flight (or established) native peer negotiation without
+ * tearing the service down, so the persistent signaling socket survives a p2p
+ * drop and the next negotiation can reuse it. Best-effort.
+ */
+export async function cancelNativeNegotiation(
+ native: LiquidAuthNativeApi = getDefaultNativeApi(),
+): Promise {
+ await native.cancel();
+}
+
+/**
+ * Tell the native background service whether the app is currently online
+ * (foregrounded, with its JS listeners attached). Drive this from the app's
+ * foreground/background lifecycle so the app owns the signaling delivery
+ * state. Deliberately does NOT replay the offline queue — a relaunching app
+ * flips active before its listeners are rewired, so an automatic replay here
+ * would hand the buffered messages to the previous (stale) session's handlers
+ * and lose them. The replay happens when a fresh sink attaches (`connect` /
+ * `attach` inside {@link createNativeAc2Transport}) or when the app explicitly
+ * calls {@link flushNativeQueue} once its listeners are wired.
+ */
+export function setNativeActive(
+ active: boolean,
+ native: LiquidAuthNativeApi = getDefaultNativeApi(),
+): void {
+ native.setActive(active);
+}
+
+/**
+ * Explicitly replay any messages the native background service buffered while
+ * the app was offline, through the `onMessage` event in arrival order. Call it
+ * only once the message consumers are wired (a live transport's channel
+ * handlers), so the replay can't race the listener setup — e.g. on a plain
+ * background -> foreground transition with a still-live transport, or right
+ * after a negotiation completes. No-op when nothing is buffered or the native
+ * module doesn't implement it.
+ */
+export function flushNativeQueue(native: LiquidAuthNativeApi = getDefaultNativeApi()): void {
+ native.flushQueue?.();
+}
+
+/**
+ * Query the background service's CURRENT connection so the app can hydrate its
+ * UI on reconnect/relaunch (whether a peer is live, which channels are open,
+ * and the bound `requestId`) instead of assuming a fresh start. Safe to call
+ * before the service is started (returns `connected: false`).
+ */
+export function getNativeConnectionState(
+ native: LiquidAuthNativeApi = getDefaultNativeApi(),
+): NativeConnectionStateSnapshot {
+ return native.getConnectionState();
+}
+
+/**
+ * Whether a {@link getNativeConnectionState} snapshot reports the given data
+ * channel as open. The raw snapshot carries the native WebRTC enum strings
+ * verbatim (UPPERCASE, e.g. `"OPEN"`) — unlike the channel shims, which
+ * lowercase them into `readyState` — so the comparison is case-insensitive.
+ */
+export function isSnapshotChannelOpen(
+ snapshot: NativeConnectionStateSnapshot,
+ channel: string = AC2_CONTROL_CHANNEL,
+): boolean {
+ return snapshot.channels?.[channel]?.toLowerCase() === 'open';
+}
+
+/**
+ * Subscribe to server-broadcast presence for the connected `requestId`. Lives
+ * with the persistent service (not a single negotiation), mirroring how the JS
+ * path subscribed presence on the long-lived signaling socket.
+ */
+export function addNativePresenceListener(
+ listener: (e: NativePresenceEvent) => void,
+ native: LiquidAuthNativeApi = getDefaultNativeApi(),
+): NativeSubscription {
+ return native.addPresenceListener(listener);
+}
+
+/**
+ * Subscribe to signaling-socket connectivity changes (`connected` /
+ * `disconnected`, including socket.io auto-reconnects) from the persistent
+ * native service. Independent of the p2p connection — data channels
+ * deliberately survive signaling disruptions — so the app can surface a
+ * dedicated "signaling server offline" state. Returns a no-op subscription
+ * when the native module predates the event.
+ */
+export function addNativeSignalingStateListener(
+ listener: (e: NativeSignalingStateEvent) => void,
+ native: LiquidAuthNativeApi = getDefaultNativeApi(),
+): NativeSubscription {
+ if (!native.addSignalingStateListener) {
+ return { remove: () => {} };
+ }
+ return native.addSignalingStateListener(listener);
+}
+
+/**
+ * Open the AC2 control plane over the native background service. Side-channels
+ * (`ac2-stream`, `ac2-heartbeat`) are surfaced via `onSideChannel`. Resolves
+ * once `ac2-v1` is `open`; rejects with an `AbortError` if `signal` fires or
+ * with the native error (e.g. `E_LINK_ERROR` / `E_ABORTED`) otherwise.
+ */
+export async function createNativeAc2Transport(
+ opts: CreateNativeAc2TransportOptions,
+): Promise {
+ const {
+ url,
+ requestId,
+ onSideChannel,
+ onPeerConnection,
+ signal,
+ onPresence,
+ onLinkError,
+ iceServers = DEFAULT_ICE_SERVERS,
+ dataChannels = DEFAULT_DATA_CHANNELS,
+ notifications = DEFAULT_AC2_NOTIFICATIONS,
+ queueChannels = DEFAULT_AC2_QUEUE_CHANNELS,
+ heartbeat = DEFAULT_AC2_HEARTBEAT,
+ native = getDefaultNativeApi(),
+ } = opts;
+
+ if (signal?.aborted) {
+ throw makeAbortError();
+ }
+
+ // Build a shim per requested channel and index them so native events (which
+ // carry a channel label) can be routed to the right instance.
+ const channels = new Map();
+ for (const label of Object.keys(dataChannels)) {
+ channels.set(label, new NativeDataChannel(label, native.sendToChannel));
+ }
+ // The control channel must always exist even if a caller passed a custom map
+ // that omitted it, since the SDK client binds to it.
+ if (!channels.has(AC2_CONTROL_CHANNEL)) {
+ channels.set(
+ AC2_CONTROL_CHANNEL,
+ new NativeDataChannel(AC2_CONTROL_CHANNEL, native.sendToChannel),
+ );
+ }
+
+ const peerConnection = new NativePeerConnection();
+
+ // Subscribe to native events BEFORE connecting so no early open/message is
+ // missed. Each subscription is detached by `dispose()` below.
+ const subscriptions: NativeSubscription[] = [];
+ subscriptions.push(
+ native.addMessageListener((e) => {
+ const channel = channels.get(e.channel);
+ console.log(
+ `[ac2-native] onMessage received channel=${e.channel} hasShim=${channel != null}`,
+ );
+ channel?.dispatchMessage(e.message);
+ }),
+ native.addStateChangeListener((e) => {
+ console.log(`[ac2-native] onStateChange channel=${e.channel} state=${e.state}`);
+ channels.get(e.channel)?.setState(e.state);
+ }),
+ native.addConnectionStateListener((e) => peerConnection.setConnectionState(e.state)),
+ );
+
+ let disposePresence: () => void = () => {};
+ if (onPresence) {
+ const sub = native.addPresenceListener((e) =>
+ onPresence({ requestId: e.requestId, deviceCount: e.deviceCount, online: e.online }),
+ );
+ subscriptions.push(sub);
+ disposePresence = () => sub.remove();
+ }
+ if (onLinkError) {
+ subscriptions.push(native.addLinkErrorListener((e) => onLinkError(e)));
+ }
+
+ const dispose = () => {
+ for (const sub of subscriptions) {
+ try {
+ sub.remove();
+ } catch {
+ /* best-effort detach */
+ }
+ }
+ };
+
+ // Wire side-channel handlers before negotiation so their onmessage/onopen are
+ // attached when the first frames arrive.
+ for (const [label, channel] of channels) {
+ if (label !== AC2_CONTROL_CHANNEL) onSideChannel(channel);
+ }
+
+ const controlChannel = channels.get(AC2_CONTROL_CHANNEL)!;
+
+ try {
+ await native.start(url);
+ if (signal?.aborted) throw makeAbortError();
+
+ // If the background service is ALREADY connected to this `requestId` (it
+ // kept the peer alive across a relaunch / foreground transition), re-attach
+ // to the live connection instead of renegotiating — this is what "the
+ // service stays connected and the frontend just connects when it's up"
+ // means. `attach()` rebinds the native event listeners to this fresh JS
+ // runtime and re-emits the current channel + ICE state, which the
+ // subscriptions above route into the shims so the control channel is seen
+ // as already open (hydration). No SDP/ICE renegotiation, so the live p2p
+ // connection is never torn down.
+ const existing = native.getConnectionState();
+ if (existing.connected && existing.requestId === requestId) {
+ await native.attach({
+ dataChannels,
+ notifications,
+ queueChannels,
+ ...(heartbeat ? { heartbeat } : {}),
+ });
+ if (signal?.aborted) throw makeAbortError();
+ await waitForChannelOpen(controlChannel as any, CHANNEL_OPEN_TIMEOUT_MS, signal);
+ onPeerConnection?.(peerConnection);
+ return { datachannel: controlChannel, channels, peerConnection, disposePresence, dispose };
+ }
+
+ // Race the native negotiation against the abort signal; on abort, ask the
+ // native service to cancel the in-flight negotiation.
+ let onAbort: (() => void) | undefined;
+ const connectPromise = native.connect(requestId, 'answer', iceServers, {
+ dataChannels,
+ notifications,
+ queueChannels,
+ ...(heartbeat ? { heartbeat } : {}),
+ });
+
+ if (signal) {
+ const abortPromise = new Promise((_, reject) => {
+ onAbort = () => {
+ native.cancel().catch(() => {
+ /* best-effort; the connect promise will also reject */
+ });
+ reject(makeAbortError());
+ };
+ signal.addEventListener('abort', onAbort);
+ });
+ await Promise.race([connectPromise, abortPromise]).finally(() => {
+ if (onAbort) signal.removeEventListener('abort', onAbort);
+ });
+ } else {
+ await connectPromise;
+ }
+
+ // Negotiation resolved once a channel opened; block until the control
+ // channel specifically is open (fast-fail on a STUN/TURN stall).
+ await waitForChannelOpen(controlChannel as any, CHANNEL_OPEN_TIMEOUT_MS, signal);
+
+ // Surface the peer connection now that the channel is live, matching the JS
+ // path's timing (the monitor attaches once the channel is usable).
+ onPeerConnection?.(peerConnection);
+
+ return { datachannel: controlChannel, channels, peerConnection, disposePresence, dispose };
+ } catch (err) {
+ // Nothing downstream owns the listeners on a failed negotiation; detach them
+ // here so a retry does not accumulate handlers.
+ dispose();
+ throw err;
+ }
+}
+
+/** Build a plain `AbortError` (broadest RN/Hermes compatibility). */
+function makeAbortError(): Error {
+ const err = new Error('Aborted');
+ err.name = 'AbortError';
+ return err;
+}
diff --git a/lib/ac2/presence.ts b/lib/ac2/presence.ts
new file mode 100644
index 0000000..90e63a5
--- /dev/null
+++ b/lib/ac2/presence.ts
@@ -0,0 +1,190 @@
+/**
+ * Liquid Auth presence detection, handled outside the `SignalClient` directly
+ * on the signaling socket.
+ *
+ * The Liquid Auth signaling server exposes a dedicated `presence` websocket
+ * event: emitting `{ requestId }` returns an ack `{ requestId, deviceCount,
+ * online }`, and the server also broadcasts the same shape to everyone in the
+ * `requestId` room whenever a peer joins or leaves. This lets a (potentially
+ * offline) wallet detect whether there is anyone connected for a `requestId`
+ * before attempting to (re)connect — the "should I even bother reconnecting?"
+ * decision.
+ *
+ * The helpers here are intentionally decoupled from `SignalClient`: they work
+ * against any object exposing socket.io's `emit`/`on`/`off`, so they can be
+ * driven by `signalClient.socket` in production and by a plain mock in tests.
+ */
+
+/** Presence snapshot for a `requestId`, as reported by the signaling server. */
+export interface PresenceResult {
+ requestId: string;
+ /** Number of devices currently connected for the `requestId`. */
+ deviceCount: number;
+ /** Convenience flag: `deviceCount > 0`. */
+ online: boolean;
+}
+
+/**
+ * Minimal socket surface the presence helpers rely on. Satisfied by a
+ * socket.io-client `Socket` (and by `signalClient.socket`).
+ */
+export interface PresenceSocket {
+ emit: (event: string, ...args: any[]) => unknown;
+ on?: (event: string, listener: (...args: any[]) => void) => unknown;
+ off?: (event: string, listener: (...args: any[]) => void) => unknown;
+}
+
+export const PRESENCE_EVENT = 'presence';
+const DEFAULT_PRESENCE_TIMEOUT_MS = 10000;
+
+/**
+ * Coerce an arbitrary presence payload into a well-formed `PresenceResult`,
+ * tolerating a missing/partial ack from an older server. Falls back to the
+ * queried `requestId` and derives `online` from `deviceCount` when absent.
+ */
+export function normalizePresence(requestId: string, data: unknown): PresenceResult {
+ const record = (data ?? {}) as Record;
+ const deviceCount =
+ typeof record.deviceCount === 'number' && Number.isFinite(record.deviceCount)
+ ? record.deviceCount
+ : 0;
+ const online = typeof record.online === 'boolean' ? record.online : deviceCount > 0;
+ const resolvedRequestId =
+ typeof record.requestId === 'string' && record.requestId.length > 0
+ ? record.requestId
+ : requestId;
+ return { requestId: resolvedRequestId, deviceCount, online };
+}
+
+/**
+ * Query how many devices are connected for `requestId` by emitting the
+ * `presence` event and awaiting the server ack. Rejects on an empty
+ * `requestId` or if no ack arrives within `timeoutMs`.
+ */
+export function queryPresence(
+ socket: PresenceSocket,
+ requestId: string,
+ opts: { timeoutMs?: number } = {},
+): Promise {
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_PRESENCE_TIMEOUT_MS;
+ return new Promise((resolve, reject) => {
+ if (typeof requestId !== 'string' || requestId.length === 0) {
+ reject(new Error('presence query requires a non-empty requestId'));
+ return;
+ }
+
+ let settled = false;
+ const timer = setTimeout(() => {
+ if (settled) return;
+ settled = true;
+ reject(new Error(`Timed out waiting for presence ack for ${requestId} (${timeoutMs}ms)`));
+ }, timeoutMs);
+
+ try {
+ socket.emit(PRESENCE_EVENT, { requestId }, (data: unknown) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ resolve(normalizePresence(requestId, data));
+ });
+ } catch (err) {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ reject(err as Error);
+ }
+ });
+}
+
+/**
+ * Subscribe to server-broadcast `presence` updates. Returns an unsubscribe
+ * function. Safe to call against a null/undefined socket, or one that lacks
+ * `on`/`off` (no-op) — e.g. a `SignalClient` whose socket has not finished its
+ * asynchronous initialization yet.
+ */
+export function subscribeToPresence(
+ socket: PresenceSocket | null | undefined,
+ handler: (presence: PresenceResult) => void,
+): () => void {
+ if (!socket) {
+ return () => {};
+ }
+ const listener = (data: unknown) => {
+ const record = (data ?? {}) as Record;
+ const requestId = typeof record.requestId === 'string' ? record.requestId : '';
+ handler(normalizePresence(requestId, data));
+ };
+ socket.on?.(PRESENCE_EVENT, listener);
+ return () => {
+ socket.off?.(PRESENCE_EVENT, listener);
+ };
+}
+
+/**
+ * Decide, from the wallet's own perspective, whether the *peer* (the other
+ * party in the `requestId` room) is offline.
+ *
+ * The signaling server counts the wallet itself once its socket has joined the
+ * room, so a broadcast/ack the wallet receives reports `deviceCount` including
+ * itself: `deviceCount <= 1` therefore means "nobody but me" — the peer is not
+ * there. A missing snapshot is treated as unknown (returns `false`) so the app
+ * doesn't cry wolf before the first presence update arrives.
+ */
+export function isPeerOffline(presence: PresenceResult | null | undefined): boolean {
+ if (!presence) return false;
+ return presence.deviceCount <= 1;
+}
+
+/**
+ * Regex for signaling failures that mean the peer never answered — i.e. it is
+ * not linked/online for the `requestId`. Matches the transport's answer-
+ * description timeout message (see `createAc2Transport`).
+ */
+const PEER_UNREACHABLE_MESSAGE = /answer-description|Timed out waiting for Liquid Auth/i;
+
+/**
+ * Classify a connection failure as "the peer is unreachable/offline" purely
+ * from its error message. Used alongside {@link isPeerOffline} so the wallet can
+ * surface a clear "peer offline" alert instead of a cryptic timeout when the
+ * other device simply isn't available.
+ */
+export function isPeerUnreachableError(error?: { message?: unknown } | null): boolean {
+ const message = error?.message;
+ return typeof message === 'string' && PEER_UNREACHABLE_MESSAGE.test(message);
+}
+
+/**
+ * Classify a connection failure as a signaling-server ROOM REFUSAL (the
+ * two-peer lockdown: the `requestId` session already holds its one wallet + one
+ * peer, or a device of the same role). The client library rejects with a
+ * `LinkError` (name `'LinkError'`), which `createAc2Transport` surfaces fast
+ * instead of the answer-description timeout.
+ *
+ * Detected by error name rather than `instanceof` so it stays correct even when
+ * the error crosses a module/realm boundary (Metro bundling, re-thrown copies).
+ * Unlike {@link isPeerUnreachableError}, this failure is TERMINAL — retrying
+ * cannot free a slot — so callers should surface it immediately rather than
+ * burning the auto-reconnect budget.
+ */
+export function isPeerRejectedError(error?: { name?: unknown } | null): boolean {
+ return typeof error?.name === 'string' && error.name === 'LinkError';
+}
+
+/**
+ * Convenience wrapper for the reconnect decision: resolves `true` when at
+ * least one device is connected for `requestId`. Swallows query errors and
+ * timeouts into `false` so an offline client simply declines to reconnect
+ * rather than throwing.
+ */
+export async function hasPeerPresence(
+ socket: PresenceSocket,
+ requestId: string,
+ opts?: { timeoutMs?: number },
+): Promise {
+ try {
+ const presence = await queryPresence(socket, requestId, opts);
+ return presence.online;
+ } catch {
+ return false;
+ }
+}
diff --git a/lib/ac2/stream.ts b/lib/ac2/stream.ts
index 40454e0..f051321 100644
--- a/lib/ac2/stream.ts
+++ b/lib/ac2/stream.ts
@@ -1,7 +1,7 @@
/**
* STX-prefixed control-frame parser for the `ac2-stream` side channel.
* Decodes the finalizer-driven live-preview protocol (preview / finalize /
- * discard / conversations / tool / history) the agent sends.
+ * discard / conversations / tool / history / notice) the agent sends.
*/
/** STX byte that prefixes every control frame on the stream channel. */
@@ -9,6 +9,69 @@ export const STX = 0x02;
export type AgentPresence = 'thinking' | 'tool' | 'typing' | null;
+/** Severity of an out-of-band `notice` control frame. */
+export type NoticeLevel = 'info' | 'warning' | 'error';
+
+/**
+ * An out-of-band advisory the agent pushed (e.g. a locked/new-wallet warning).
+ * Rendered as a banner in the chat surface, never as a chat bubble.
+ */
+export interface ConnectionNotice {
+ /** Machine-readable code so the UI can special-case a notice. */
+ code: string;
+ level: NoticeLevel;
+ title?: string;
+ text: string;
+}
+
+/**
+ * A {@link ConnectionNotice} tagged with the connection (`requestId`) it was
+ * raised on. The chat surface is reused across connection switches, so a notice
+ * is stored scoped to its connection to prevent one wallet's banner from
+ * bleeding onto another.
+ */
+export interface ScopedConnectionNotice {
+ notice: ConnectionNotice;
+ requestId: string;
+}
+
+/**
+ * Resolve which notice (if any) should be shown for the connection currently on
+ * screen. Returns the stored notice only when it belongs to `requestId`;
+ * otherwise `null` — starting a new connection (a new registration or a
+ * previously-paired wallet reconnecting) has a different `requestId`, so the
+ * banner disappears. Pure, so it is trivially unit-testable.
+ */
+export function selectConnectionNoticeForRequest(
+ scoped: ScopedConnectionNotice | null,
+ requestId: string,
+): ConnectionNotice | null {
+ if (!scoped) return null;
+ return scoped.requestId === requestId ? scoped.notice : null;
+}
+
+/**
+ * Notice codes that mean the wallet is not registered with the agent and must
+ * not be allowed to send new messages:
+ * - `controller_locked` — a *different* wallet connected to an agent already
+ * registered to another one (the agent refuses to switch).
+ * - `identity_missing` — the agent has not been granted an identity yet.
+ * The agent surfaces both as banner-only notices; the wallet uses these codes
+ * to gate the composer until registration completes.
+ */
+export const REGISTRATION_BLOCKING_NOTICE_CODES: ReadonlySet = new Set([
+ 'controller_locked',
+ 'identity_missing',
+]);
+
+/**
+ * Whether a notice `code` indicates the wallet is not registered, so the chat
+ * composer must be blocked. Pure, so it is trivially unit-testable.
+ */
+export function isRegistrationBlockingNotice(code: string | undefined | null): boolean {
+ return typeof code === 'string' && REGISTRATION_BLOCKING_NOTICE_CODES.has(code);
+}
+
export type StreamControlFrame =
| {
t: 'preview';
@@ -21,9 +84,42 @@ export type StreamControlFrame =
| { t: 'discard'; thid?: string }
| { t: 'conversations'; threads?: Array<{ thid: string; title?: string; updatedAt?: number }> }
| { t: 'tool'; thid?: string; id?: string; name?: string; command?: string; output?: string }
+ | {
+ t: 'task';
+ thid?: string;
+ id?: string;
+ title?: string;
+ status?: string;
+ prompt?: string;
+ result?: string;
+ }
| { t: 'history'; thid?: string; messages?: any[] }
+ | { t: 'notice'; code?: string; level?: NoticeLevel; title?: string; text?: string }
| { t: string; [k: string]: unknown };
+const NOTICE_LEVELS: ReadonlySet = new Set(['info', 'warning', 'error']);
+
+/**
+ * Normalize a `notice` control frame into a `ConnectionNotice`, or return
+ * `null` when it carries no displayable text. Kept pure (no store/UI deps) so
+ * it is trivially unit-testable. `level` defaults to `warning`; an unknown
+ * level is coerced to `warning` too.
+ */
+export function normalizeNoticeFrame(frame: unknown): ConnectionNotice | null {
+ if (typeof frame !== 'object' || frame === null) return null;
+ const f = frame as Record;
+ if (f['t'] !== 'notice') return null;
+ const text = typeof f['text'] === 'string' ? f['text'].trim() : '';
+ if (text.length === 0) return null;
+ const level =
+ typeof f['level'] === 'string' && NOTICE_LEVELS.has(f['level'])
+ ? (f['level'] as NoticeLevel)
+ : 'warning';
+ const code = typeof f['code'] === 'string' && f['code'].length > 0 ? f['code'] : 'notice';
+ const title = typeof f['title'] === 'string' && f['title'].length > 0 ? f['title'] : undefined;
+ return { code, level, text, ...(title ? { title } : {}) };
+}
+
/**
* Parse a raw DataChannel string as a control frame. Returns `null` if `raw`
* is not a control frame (no STX prefix) and `undefined` if it parsed but
diff --git a/lib/ac2/streamControlFrame.ts b/lib/ac2/streamControlFrame.ts
index 9752ab1..12bec79 100644
--- a/lib/ac2/streamControlFrame.ts
+++ b/lib/ac2/streamControlFrame.ts
@@ -7,8 +7,9 @@
* never rendered as a chat message. The returned function applies one frame and
* reports whether `raw` was a control frame (recognized or malformed).
*/
-import { parseStreamControlFrame } from '@/lib/ac2';
-import { addMessage, addToolActivity, setThreadHistory } from '@/stores/messages';
+import { normalizeNoticeFrame, parseStreamControlFrame } from '@/lib/ac2';
+import type { ConnectionNotice } from '@/lib/ac2';
+import { addMessage, addToolActivity, addTaskActivity, setThreadHistory } from '@/stores/messages';
import { updateSessionActivity } from '@/stores/sessions';
import type { MutableRefObject } from 'react';
@@ -31,6 +32,11 @@ export interface ControlFrameContext {
setActiveStreamText: (text: string) => void;
setLastHeartbeat: (at: number) => void;
setRemoteThreads: (threads: RemoteThread[]) => void;
+ /**
+ * Surface an out-of-band advisory the agent pushed (e.g. a locked/new-wallet
+ * warning) as a banner. `null` clears it.
+ */
+ setConnectionNotice: (notice: ConnectionNotice | null) => void;
}
export function createControlFrameHandler(ctx: ControlFrameContext): (raw: string) => boolean {
@@ -45,6 +51,7 @@ export function createControlFrameHandler(ctx: ControlFrameContext): (raw: strin
setActiveStreamText,
setLastHeartbeat,
setRemoteThreads,
+ setConnectionNotice,
} = ctx;
return (raw: string): boolean => {
@@ -146,6 +153,34 @@ export function createControlFrameHandler(ctx: ControlFrameContext): (raw: strin
}
break;
}
+ case 'task': {
+ // Durable background-task card: a record of one background sub-agent run.
+ // Persist it (de-duped by the agent-supplied id) so it renders as a
+ // distinct "task card" in the thread's timeline.
+ if (typeof frame.id === 'string' && addressRef.current) {
+ addTaskActivity({
+ taskId: frame.id,
+ address: addressRef.current,
+ origin,
+ requestId,
+ thid: fthid,
+ ...(typeof frame.title === 'string' ? { title: frame.title } : {}),
+ ...(typeof frame.prompt === 'string' ? { prompt: frame.prompt } : {}),
+ ...(typeof frame.status === 'string' ? { status: frame.status } : {}),
+ ...(typeof frame.result === 'string' ? { result: frame.result } : {}),
+ });
+ updateSessionActivity(requestId, origin);
+ }
+ break;
+ }
+ case 'notice': {
+ // Out-of-band advisory (never a chat bubble): surface it as a banner so
+ // the user is alerted even though the agent isn't replying in-thread.
+ // Used for the "a different wallet is connecting" lock warning.
+ const notice = normalizeNoticeFrame(frame);
+ if (notice) setConnectionNotice(notice);
+ break;
+ }
case 'history': {
if (typeof frame.thid === 'string' && Array.isArray(frame.messages) && addressRef.current) {
// The agent replayed an existing conversation's history (e.g.
@@ -154,8 +189,11 @@ export function createControlFrameHandler(ctx: ControlFrameContext): (raw: strin
const history = frame.messages
.filter(
(m: any) =>
- (m?.role === 'user' || m?.role === 'assistant' || m?.role === 'tool') &&
- (typeof m?.text === 'string' || m?.role === 'tool'),
+ (m?.role === 'user' ||
+ m?.role === 'assistant' ||
+ m?.role === 'tool' ||
+ m?.role === 'task') &&
+ (typeof m?.text === 'string' || m?.role === 'tool' || m?.role === 'task'),
)
.map((m: any) => {
if (m.role === 'tool') {
@@ -172,6 +210,18 @@ export function createControlFrameHandler(ctx: ControlFrameContext): (raw: strin
...(typeof m.at === 'number' ? { at: m.at } : {}),
};
}
+ if (m.role === 'task') {
+ return {
+ role: 'task' as const,
+ text: '',
+ ...(typeof m.id === 'string' ? { id: m.id } : {}),
+ ...(typeof m.title === 'string' ? { title: m.title } : {}),
+ ...(typeof m.prompt === 'string' ? { prompt: m.prompt } : {}),
+ ...(typeof m.status === 'string' ? { status: m.status } : {}),
+ ...(typeof m.result === 'string' ? { result: m.result } : {}),
+ ...(typeof m.at === 'number' ? { at: m.at } : {}),
+ };
+ }
return {
role: m.role as 'user' | 'assistant',
text: m.text as string,
diff --git a/lib/ac2/transport.ts b/lib/ac2/transport.ts
index d126ecc..c484a26 100644
--- a/lib/ac2/transport.ts
+++ b/lib/ac2/transport.ts
@@ -1,13 +1,20 @@
/**
- * Liquid Auth + WebRTC pairing for the AC2 controller. Negotiates the
- * `ac2-v1` / `ac2-stream` / `ac2-heartbeat` DataChannels that the AC2
- * SDK and the controller UI consume.
+ * Shared transport helpers + constants for the AC2 controller.
+ *
+ * Historically this module also hosted `createAc2Transport`, the *in-process*
+ * `SignalClient` + `react-native-webrtc` negotiator. That path has been retired
+ * in favour of the native background service (`./nativeTransport`,
+ * `createNativeAc2Transport`), so the wallet no longer runs signaling/WebRTC
+ * inside the JS runtime. What remains here is transport-agnostic and reused by
+ * the native path: the AC2 ICE/data-channel constants and the
+ * `waitForChannelOpen` guard, plus a handful of socket.io helpers kept for
+ * their unit coverage.
*/
-import { SignalClient } from '@algorandfoundation/liquid-client';
+import type { SignalClient } from '@algorandfoundation/liquid-client';
/** Default ICE config for the Liquid Auth signaling pair. */
-const DEFAULT_ICE_SERVERS = [
+export const DEFAULT_ICE_SERVERS = [
{
urls: ['stun:geo.turn.algonode.xyz:80', 'stun:global.turn.nodely.io:443'],
},
@@ -22,164 +29,118 @@ const DEFAULT_ICE_SERVERS = [
];
/** DataChannel labels requested on the peer (AC2 spec mandated). */
-const DEFAULT_DATA_CHANNELS = {
+export const DEFAULT_DATA_CHANNELS = {
'ac2-v1': { ordered: true },
'ac2-stream': { ordered: true },
'ac2-heartbeat': { ordered: true },
};
-const SIGNALING_TIMEOUT_MS = 30000;
const SOCKET_CONNECT_TIMEOUT_MS = 10000;
-// `signalClient.peer()` resolves once the remote description is applied, long
+// The native negotiation resolves once the remote description is applied, long
// before the `ac2-v1` channel is actually usable. Bound how long we then wait
// for it to reach `open`, so a peer whose ICE never establishes (a STUN/TURN
// stall) turns into a fast rejection the caller can retry rather than an
// indefinite hang.
-const CHANNEL_OPEN_TIMEOUT_MS = 15000;
+export const CHANNEL_OPEN_TIMEOUT_MS = 15000;
const SIGNAL_CANDIDATE_NORMALIZER = Symbol('ac2.signalCandidateNormalizer');
const SIGNAL_CANDIDATE_EVENTS = new Set(['offer-candidate', 'answer-candidate']);
-export interface Ac2TransportSetup {
- /** Active Liquid Auth `SignalClient` (already authenticated). */
- client: SignalClient;
- /** The control plane DataChannel (`ac2-v1`). */
- datachannel: RTCDataChannel;
-}
-
-export interface CreateAc2TransportOptions {
- requestId: string;
- signalClient: SignalClient;
- /** Called for each negotiated side-channel (`ac2-stream`, `ac2-heartbeat`). */
- onSideChannel: (channel: RTCDataChannel) => void;
- /**
- * Called once with the negotiated `RTCPeerConnection` (the SDK's
- * `peerClient`) as soon as negotiation resolves, so the caller can attach a
- * connectivity monitor — the SDK never does. `peerClient` is guaranteed set
- * at this point.
- */
- onPeerConnection?: (peerConnection: RTCPeerConnection) => void;
- /**
- * Optional abort signal. When fired the pending negotiation is torn down
- * immediately and the returned promise rejects with an `AbortError`.
- */
- signal?: AbortSignal;
-}
-
/**
- * Open the AC2 control plane DataChannel against an already-authenticated
- * `SignalClient`. Side-channels (`ac2-stream`, `ac2-heartbeat`) are
- * surfaced via `onSideChannel`.
+ * Attach lightweight, additive diagnostics to a `SignalClient` for the duration
+ * of one negotiation, so a stalled handshake ("Connecting…" that never
+ * proceeds) is attributable to a specific missing step in the logs.
+ *
+ * The wallet negotiates as the `'answer'` peer: it SENDS an `offer-description`
+ * and then waits (one-shot) for the remote's `answer-description`. The classic
+ * stall signature is therefore an `offer-description` line with NO following
+ * `answer-description` — the remote peer (the OpenClaw agent) never answered,
+ * usually because it wasn't yet armed to receive our offer when we sent it (a
+ * re-pairing race right after the agent restarts). Restarting the wallet
+ * "fixes" it only because the wallet then re-sends its offer after the agent is
+ * ready.
+ *
+ * Listeners are attached with `.on` (purely additive to the SDK's own `.once`
+ * awaiters, so they never consume an event) and removed by the returned
+ * disposer. Best-effort throughout: diagnostics must never break negotiation.
*/
-export async function createAc2Transport(
- opts: CreateAc2TransportOptions,
-): Promise {
- const { requestId, signalClient, onSideChannel, onPeerConnection, signal } = opts;
-
- if (signal?.aborted) {
- const err = new Error('Aborted');
- err.name = 'AbortError';
- throw err;
- }
-
- signalClient.on('data-channel', (channel: RTCDataChannel) => {
- if (channel.label === 'ac2-v1') return; // owned by datachannel below
- onSideChannel(channel);
- });
-
- await waitForSignalSocketConnected(signalClient);
- installSignalCandidateNormalizer(signalClient);
-
- const peerPromise = signalClient.peer(
- requestId,
- 'answer',
- {
- iceServers: DEFAULT_ICE_SERVERS,
- },
- {
- dataChannels: DEFAULT_DATA_CHANNELS,
- },
- );
-
- let timeoutId: ReturnType | undefined;
- const timeoutPromise = new Promise((_, reject) => {
- timeoutId = setTimeout(() => {
- signalClient.peerClient?.close();
- reject(
- new Error(
- 'Timed out waiting for Liquid Auth answer-description. Check that the signaling socket is authenticated and the OpenClaw peer is still linked to this requestId.',
- ),
- );
- }, SIGNALING_TIMEOUT_MS);
- });
-
- const racers: Promise[] = [peerPromise, timeoutPromise];
-
- // Track the abort listener so it can be removed in `finally` regardless of
- // which racer wins — prevents a dangling listener from firing later and
- // inadvertently closing a healthy peer connection.
- let onAbort: (() => void) | undefined;
-
- if (signal) {
- const abortPromise = new Promise((_, reject) => {
- const abort = () => {
- if (timeoutId !== undefined) clearTimeout(timeoutId);
- // Do NOT hard-close the native peer on abort. On Android,
- // `react-native-webrtc` can still be asynchronously applying the
- // remote description when a superseded run is cancelled; tearing the
- // peer down here races that in-flight work and can crash with
- // `peerConnectionSetRemoteDescription(...getPeerConnection() == null)`.
- // The caller's normal transport cleanup will detach the obsolete
- // SignalClient/socket without forcing this unsafe native transition.
- // Use a plain Error with name 'AbortError' rather than DOMException
- // for broadest compatibility across Hermes versions.
- const err = new Error('Aborted');
- err.name = 'AbortError';
- reject(err);
- };
- if (signal.aborted) {
- abort();
- return;
+export function attachSignalingDiagnostics(
+ signalClient: SignalClient,
+ requestId: string,
+ log: (message: string) => void = (message) => console.log(message),
+): () => void {
+ const emitter = signalClient as unknown as {
+ on: (event: string, listener: (...args: any[]) => void) => unknown;
+ off: (event: string, listener: (...args: any[]) => void) => unknown;
+ };
+ const startedAt = Date.now();
+ const since = () => `+${Date.now() - startedAt}ms`;
+ const shortId = requestId.length > 8 ? `${requestId.slice(0, 8)}…` : requestId;
+ const handlers: [string, (...args: any[]) => void][] = [];
+ const candidateCounts: Record = {};
+
+ const track = (event: string, describe?: (...args: any[]) => string): void => {
+ const listener = (...args: any[]) => {
+ try {
+ const detail = describe ? describe(...args) : '';
+ log(`[ac2][signal] ${shortId} ${event} ${since()}${detail ? ` — ${detail}` : ''}`);
+ } catch {
+ /* diagnostics only */
}
- onAbort = abort;
- signal.addEventListener('abort', onAbort);
- });
- racers.push(abortPromise);
- }
-
- const datachannel = await Promise.race(racers).finally(() => {
- if (timeoutId !== undefined) clearTimeout(timeoutId);
- if (signal && onAbort) signal.removeEventListener('abort', onAbort);
- });
+ };
+ try {
+ emitter.on(event, listener);
+ handlers.push([event, listener]);
+ } catch {
+ /* diagnostics only */
+ }
+ };
- // Surface the negotiated peer connection so the caller can watch it for
- // connectivity loss. The SDK attaches no ICE/connection state handlers, so
- // this is the only seam for detecting a post-negotiation drop.
- if (signalClient.peerClient) onPeerConnection?.(signalClient.peerClient);
+ // Count (rather than spam) trickled ICE candidates: only the first of each
+ // kind is logged, with the running total reported when diagnostics detach.
+ const trackCandidates = (event: string): void => {
+ const listener = () => {
+ candidateCounts[event] = (candidateCounts[event] ?? 0) + 1;
+ if (candidateCounts[event] === 1) {
+ log(`[ac2][signal] ${shortId} ${event} (first) ${since()}`);
+ }
+ };
+ try {
+ emitter.on(event, listener);
+ handlers.push([event, listener]);
+ } catch {
+ /* diagnostics only */
+ }
+ };
- // The negotiation above resolves once the remote description is applied, but
- // the `ac2-v1` channel is still `connecting` at that point. Block until it
- // actually opens so a peer whose ICE never completes (STUN/TURN stall) fails
- // fast into the caller's retry path instead of hanging forever waiting for an
- // `onOpen` that never arrives.
- try {
- await waitForChannelOpen(datachannel, CHANNEL_OPEN_TIMEOUT_MS, signal);
- } catch (err: any) {
- // A non-abort failure (open timeout / early close) leaves a half-open peer
- // whose ICE machinery would otherwise keep running; close it promptly,
- // mirroring the signaling-timeout cleanup above. An abort intentionally
- // does NOT hard-close the native peer here (see the abort handler's note
- // about Android's in-flight setRemoteDescription).
- if (err?.name !== 'AbortError') {
+ log(
+ `[ac2][signal] ${shortId} negotiate: peer('answer') started — will send offer-description then await answer-description`,
+ );
+ track('link', () => 'awaiting link-message');
+ track('link-message', (data) => (data?.wallet ? `linked wallet=${data.wallet}` : 'linked'));
+ track('signal', (data) => (data?.type ? `awaiting ${data.type}-description` : ''));
+ track('offer-description', () => 'SENT our SDP offer');
+ track('answer-description', () => 'RECEIVED peer SDP answer');
+ trackCandidates('offer-candidate');
+ trackCandidates('answer-candidate');
+ track('data-channel', (channel) => `channel=${channel?.label ?? 'unknown'}`);
+ track('connect', () => 'signaling socket connected');
+ track('disconnect', () => 'signaling socket disconnected');
+
+ return () => {
+ const counts = Object.entries(candidateCounts)
+ .map(([event, count]) => `${event}=${count}`)
+ .join(', ');
+ log(
+ `[ac2][signal] ${shortId} negotiate: diagnostics detached ${since()}${counts ? ` (candidates: ${counts})` : ''}`,
+ );
+ for (const [event, listener] of handlers) {
try {
- signalClient.peerClient?.close();
+ emitter.off(event, listener);
} catch {
- /* noop */
+ /* diagnostics only */
}
}
- throw err;
- }
-
- return { client: signalClient, datachannel };
+ };
}
/**
@@ -283,7 +244,7 @@ export function installSignalCandidateNormalizer(signalClient: SignalClient): vo
Object.defineProperty(socket, SIGNAL_CANDIDATE_NORMALIZER, { value: true });
}
-async function waitForSignalSocketConnected(signalClient: SignalClient): Promise {
+export async function waitForSignalSocketConnected(signalClient: SignalClient): Promise {
// The liquid-client constructor resolves once socket.io is created, not once
// it is connected. Sending the SDP after the connect event keeps signaling
// ordering deterministic on React Native.
diff --git a/lib/liquid-auth/flow.ts b/lib/liquid-auth/flow.ts
index 2b59721..568a8c2 100644
--- a/lib/liquid-auth/flow.ts
+++ b/lib/liquid-auth/flow.ts
@@ -63,6 +63,12 @@ export interface AuthenticateLiquidAuthParams {
* creating a new one via attestation. Only the initial scan flow opts in.
*/
allowPasskeyCreation: boolean;
+ /**
+ * Internal recovery flag. Set when re-entering the exchange after a failed
+ * native assertion so it skips the (now unusable) passkey and re-registers
+ * via attestation, bypassing the `allowPasskeyCreation` guard.
+ */
+ recoverFromFailedAssertion?: boolean;
key: ReactNativeProvider['key'];
passkey: ReactNativeProvider['passkey'];
setAddress: (address: string) => void;
@@ -154,6 +160,24 @@ async function nativeStoredPasskeys(): Promise {
});
}
+/**
+ * Whether a failed native passkey assertion should trigger re-registration via
+ * attestation. User-driven aborts (cancellation, timeout, interruption) are
+ * intentionally excluded so we respect the user's choice instead of silently
+ * re-registering; everything else (e.g. a native "The incoming request cannot
+ * be validated" error after the device lost the credential) is recoverable.
+ */
+export function isRecoverableAssertionFailure(error: unknown): boolean {
+ const code = (error as { error?: unknown } | null)?.error;
+ if (
+ typeof code === 'string' &&
+ (code === 'UserCancelled' || code === 'TimedOut' || code === 'Interrupted')
+ ) {
+ return false;
+ }
+ return true;
+}
+
/**
* Run the assertion (existing passkey) or attestation (first-time) exchange.
* Resolves `{ superseded: true }` when the run was cancelled mid-flight, or
@@ -224,10 +248,15 @@ export async function authenticateLiquidAuth(
});
let assertionOptions: { credentialId: string; options: any } | null = null;
- for (const credentialId of uniqueAssertionCredentialIds) {
- assertionOptions = await fetchAssertionOptions(fetchWithTimeout, origin, credentialId);
- if (!isActive()) return { superseded: true };
- if (assertionOptions) break;
+ // Skip looking up an existing passkey when recovering from a failed native
+ // assertion: the device can no longer use the credential, so go straight to
+ // attestation to re-register it.
+ if (!params.recoverFromFailedAssertion) {
+ for (const credentialId of uniqueAssertionCredentialIds) {
+ assertionOptions = await fetchAssertionOptions(fetchWithTimeout, origin, credentialId);
+ if (!isActive()) return { superseded: true };
+ if (assertionOptions) break;
+ }
}
if (assertionOptions) {
@@ -269,9 +298,28 @@ export async function authenticateLiquidAuth(
device: 'Demo Web Wallet',
};
- const credential = (await navigator.credentials.get({
- publicKey: decodedOptions,
- })) as any;
+ let credential: any;
+ try {
+ credential = (await navigator.credentials.get({
+ publicKey: decodedOptions,
+ })) as any;
+ } catch (assertionError) {
+ // After an app reinstall the platform may no longer hold this passkey even
+ // though the server still has the credential record (so assertion options
+ // were found above). Instead of aborting the whole connection with a
+ // native "The incoming request cannot be validated" error, fall back to
+ // attestation to re-register the passkey. User-driven aborts
+ // (cancel/timeout) are re-thrown so they surface normally.
+ if (!isRecoverableAssertionFailure(assertionError)) throw assertionError;
+ console.warn(
+ 'Passkey assertion failed; re-registering via attestation:',
+ assertionError,
+ );
+ return authenticateLiquidAuth({
+ ...params,
+ recoverFromFailedAssertion: true,
+ });
+ }
if (!isActive()) return { superseded: true };
authFlowInProgressRef.current = false;
@@ -387,7 +435,10 @@ export async function authenticateLiquidAuth(
}
}
} else {
- if (!allowPasskeyCreation) {
+ // Allow re-registration during recovery even when passkey creation is
+ // otherwise disabled: we already know a credential exists server-side, the
+ // device just lost the local passkey (e.g. after an app reinstall).
+ if (!allowPasskeyCreation && !params.recoverFromFailedAssertion) {
throw new Error(
'No existing passkey was found for this connection. Scan the agent QR code again to create one.',
);
diff --git a/lib/liquid-auth/helpers.ts b/lib/liquid-auth/helpers.ts
index 8df7e06..20400f8 100644
--- a/lib/liquid-auth/helpers.ts
+++ b/lib/liquid-auth/helpers.ts
@@ -88,6 +88,40 @@ export function sessionAddressFromData(sessionData: any): string | null {
: null;
}
+/**
+ * The requestId a `/auth/session` payload is currently bound to, if any. The
+ * server persists it under `session.requestId` (and echoes it at the top level
+ * in some responses), so both shapes are tolerated.
+ */
+export function sessionRequestIdFromData(sessionData: any): string | null {
+ return typeof sessionData?.session?.requestId === 'string'
+ ? sessionData.session.requestId
+ : typeof sessionData?.requestId === 'string'
+ ? sessionData.requestId
+ : null;
+}
+
+/**
+ * True when an existing `/auth/session` already authenticates this wallet key
+ * for this exact requestId. When so, a reconnect can renegotiate over the
+ * already-authenticated socket without a fresh passkey assertion — the server
+ * re-announces presence for the bound requestId on the socket's reconnect,
+ * which resolves the waiting peer's `link`.
+ */
+export function sessionAlreadyAuthenticatedForRequest(
+ sessionData: any,
+ key: Key,
+ requestId: string,
+): boolean {
+ if (typeof requestId !== 'string' || requestId.length === 0) return false;
+ const address = sessionAddressFromData(sessionData);
+ return (
+ !!address &&
+ addressMatchesKey(address, key) &&
+ sessionRequestIdFromData(sessionData) === requestId
+ );
+}
+
export function credentialIdFromData(data: any): string | null {
if (!data) return null;
if (typeof data === 'string') return data;
diff --git a/lib/notifications.ts b/lib/notifications.ts
new file mode 100644
index 0000000..aeb73f4
--- /dev/null
+++ b/lib/notifications.ts
@@ -0,0 +1,34 @@
+/**
+ * Notification permission helpers.
+ *
+ * The Liquid Auth connection runs inside a native foreground service
+ * (`react-native-liquid-auth`) that shows an ongoing "connected" notification
+ * plus per-message notifications while the app is backgrounded. Since Android
+ * 13 (API 33) `POST_NOTIFICATIONS` is a *runtime* permission: declaring it in
+ * the manifest is not enough — the app must request it and the user must grant
+ * it, or the service runs silently with no notifications. `expo-notifications`
+ * requests the platform permission (`POST_NOTIFICATIONS` on Android, the
+ * user-notification authorization on iOS).
+ */
+
+import * as Notifications from 'expo-notifications';
+
+/**
+ * Ensure the runtime notification permission is granted, requesting it once if
+ * it can still be asked for. Non-fatal: returns `false` (rather than throwing)
+ * when the permission is unavailable/denied, so the caller can proceed — the
+ * background service still runs, just without visible notifications.
+ */
+export async function ensureNotificationPermission(): Promise {
+ try {
+ const current = await Notifications.getPermissionsAsync();
+ if (current.granted) return true;
+ // Already permanently denied (user chose "Don't allow"): don't nag.
+ if (!current.canAskAgain) return false;
+ const requested = await Notifications.requestPermissionsAsync();
+ return requested.granted;
+ } catch (err) {
+ console.warn('[ac2] Failed to request notification permission', err);
+ return false;
+ }
+}
diff --git a/metro.config.js b/metro.config.js
index 95f8c7d..4b7fcc5 100644
--- a/metro.config.js
+++ b/metro.config.js
@@ -1,8 +1,20 @@
+const path = require('path');
const { withNativeWind } = require('nativewind/metro');
const { getSentryExpoConfig } = require('@sentry/react-native/metro');
const config = getSentryExpoConfig(__dirname);
+// `react-native-liquid-auth` is vendored as an Expo local module under
+// `modules/` (see modules/react-native-liquid-auth/VENDORED.md), which is NOT on
+// the node resolution path. Alias the bare specifier to the vendored `src` so
+// every existing `require('react-native-liquid-auth')` / import resolves
+// unchanged and Metro bundles the module's TypeScript directly (no build step).
+// When the package is published, delete this alias and add a scoped dependency.
+config.resolver.extraNodeModules = {
+ ...config.resolver.extraNodeModules,
+ 'react-native-liquid-auth': path.resolve(__dirname, 'modules/react-native-liquid-auth/src'),
+};
+
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (moduleName === 'crypto' || moduleName === 'node:crypto') {
// when importing crypto, resolve to react-native-quick-crypto
diff --git a/modules/react-native-liquid-auth/LICENSE b/modules/react-native-liquid-auth/LICENSE
new file mode 100644
index 0000000..30b20e3
--- /dev/null
+++ b/modules/react-native-liquid-auth/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/modules/react-native-liquid-auth/VENDORED.md b/modules/react-native-liquid-auth/VENDORED.md
new file mode 100644
index 0000000..4082971
--- /dev/null
+++ b/modules/react-native-liquid-auth/VENDORED.md
@@ -0,0 +1,90 @@
+# Vendored: react-native-liquid-auth (Expo local module)
+
+This directory is a **vendored copy** of the `react-native-liquid-auth` package,
+consumed by `ac2-wallet` as an [Expo local module](https://docs.expo.dev/modules/get-started/#creating-the-local-expo-module)
+under `modules/`. It exists so the wallet is a self-contained, buildable app
+without a pnpm workspace, a published package, or an externally checked-out
+sibling repo.
+
+## Provenance
+
+- **Upstream repo:** https://github.com/algorandfoundation/react-native-liquid-auth
+- **Upstream commit:** `1b5d4ca7dc69b11c13a44e0a7696a877eff2a0bc` (`chore: bind connections to service`)
+- **Vendored on:** 2026-07-23
+- **Re-synced on:** 2026-07-24 — persistent signaling socket (peer-only
+ `cancel()` that no longer tears the socket down) and the new
+ `onSignalingStateChange` event / `signalingConnected` snapshot field, applied
+ from the upstream working tree alongside the same wallet-side changes.
+
+## Sync direction (one-way: upstream → copy)
+
+This is a **one-way mirror**. The upstream repo is the source of truth. Do not
+make behavioral edits to the native Kotlin/Swift or the `src/` TypeScript here;
+change upstream first, then re-vendor. Consistent with the vendor-a-copy
+convention (consolidation decisions D1/D7).
+
+### Intentional local divergence (build config only, not behavior)
+
+- **`android/build.gradle` — WebRTC is `compileOnly`.** Upstream declares
+ `implementation "io.getstream:stream-webrtc-android:1.1.3"` so the standalone
+ package ships its own `org.webrtc.*`. In this wallet, `react-native-webrtc`
+ already ships the same `org.webrtc` API (`org.jitsi:webrtc:124.+`), so two
+ implementations on the app classpath fail the Android build with
+ `Duplicate class org.webrtc.*` (63 collisions). The dependency is changed to
+ `compileOnly` here so the module compiles against `org.webrtc` but the app's
+ WebRTC provides those classes at runtime. The module only imports standard
+ `org.webrtc.*` types (verified), so this is safe. Do **not** back-port this
+ upstream — the standalone package still needs `implementation`; it is a
+ consumption-side adjustment specific to the wallet (which also depends on
+ `react-native-webrtc`).
+
+## What was copied / trimmed
+
+Copied from upstream (verbatim):
+
+- `src/` — JS/TS API (`index.ts`, `LiquidAuthNativeModule.ts`, `.web.ts`,
+ `LiquidAuthNative.types.ts`, `nativeChannel.ts`). Consumed directly by Metro;
+ there is **no `build/` step** for a local module.
+- `android/` — Kotlin native module + gradle (autolinked from `modules/`).
+- `ios/` — Swift native module + `LiquidAuthNative.podspec` + vendored
+ `LiquidAuthSDK/` (autolinked from `modules/`).
+- `expo-module.config.json` — declares the native module for autolinking.
+- `LICENSE`.
+
+Excluded (not needed to build/consume as a local module):
+
+- `node_modules/`, `build/`, `example/`, `internal/`, `pnpm-lock.yaml`, `.git`.
+- The upstream `src/__tests__/` (the package's own Jest suite).
+
+The `package.json` here is **trimmed**: `main`/`types` point at `src/index.ts`
+(Metro bundles the TypeScript directly), and build scripts / devDependencies /
+lockfile are dropped. `name`, `version`, and `peerDependencies` are kept so Expo
+autolinking still discovers the module.
+
+## How the wallet reaches it
+
+The bare specifier `react-native-liquid-auth` is mapped to
+`modules/react-native-liquid-auth/src` via a Metro resolver alias
+(`metro.config.js`), so every existing `require('react-native-liquid-auth')` /
+import resolves unchanged. The vendored tree is excluded from the wallet's
+`tsc`, `oxlint`, and `oxfmt` (and scoped in Jest via `moduleNameMapper`).
+
+## Known caveat
+
+**Android (resolved):** the module's `org.webrtc` provider (`stream-webrtc-android`)
+collided with the wallet's `react-native-webrtc` (`org.jitsi:webrtc`), failing
+`expo run:android` / `:app:assembleDebug` with `Duplicate class org.webrtc.*`.
+Fixed by declaring the module's WebRTC dependency `compileOnly` (see "Intentional
+local divergence" above) — `:app:assembleDebug` now `BUILD SUCCESSFUL`.
+
+**iOS (open):** the iOS podspec pulls `WebRTC-lib`, which can similarly clash
+with the wallet's `react-native-webrtc` at `pod install`. This is not introduced
+by vendoring; it is resolved when the in-process WebRTC path is retired (Phase 4
+cleanup).
+
+## Upgrade path (removing this copy)
+
+When the package is published to a registry, this is a small, mechanical change:
+delete `modules/react-native-liquid-auth/`, drop the Metro alias +
+`tsconfig`/`oxlint`/`oxfmt`/Jest isolation entries, and add a normal scoped
+dependency (e.g. `@algorandfoundation/react-native-liquid-auth`).
diff --git a/modules/react-native-liquid-auth/android/build.gradle b/modules/react-native-liquid-auth/android/build.gradle
new file mode 100644
index 0000000..d73fd55
--- /dev/null
+++ b/modules/react-native-liquid-auth/android/build.gradle
@@ -0,0 +1,40 @@
+plugins {
+ id 'com.android.library'
+ id 'expo-module-gradle-plugin'
+}
+
+group = 'co.algorand.liquid'
+version = '0.1.0'
+
+android {
+ namespace "co.algorand.liquid"
+ defaultConfig {
+ versionCode 1
+ versionName "0.1.0"
+ }
+ lintOptions {
+ abortOnError false
+ }
+}
+
+dependencies {
+ // Signaling transport
+ implementation "io.socket:socket.io-client:2.1.0"
+ // Native WebRTC — provides the `org.webrtc.*` API used by the signaling code.
+ // In this wallet, `react-native-webrtc` already ships the same `org.webrtc`
+ // API (via `org.jitsi:webrtc:124.+`), so this is declared `compileOnly` to
+ // avoid two implementations of `org.webrtc.*` on the app classpath (which
+ // otherwise fails the build with "Duplicate class org.webrtc.*"). The app's
+ // WebRTC provides these classes at runtime. See VENDORED.md ("Known caveat").
+ compileOnly "io.getstream:stream-webrtc-android:1.1.3"
+ // HTTP client shared with the signaling socket
+ implementation "com.squareup.okhttp3:okhttp:4.12.0"
+ // Coroutines used by the signaling client
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1"
+ // QR Code generation (offer/link handshake)
+ implementation "io.github.g0dkar:qrcode-kotlin:4.1.1"
+ // Time-based UUID request ids
+ implementation "com.fasterxml.uuid:java-uuid-generator:5.1.0"
+ // Foreground service + notifications helpers
+ implementation "androidx.core:core-ktx:1.12.0"
+}
diff --git a/modules/react-native-liquid-auth/android/src/main/AndroidManifest.xml b/modules/react-native-liquid-auth/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..bf8a0d3
--- /dev/null
+++ b/modules/react-native-liquid-auth/android/src/main/AndroidManifest.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/react-native-liquid-auth/android/src/main/java/co/algorand/liquid/LiquidAuthNativeModule.kt b/modules/react-native-liquid-auth/android/src/main/java/co/algorand/liquid/LiquidAuthNativeModule.kt
new file mode 100644
index 0000000..9470de3
--- /dev/null
+++ b/modules/react-native-liquid-auth/android/src/main/java/co/algorand/liquid/LiquidAuthNativeModule.kt
@@ -0,0 +1,652 @@
+package co.algorand.liquid
+
+import android.app.Activity
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Build
+import android.os.IBinder
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import expo.modules.kotlin.Promise
+import expo.modules.kotlin.exception.Exceptions
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import foundation.algorand.auth.connect.AuthMessage
+import foundation.algorand.auth.connect.HeartbeatConfig
+import foundation.algorand.auth.connect.NotificationContent
+import foundation.algorand.auth.connect.NotificationStatus
+import foundation.algorand.auth.connect.SignalClient
+import foundation.algorand.auth.connect.SignalService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import okhttp3.Cookie
+import okhttp3.CookieJar
+import okhttp3.HttpUrl
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import org.webrtc.DataChannel
+import org.webrtc.PeerConnection
+
+/**
+ * React Native (Expo) bindings for the Liquid Auth signaling service.
+ *
+ * Wraps the native [SignalService] foreground service (migrated from
+ * `liquid-auth-android`) so wallets can drive the WebRTC signaling handshake
+ * from JavaScript instead of talking to the signaling client directly.
+ */
+class LiquidAuthNativeModule : Module() {
+ companion object {
+ const val TAG = "LiquidAuthNative"
+ const val CHANNEL_ID = "liquid_auth_signaling"
+ const val CHANNEL_NAME = "Liquid Auth"
+ const val NOTIFICATION_ID = 1337
+ const val ON_MESSAGE = "onMessage"
+ const val ON_STATE_CHANGE = "onStateChange"
+ const val ON_TRACK = "onTrack"
+ const val ON_PRESENCE = "onPresence"
+ const val ON_LINK_ERROR = "onLinkError"
+ const val ON_CONNECTION_STATE_CHANGE = "onConnectionStateChange"
+ const val ON_SIGNALING_STATE_CHANGE = "onSignalingStateChange"
+ }
+
+ private var signalService: SignalService? = null
+ private var serviceConnection: ServiceConnection? = null
+ // A single cookie-jar-backed HTTP client is shared between the authenticated
+ // FIDO/session requests issued from JS via `request(...)` and the background
+ // [SignalService] socket, so the `connect.sid` session cookie set during the
+ // auth ceremony automatically rides the signaling connection (see D9 /
+ // docs/NATIVE_AUTH_SESSION.md).
+ private val cookieJar = LiquidCookieJar()
+ private val httpClient = OkHttpClient.Builder().cookieJar(cookieJar).build()
+ private val scope = CoroutineScope(Dispatchers.Main)
+
+ private val context: Context
+ get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
+
+ private val currentActivity: Activity
+ get() = appContext.currentActivity ?: throw Exceptions.MissingActivity()
+
+ override fun definition() = ModuleDefinition {
+ Name("LiquidAuthNative")
+
+ Events(ON_MESSAGE, ON_STATE_CHANGE, ON_TRACK, ON_PRESENCE, ON_LINK_ERROR, ON_CONNECTION_STATE_CHANGE, ON_SIGNALING_STATE_CHANGE)
+
+ /**
+ * Generate a random (time-based) request id.
+ */
+ Function("generateRequestId") {
+ SignalClient.generateRequestId()
+ }
+
+ /**
+ * Parse a `liquid:///?requestId=` URI (or JSON payload) into
+ * its `origin` and `requestId` parts.
+ */
+ Function("parseMessage") { value: String ->
+ val message = AuthMessage.fromString(value)
+ mapOf(
+ "origin" to message.origin,
+ "requestId" to message.requestId
+ )
+ }
+
+ /**
+ * Start (and bind to) the foreground signaling service and connect the
+ * signaling client to the given origin.
+ */
+ AsyncFunction("start") { url: String, promise: Promise ->
+ bindService {
+ val service = signalService
+ if (service == null) {
+ promise.reject("E_SERVICE", "Failed to bind the signaling service", null)
+ return@bindService
+ }
+ try {
+ service.start(
+ url,
+ httpClient,
+ createNotificationBuilder(),
+ NOTIFICATION_ID,
+ currentActivity::class.java,
+ // The persistent signaling socket comes up at service start (not
+ // lazily on the first negotiation), so presence and signaling
+ // connectivity must already be routed to JS here.
+ onPresence = { presence -> sendEvent(ON_PRESENCE, jsonToMap(presence)) },
+ onSignalingState = { state ->
+ sendEvent(ON_SIGNALING_STATE_CHANGE, mapOf("state" to state))
+ }
+ )
+ promise.resolve(null)
+ } catch (e: Exception) {
+ promise.reject("E_START", e.message, e)
+ }
+ }
+ }
+
+ /**
+ * Connect to a remote peer by `requestId`. `type` is the remote peer type
+ * (`"offer"` or `"answer"`). Data-channel messages and state changes are
+ * forwarded through the `onMessage` / `onStateChange` events.
+ */
+ AsyncFunction("connect") { requestId: String, type: String, iceServers: List