From c776a1c81a365b954cef104fb8fc1242d2cee6e0 Mon Sep 17 00:00:00 2001 From: Michael J Feher Date: Tue, 21 Jul 2026 13:41:07 -0400 Subject: [PATCH 1/9] wip: presence detection for liquid auth --- .../components/chat/chat-reconnect.test.tsx | 28 + __tests__/lib/ac2/presence.test.ts | 207 +++++++ __tests__/lib/liquid-auth/flow.test.ts | 58 ++ __tests__/lib/liquid-auth/helpers.test.ts | 88 +++ components/chat/ChatScreen.tsx | 9 +- components/chat/ReconnectBar.tsx | 26 +- hooks/useConnection.ts | 527 +++++++++++++++--- lib/ac2/index.ts | 12 +- lib/ac2/presence.ts | 173 ++++++ lib/ac2/transport.ts | 20 +- lib/liquid-auth/flow.ts | 67 ++- lib/liquid-auth/helpers.ts | 34 ++ package.json | 4 +- pnpm-lock.yaml | 76 +-- 14 files changed, 1191 insertions(+), 138 deletions(-) create mode 100644 __tests__/lib/ac2/presence.test.ts create mode 100644 __tests__/lib/liquid-auth/flow.test.ts create mode 100644 __tests__/lib/liquid-auth/helpers.test.ts create mode 100644 lib/ac2/presence.ts diff --git a/__tests__/components/chat/chat-reconnect.test.tsx b/__tests__/components/chat/chat-reconnect.test.tsx index 2d8be52..491bd58 100644 --- a/__tests__/components/chat/chat-reconnect.test.tsx +++ b/__tests__/components/chat/chat-reconnect.test.tsx @@ -60,6 +60,8 @@ function baseConnection() { isError: false, isLoading: false, isReconnecting: false, + peerOffline: false, + isSocketConnected: true, reconnectAttempt: 0, maxReconnectAttempts: 3, send: jest.fn(), @@ -111,6 +113,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(); 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/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/components/chat/ChatScreen.tsx b/components/chat/ChatScreen.tsx index 5d69a29..038032e 100644 --- a/components/chat/ChatScreen.tsx +++ b/components/chat/ChatScreen.tsx @@ -47,6 +47,8 @@ function ChatScreen({ origin, requestId, allowPasskeyCreation = false }: ChatScr isError, isLoading, isReconnecting, + peerOffline, + isSocketConnected, reconnectAttempt, maxReconnectAttempts, send, @@ -301,7 +303,12 @@ function ChatScreen({ origin, requestId, allowPasskeyCreation = false }: ChatScr ) : isLoading ? ( ) : ( - + )} 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 — retrying…' + : 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} (null); const streamChannelRef = useRef(null); @@ -191,6 +227,38 @@ 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). Handled outside the SignalClient, on the socket, via the + // dedicated `presence` websocket event. Used to detect whether there is + // anyone available to (re)connect to. + const [peerPresence, setPeerPresence] = useState(null); + // Live mirror of `peerPresence` so the failure funnel can read the latest + // snapshot synchronously (without re-subscribing on every presence update) + // when deciding whether a connection failure means the peer is offline. + const peerPresenceRef = useRef(null); + peerPresenceRef.current = peerPresence; + // True when we've given up (re)connecting because the peer isn't in the + // requestId room. Surfaced inline in the chat window (a clean banner over the + // composer) rather than as a disruptive pop-up, so the user knows to check + // their remote device. Cleared on a fresh (re)connect and on a successful + // connect. + const [peerOffline, setPeerOffline] = useState(false); + // Whether the signaling socket itself is connected to the Liquid Auth + // service. Owned by the persistent socket effect and kept alive across p2p + // chat drops, so presence checks and renegotiation keep working. Surfaced as + // "Service unavailable" in the chat UI when false. + const [isSocketConnected, setIsSocketConnected] = useState(false); + const isSocketConnectedRef = useRef(false); + isSocketConnectedRef.current = isSocketConnected; + // Disposer for the socket-level `presence` subscription (lives with the + // socket, not the transport). + const presenceUnsubRef = useRef<(() => void) | null>(null); + // True once the persistent socket is established AND connected, so p2p + // negotiation may be attempted (subject to the both-peers-present gate). + const socketReadyRef = useRef(false); + // True while a p2p transport negotiation is in flight, so presence/reconnect + // triggers never stack a second concurrent negotiation on the shared socket. + const transportInFlightRef = useRef(false); // Threads the agent advertised on connect (`conversations` control frame). const [remoteThreads, setRemoteThreads] = useState< { thid: string; title?: string; updatedAt?: number }[] @@ -255,22 +323,66 @@ export function useConnection( } heartbeatChannelRef.current = null; } - if (clientRef.current) { + // Tear down ONLY the p2p peer, keeping the persistent signaling socket + // (and its presence subscription) alive so the app stays connected to the + // service after a chat drop — enabling presence checks and renegotiation + // over the same socket without a fresh auth/passkey. The socket itself is + // owned by the socket effect (see `closeSocket`). + const client = clientRef.current; + if (client) { 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(); + client.peerClient?.close(); } catch { /* noop */ } + // Allow a fresh `peer()` on the reused SignalClient (it refuses to run + // while a peer/requestId is still in progress). + client.peerClient = undefined; + // Detach the per-negotiation listeners the SDK (`peer()`/`signal()`) and + // `createAc2Transport` add on each negotiation, so reusing this socket + // for the next attempt doesn't accumulate duplicate `data-channel` / + // candidate / description handlers that would double-apply signaling. 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. + client.off('data-channel'); + } catch { + /* noop */ + } + const socket = client.socket as any; + try { + socket?.off?.('offer-candidate'); + socket?.off?.('answer-candidate'); + socket?.off?.('offer-description'); + socket?.off?.('answer-description'); + } catch { + /* noop */ + } + } + }, []); + + // Fully tear down the persistent signaling socket (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 + // socket survives p2p reconnects. + const closeSocket = useCallback(() => { + if (presenceUnsubRef.current) { + try { + presenceUnsubRef.current(); + } catch { + /* noop */ + } + presenceUnsubRef.current = null; + } + socketReadyRef.current = false; + setIsSocketConnected(false); + if (clientRef.current) { + try { + // `close(true)` detaches listeners AND disconnects the underlying + // socket.io connection. clientRef.current.close(true); } catch { /* noop */ @@ -308,6 +420,9 @@ export function useConnection( autoReconnectTimerRef.current = null; } clearTransport(); + // An explicit user disconnect also drops the persistent signaling socket: + // the user is leaving the session, so there is nothing to stay present for. + closeSocket(); setActiveStreamText(''); setAgentPresence(null); setAgentPresenceDetail(null); @@ -316,8 +431,9 @@ export function useConnection( setIsReconnecting(false); setReconnectAttempt(0); setError(null); + setPeerOffline(false); updateSessionStatus(requestId, origin, 'closed'); - }, [requestId, origin, clearTransport]); + }, [requestId, origin, clearTransport, closeSocket]); // Core reconnect primitive: tear down any stale transport (so the connection // effect's guard doesn't short-circuit), flip into the loading/connecting @@ -335,6 +451,8 @@ export function useConnection( lastInboundActivityRef.current = Date.now(); lastLocalActivityRef.current = Date.now(); setError(null); + // A fresh attempt is starting — clear any "peer offline" notice. + setPeerOffline(false); setIsConnected(false); setIsLoading(true); setReconnectNonce((n) => n + 1); @@ -350,6 +468,17 @@ export function useConnection( reconnectAttemptRef.current = 0; setReconnectAttempt(0); setIsReconnecting(false); + if (!clientRef.current) { + // The persistent socket was fully torn down (an explicit disconnect): + // rebuild it. The socket effect re-authenticates, reconnects, and drives + // presence-gated p2p negotiation once both peers are present again. + setError(null); + setPeerOffline(false); + setIsConnected(false); + setIsLoading(true); + setSocketNonce((n) => n + 1); + return; + } performReconnect(); }, [performReconnect]); const reconnectRef = useRef(reconnect); @@ -406,7 +535,20 @@ export function useConnection( if (!scheduleAutoReconnectRef.current()) { setIsReconnecting(false); setIsLoading(false); - if (error) { + // Distinguish "the peer simply isn't there" from a generic failure so + // the user gets an actionable message instead of a cryptic timeout. + // The peer is deemed offline when the signaling server reports nobody + // but us in the requestId room (presence) or when the negotiation timed + // out waiting for the peer's answer-description. + const peerIsOffline = + isPeerOffline(peerPresenceRef.current) || isPeerUnreachableError(error); + if (peerIsOffline) { + // Surface this inline in the chat window (see ChatScreen) rather than + // as a pop-up: tell the user the chat can't connect and that they + // should check their remote device. + if (error) setError(error); + setPeerOffline(true); + } else if (error) { setError(error); Alert.alert( 'Connection Failed', @@ -431,6 +573,61 @@ export function useConnection( const isReconnectingRef = useRef(isReconnecting); isReconnectingRef.current = isReconnecting; + // Attempt a p2p (re)negotiation IFF it is safe and worthwhile. Peers must not + // negotiate without knowing they both exist, so this only proceeds when the + // persistent socket is connected, we aren't already connected/negotiating, + // and the signaling server reports the peer present in the requestId room. + // When the peer is absent it simply waits — the next `presence` broadcast (or + // a manual Reconnect) re-invokes this once both parties are back in the room. + const maybeNegotiate = useCallback(() => { + if (userStoppedRef.current) return; + if (!socketReadyRef.current) return; + if (isConnectedRef.current || transportInFlightRef.current) return; + if (autoReconnectTimerRef.current) return; + // Both peers must be present (deviceCount >= 2) before we negotiate p2p. + if (isPeerOffline(peerPresenceRef.current)) { + setIsLoading(false); + setPeerOffline(true); + return; + } + performReconnectRef.current(); + }, []); + const maybeNegotiateRef = useRef(maybeNegotiate); + maybeNegotiateRef.current = maybeNegotiate; + + // The signaling server reports the peer has left the requestId room (presence + // deviceCount dropped to just us). Presence is authoritative and immediate, so + // proactively tear down the p2p transport and surface a clean inline "Peer + // offline" notice right away, instead of waiting out the heartbeat/ICE + // watchdog — the heartbeat only keeps a LIVE connection alive while BOTH peers + // are online. We do NOT schedule a reconnect here: with the peer gone there is + // nothing to connect to. The next presence broadcast showing both peers back + // in the room drives renegotiation via `maybeNegotiate`. + const handlePeerOffline = useCallback(() => { + // Respect an explicit user disconnect — nothing to keep present for. + if (userStoppedRef.current) return; + // Cancel any pending automatic reconnect: retrying is pointless while the + // peer is absent and would otherwise flip the UI back into "Connecting…". + if (autoReconnectTimerRef.current) { + clearTimeout(autoReconnectTimerRef.current); + autoReconnectTimerRef.current = null; + } + reconnectAttemptRef.current = 0; + setReconnectAttempt(0); + setIsReconnecting(false); + // Tear down ONLY the p2p peer/data-channels (the persistent socket and its + // presence subscription stay alive so we keep receiving broadcasts). Flag + // the teardown as deliberate so the channel `onClose` doesn't re-enter the + // failure/auto-reconnect path. + deliberateCloseRef.current = true; + clearTransport(); + setIsConnected(false); + setIsLoading(false); + setPeerOffline(true); + }, [clearTransport]); + const handlePeerOfflineRef = useRef(handlePeerOffline); + handlePeerOfflineRef.current = handlePeerOffline; + // 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 @@ -460,12 +657,14 @@ export function useConnection( // 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. + // a p2p transport negotiation is already running, we're in the + // loading/connecting state, an auto-reconnect sequence is running, or a + // retry timer is already pending. NOTE: we no longer treat a live + // `clientRef` (the persistent socket) as "busy" — it is expected to stay + // connected across chat drops, and a resume only re-negotiates the peer. if ( authFlowInProgressRef.current || - clientRef.current || + transportInFlightRef.current || isLoadingRef.current || isReconnectingRef.current || autoReconnectTimerRef.current @@ -685,8 +884,13 @@ export function useConnection( return; } - // If we are already connecting or connected, don't start again - if (clientRef.current || isConnected) { + // The persistent socket is already established for this session — the + // socket effect only builds it once (it survives p2p chat drops). + if (clientRef.current) { + return; + } + // Never resurrect a session the user explicitly disconnected. + if (userStoppedRef.current) { return; } @@ -764,25 +968,42 @@ export function useConnection( } } - const authResult = await authenticateLiquidAuth({ - origin, - requestId, - foundKey, - walletAddress, - currentKeys, - initialSessionData, - initialSessionAddress, - existingSessionPasskeyCredentialId: existingSession?.passkeyCredentialId, - allowPasskeyCreation, - key, - passkey, - setAddress, - addressRef, - authFlowInProgressRef, - fetchWithTimeout, - isActive: () => active, - }); - if (authResult.superseded || !active) return; + // 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, + foundKey, + walletAddress, + currentKeys, + initialSessionData, + initialSessionAddress, + existingSessionPasskeyCredentialId: existingSession?.passkeyCredentialId, + allowPasskeyCreation, + key, + passkey, + setAddress, + addressRef, + authFlowInProgressRef, + fetchWithTimeout, + isActive: () => active, + }); + if (authResult.superseded || !active) return; + } console.log(`[ac2] auth phase done in ${Date.now() - setupStartedAt}ms`); // Final validation of the session before connecting @@ -838,6 +1059,141 @@ export function useConnection( //@ts-ignore client.authenticated = true; + // Wait for the socket to actually connect before wiring any listeners. + // `SignalClient` initializes its socket asynchronously (it dynamically + // imports socket.io-client), so `client.socket` is `undefined` right + // after construction — subscribing to presence or connect/disconnect + // events before this point throws "Cannot read property 'on' of + // undefined". Awaiting here guarantees `client.socket` exists. + await waitForSignalSocketConnected(client); + if (!active) return; + + // Track socket connectivity so the chat surface can show "Service + // unavailable" while the signaling service is unreachable. The socket is + // kept alive across p2p chat drops; socket.io auto-reconnects transient + // drops without rebuilding the client, and on each (re)connect the + // server rejoins us to the requestId room and rebroadcasts presence. + const socket = client.socket as any; + const onSocketConnect = () => { + if (!active) return; + setIsSocketConnected(true); + socketReadyRef.current = true; + maybeNegotiateRef.current(); + }; + const onSocketDisconnect = () => { + if (!active) return; + setIsSocketConnected(false); + socketReadyRef.current = false; + }; + socket?.on?.('connect', onSocketConnect); + socket?.on?.('disconnect', onSocketDisconnect); + + // Presence lives with the socket (outside the p2p transport) so it keeps + // working across chat drops and drives presence-gated renegotiation: + // peers must both be present in the requestId room before negotiating. + presenceUnsubRef.current = subscribeToPresence(socket, (presence) => { + if (!active) return; + console.log( + `[ac2] presence for ${presence.requestId}: ${presence.deviceCount} device(s), online=${presence.online}`, + ); + setPeerPresence(presence); + peerPresenceRef.current = presence; + if (isPeerOffline(presence)) { + // The peer isn't in the requestId room. Presence is authoritative + // and immediate, so react now whether or not a chat is live: if we + // were connected, the peer just left, so proactively tear the + // transport down and show "Peer offline" instead of waiting out the + // heartbeat/ICE watchdog; if we weren't, surface the same clean + // inline notice rather than an endless "Connecting…". Only progress + // to connecting again once the peer is back (the else branch). + handlePeerOfflineRef.current(); + } else { + // Both peers are present: (re)negotiate the p2p transport. + setPeerOffline(false); + maybeNegotiateRef.current(); + } + }); + + setIsSocketConnected(true); + socketReadyRef.current = true; + console.log(`[ac2] socket phase done in ${Date.now() - setupStartedAt}ms`); + + // Seed presence so the first negotiation decision is based on a real + // room count instead of an unknown; broadcasts drive it afterwards. A + // failed query is non-fatal (fall back to broadcasts). + try { + const seeded = await queryPresence(socket, requestId); + if (!active) return; + setPeerPresence(seeded); + peerPresenceRef.current = seeded; + } catch (err) { + console.log('[ac2] initial presence query failed (will rely on broadcasts)', err); + } + // Attempt the first p2p negotiation (gated on both peers being present). + maybeNegotiateRef.current(); + } catch (err: any) { + // A superseded run (cleanup 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 signaling socket:', err); + updateSessionStatus(requestId, origin, 'failed'); + setIsLoading(false); + setIsSocketConnected(false); + socketReadyRef.current = false; + // Surface the auth/network failure. Peer-presence gating (the peer + // simply not being online) is handled by the presence path above. + setError(err); + } finally { + // Only release the auth lock if this run is still the active one. + if (active) authFlowInProgressRef.current = false; + } + } + + setupConnection(); + + return () => { + active = false; + // Release the auth lock before the new run starts so it isn't blocked. + authFlowInProgressRef.current = false; + runAbort.abort(); + // The socket is going away for good (session change / unmount / explicit + // rebuild): tear down the p2p transport too, then close the socket. + clearTransport(); + closeSocket(); + }; + }, [origin, requestId, accounts.length > 0, keys.length > 0, socketNonce]); + + // Negotiate (and re-negotiate) the p2p transport over the PERSISTENT socket. + // Keyed on the reconnect nonce so each manual/auto/presence-driven attempt + // runs as its own superseded-safe run. Reuses `clientRef.current` (the + // socket) and NEVER closes it on teardown — only the peer/data-channels are + // torn down, so the app stays connected to the service between chats. + useEffect(() => { + // Nothing to negotiate until the persistent socket exists and is connected. + if (!clientRef.current || !socketReadyRef.current) return; + if (isConnectedRef.current) return; + + let active = true; + const runAbort = new AbortController(); + const setupStartedAt = Date.now(); + + async function negotiateTransport() { + if (userStoppedRef.current) return; + if (transportInFlightRef.current) return; + const client = clientRef.current; + if (!client) return; + // Peers must both be present (deviceCount >= 2) before negotiating p2p. + if (isPeerOffline(peerPresenceRef.current)) { + setIsLoading(false); + setPeerOffline(true); + return; + } + + transportInFlightRef.current = true; + setIsLoading(true); + setError(null); + + 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 @@ -855,6 +1211,8 @@ export function useConnection( setRemoteThreads, }); + // Presence is subscribed on the persistent socket (socket effect), so it + // is intentionally NOT re-subscribed here. const { datachannel } = await createAc2Transport({ requestId, signalClient: client, @@ -894,12 +1252,11 @@ export function useConnection( }); 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); + // 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 socket here. return; } @@ -1003,6 +1360,9 @@ export function useConnection( reconnectAttemptRef.current = 0; setReconnectAttempt(0); setIsReconnecting(false); + // We've actually reached the peer — clear any "peer offline" + // notice so the live chat is shown. + setPeerOffline(false); setIsConnected(true); setIsLoading(false); setAc2Client(ac2); @@ -1025,35 +1385,28 @@ export function useConnection( ac2ClientRef.current = ac2; } 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. + // aborted) must do nothing: the newer run owns all recovery. if (!active || err?.name === 'AbortError') return; - console.error('Failed to setup connection:', err); + 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. + // partially-established peer (via `clearTransport`, socket preserved) + // and hands off to the bounded auto-reconnect scheduler, surfacing the + // terminal error + manual fallback only once the retry budget is spent. 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; + // Only release the negotiation lock if this run is still the active one. + if (active) transportInFlightRef.current = false; } } - setupConnection(); + negotiateTransport(); 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; + // Release the negotiation lock before the next run starts (the `finally` + // above is guarded by `active`, now false, so it won't reset it itself). + transportInFlightRef.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. @@ -1084,23 +1437,56 @@ export function useConnection( 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 (streamChannelRef.current) { + try { + streamChannelRef.current.close(); + } catch { + /* noop */ + } + streamChannelRef.current = null; + } + if (heartbeatChannelRef.current) { + try { + heartbeatChannelRef.current.close(); + } catch { + /* noop */ + } + heartbeatChannelRef.current = null; + } + const client = clientRef.current; + if (client) { + // Only hard-close the peer once this run owned an established transport. + // For a superseded run, `runAbort.abort()` above already cancelled the + // logical attempt; force-closing the native peer here can race + // Android's in-flight `setRemoteDescription` and crash. NEVER close the + // socket here — it is owned by the socket effect and must stay alive. if (hadEstablishedTransport) { try { - clientRef.current.peerClient?.close(); + client.peerClient?.close(); } catch { /* noop */ } } - clientRef.current.close(true); - clientRef.current = null; + client.peerClient = undefined; + // Detach the per-negotiation listeners so reusing this socket for the + // next attempt doesn't accumulate duplicate handlers. + try { + client.off('data-channel'); + } catch { + /* noop */ + } + const s = client.socket as any; + try { + s?.off?.('offer-candidate'); + s?.off?.('answer-candidate'); + s?.off?.('offer-description'); + s?.off?.('answer-description'); + } catch { + /* noop */ + } } }; - }, [origin, requestId, accounts.length > 0, keys.length > 0, reconnectNonce]); + }, [origin, requestId, reconnectNonce]); return { session, @@ -1111,6 +1497,9 @@ export function useConnection( activeStreamText, agentPresenceDetail, agentPresence, + peerPresence, + peerOffline, + isSocketConnected, error, isError: !!error, isLoading, diff --git a/lib/ac2/index.ts b/lib/ac2/index.ts index 9c5b3d2..3beb205 100644 --- a/lib/ac2/index.ts +++ b/lib/ac2/index.ts @@ -24,9 +24,19 @@ export type { MonitorPeerConnectionOptions, PeerConnectionFailureReason, } from './peerConnectionMonitor'; +export { + hasPeerPresence, + isPeerOffline, + isPeerUnreachableError, + normalizePresence, + PRESENCE_EVENT, + queryPresence, + subscribeToPresence, +} from './presence'; +export type { PresenceResult, PresenceSocket } from './presence'; export { parseStreamControlFrame, STX } from './stream'; export type { AgentPresence, StreamControlFrame } from './stream'; -export { createAc2Transport } from './transport'; +export { createAc2Transport, waitForSignalSocketConnected } from './transport'; export type { Ac2TransportSetup, CreateAc2TransportOptions } from './transport'; export type { AC2BaseMessage as Ac2Message } from '@algorandfoundation/ac2-sdk/schema'; diff --git a/lib/ac2/presence.ts b/lib/ac2/presence.ts new file mode 100644 index 0000000..e79a32a --- /dev/null +++ b/lib/ac2/presence.ts @@ -0,0 +1,173 @@ +/** + * 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); +} + +/** + * 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/transport.ts b/lib/ac2/transport.ts index d126ecc..470d0b6 100644 --- a/lib/ac2/transport.ts +++ b/lib/ac2/transport.ts @@ -6,6 +6,8 @@ import { SignalClient } from '@algorandfoundation/liquid-client'; +import { subscribeToPresence, type PresenceResult } from './presence'; + /** Default ICE config for the Liquid Auth signaling pair. */ const DEFAULT_ICE_SERVERS = [ { @@ -63,6 +65,13 @@ export interface CreateAc2TransportOptions { * immediately and the returned promise rejects with an `AbortError`. */ signal?: AbortSignal; + /** + * Optional presence listener. When provided, server-broadcast `presence` + * updates for the `requestId` are forwarded here so the caller can track how + * many devices are connected (and decide whether reconnecting is worthwhile). + * Handled outside `SignalClient`, directly on the signaling socket. + */ + onPresence?: (presence: PresenceResult) => void; } /** @@ -73,7 +82,7 @@ export interface CreateAc2TransportOptions { export async function createAc2Transport( opts: CreateAc2TransportOptions, ): Promise { - const { requestId, signalClient, onSideChannel, onPeerConnection, signal } = opts; + const { requestId, signalClient, onSideChannel, onPeerConnection, signal, onPresence } = opts; if (signal?.aborted) { const err = new Error('Aborted'); @@ -89,6 +98,13 @@ export async function createAc2Transport( await waitForSignalSocketConnected(signalClient); installSignalCandidateNormalizer(signalClient); + // Presence is handled outside the SignalClient, directly on the socket: keep + // the caller informed of how many devices are connected for this requestId. + if (onPresence) { + const socket = (signalClient as any).socket; + if (socket) subscribeToPresence(socket, onPresence); + } + const peerPromise = signalClient.peer( requestId, 'answer', @@ -283,7 +299,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/package.json b/package.json index ba8f62b..17a7ee1 100644 --- a/package.json +++ b/package.json @@ -107,9 +107,9 @@ "@types/jest": "^30.0.0", "@types/react": "~19.1.0", "eas-cli": "^20.1.0", - "expo-build-properties": "^55.0.9", + "expo-build-properties": "~1.0.10", "jest": "^29.7.0", - "jest-expo": "^55.0.9", + "jest-expo": "~54.0.17", "lefthook": "^2.1.2", "oxfmt": "^0.44.0", "oxlint": "^1.61.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e041c2..db4abcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,10 +57,10 @@ importers: version: '@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0)' '@algorandfoundation/react-native-keystore': specifier: 1.0.0-canary.13 - version: 1.0.0-canary.13(@tanstack/store@0.9.3)(@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0))(before-after-hook@4.0.0)(expo-build-properties@55.0.14(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 1.0.0-canary.13(@tanstack/store@0.9.3)(@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0))(before-after-hook@4.0.0)(expo-build-properties@1.0.10(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@algorandfoundation/react-native-passkey-autofill': specifier: 1.0.0-canary.21 - version: 1.0.0-canary.21(@tanstack/store@0.9.3)(@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0))(before-after-hook@4.0.0)(expo-build-properties@55.0.14(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 1.0.0-canary.21(@tanstack/store@0.9.3)(@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0))(before-after-hook@4.0.0)(expo-build-properties@1.0.10(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@algorandfoundation/wallet-provider': specifier: 1.0.0-canary.5 version: 1.0.0-canary.5 @@ -213,7 +213,7 @@ importers: version: 3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-quick-crypto: specifier: 1.1.5 - version: 1.1.5(expo-build-properties@55.0.14(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 1.1.5(expo-build-properties@1.0.10(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-reanimated: specifier: ~4.1.7 version: 4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.7)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -258,14 +258,14 @@ importers: specifier: ^20.1.0 version: 20.1.0(@types/node@22.19.19)(typescript@5.8.3) expo-build-properties: - specifier: ^55.0.9 - version: 55.0.14(expo@54.0.35) + specifier: ~1.0.10 + version: 1.0.10(expo@54.0.35) jest: specifier: ^29.7.0 version: 29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3)) jest-expo: - specifier: ^55.0.9 - version: 55.0.18(@babel/core@7.29.7)(canvas@2.11.2)(expo@54.0.35)(jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3)))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.8.3) + specifier: ~54.0.17 + version: 54.0.17(@babel/core@7.29.7)(canvas@2.11.2)(expo@54.0.35)(jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3)))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) lefthook: specifier: ^2.1.2 version: 2.1.9 @@ -1027,9 +1027,6 @@ packages: '@expo/config@55.0.10': resolution: {integrity: sha512-qCHxo9H1ZoeW+y0QeMtVZ3JfGmumpGrgUFX60wLWMarraoQZSe47ZUm9kJSn3iyoPjUtUNanO3eXQg+K8k4rag==} - '@expo/config@55.0.17': - resolution: {integrity: sha512-Y3VaRg7Jllg3MhlUOTQqHm6/dttsqcjYlnS9enhAllZvPUpTHnRA4YPETtUZlxkdMJy6y3UZe986pd/KfJ6OTg==} - '@expo/devcert@1.2.1': resolution: {integrity: sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==} @@ -1174,9 +1171,6 @@ packages: '@expo/schema-utils@0.1.8': resolution: {integrity: sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A==} - '@expo/schema-utils@55.0.4': - resolution: {integrity: sha512-65IdeeE8dAZR3n3J5Eq7LYiQ8BFGeEYCWPBCzycvafL7PkskbCyIclTQarRwf/HXFoRvezKCjaLwy/8v9Prk6g==} - '@expo/sdk-runtime-versions@1.0.0': resolution: {integrity: sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==} @@ -3349,8 +3343,8 @@ packages: react: 19.1.0 react-native: '*' - expo-build-properties@55.0.14: - resolution: {integrity: sha512-5wopuLXlzFpDnCfsIjgAcj4yKnVTYg3XYGnYV5Hqi7u6jJipLG4fwtUqxVIFqBj7vEHXjd4zYCyXjp39AtAD7w==} + expo-build-properties@1.0.10: + resolution: {integrity: sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q==} peerDependencies: expo: '*' @@ -4138,8 +4132,8 @@ packages: resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-expo@55.0.18: - resolution: {integrity: sha512-kfa3iwTHSf5+ZOkxpWtjqZZ78AZvCSpYMkW/mSMNdffFAfEIqD2x1+WNf38U0vAhByaiGYP+m+Hgo4isOhYjkQ==} + jest-expo@54.0.17: + resolution: {integrity: sha512-LyIhrsP4xvHEEcR1R024u/LBj3uPpAgB+UljgV+YXWkEHjprnr0KpE4tROsMNYCVTM1pPlAnPuoBmn5gnAN9KA==} hasBin: true peerDependencies: expo: '*' @@ -6528,7 +6522,7 @@ snapshots: qr-code-styling: 1.9.2 socket.io-client: 4.8.3 - '@algorandfoundation/react-native-keystore@1.0.0-canary.12(@tanstack/store@0.9.3)(@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0))(before-after-hook@4.0.0)(expo-build-properties@55.0.14(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@algorandfoundation/react-native-keystore@1.0.0-canary.12(@tanstack/store@0.9.3)(@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0))(before-after-hook@4.0.0)(expo-build-properties@1.0.10(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: '@algorandfoundation/dp256': 1.1.0 '@algorandfoundation/keystore': 1.0.0-canary.16(@tanstack/store@0.9.3)(@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0))(before-after-hook@4.0.0) @@ -6540,7 +6534,7 @@ snapshots: before-after-hook: 4.0.0 react-native-keychain: 10.0.0 react-native-mmkv: 4.1.2(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - react-native-quick-crypto: 1.0.18(expo-build-properties@55.0.14(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-quick-crypto: 1.0.18(expo-build-properties@1.0.10(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) optionalDependencies: '@algorandfoundation/log-store': '@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0)' transitivePeerDependencies: @@ -6551,7 +6545,7 @@ snapshots: - react-native-nitro-modules - react-native-quick-base64 - '@algorandfoundation/react-native-keystore@1.0.0-canary.13(@tanstack/store@0.9.3)(@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0))(before-after-hook@4.0.0)(expo-build-properties@55.0.14(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@algorandfoundation/react-native-keystore@1.0.0-canary.13(@tanstack/store@0.9.3)(@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0))(before-after-hook@4.0.0)(expo-build-properties@1.0.10(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: '@algorandfoundation/dp256': 1.1.0 '@algorandfoundation/keystore': 1.0.0-canary.17(@tanstack/store@0.9.3)(@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0))(before-after-hook@4.0.0) @@ -6563,7 +6557,7 @@ snapshots: before-after-hook: 4.0.0 react-native-keychain: 10.0.0 react-native-mmkv: 4.1.2(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - react-native-quick-crypto: 1.0.18(expo-build-properties@55.0.14(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-quick-crypto: 1.0.18(expo-build-properties@1.0.10(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) optionalDependencies: '@algorandfoundation/log-store': '@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0)' transitivePeerDependencies: @@ -6574,9 +6568,9 @@ snapshots: - react-native-nitro-modules - react-native-quick-base64 - '@algorandfoundation/react-native-passkey-autofill@1.0.0-canary.21(@tanstack/store@0.9.3)(@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0))(before-after-hook@4.0.0)(expo-build-properties@55.0.14(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@algorandfoundation/react-native-passkey-autofill@1.0.0-canary.21(@tanstack/store@0.9.3)(@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0))(before-after-hook@4.0.0)(expo-build-properties@1.0.10(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: - '@algorandfoundation/react-native-keystore': 1.0.0-canary.12(@tanstack/store@0.9.3)(@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0))(before-after-hook@4.0.0)(expo-build-properties@55.0.14(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@algorandfoundation/react-native-keystore': 1.0.0-canary.12(@tanstack/store@0.9.3)(@wjbeau/log-store@1.0.0-canary.3(@tanstack/store@0.9.3)(before-after-hook@4.0.0))(before-after-hook@4.0.0)(expo-build-properties@1.0.10(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo: 54.0.35(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(graphql@16.8.1)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.8.3) react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) @@ -7628,22 +7622,6 @@ snapshots: - supports-color - typescript - '@expo/config@55.0.17(typescript@5.8.3)': - dependencies: - '@expo/config-plugins': 55.0.10 - '@expo/config-types': 55.0.5 - '@expo/json-file': 10.2.0 - '@expo/require-utils': 55.0.5(typescript@5.8.3) - deepmerge: 4.3.1 - getenv: 2.0.0 - glob: 13.0.6 - resolve-workspace-root: 2.0.1 - semver: 7.8.1 - slugify: 1.6.9 - transitivePeerDependencies: - - supports-color - - typescript - '@expo/devcert@1.2.1': dependencies: '@expo/sudo-prompt': 9.3.2 @@ -7982,8 +7960,6 @@ snapshots: '@expo/schema-utils@0.1.8': {} - '@expo/schema-utils@55.0.4': {} - '@expo/sdk-runtime-versions@1.0.0': {} '@expo/spawn-async@1.7.2': @@ -10350,11 +10326,10 @@ snapshots: - supports-color - typescript - expo-build-properties@55.0.14(expo@54.0.35): + expo-build-properties@1.0.10(expo@54.0.35): dependencies: - '@expo/schema-utils': 55.0.4 + ajv: 8.20.0 expo: 54.0.35(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(graphql@16.8.1)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.8.3) - resolve-from: 5.0.0 semver: 7.8.1 expo-camera@17.0.10(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): @@ -11287,9 +11262,9 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 - jest-expo@55.0.18(@babel/core@7.29.7)(canvas@2.11.2)(expo@54.0.35)(jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3)))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.8.3): + jest-expo@54.0.17(@babel/core@7.29.7)(canvas@2.11.2)(expo@54.0.35)(jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3)))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: - '@expo/config': 55.0.17(typescript@5.8.3) + '@expo/config': 12.0.13 '@expo/json-file': 10.2.0 '@jest/create-cache-key-function': 29.7.0 '@jest/globals': 29.7.0 @@ -11312,7 +11287,6 @@ snapshots: - jest - react - supports-color - - typescript - utf-8-validate jest-get-type@29.6.3: {} @@ -12904,7 +12878,7 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) - react-native-quick-crypto@1.0.18(expo-build-properties@55.0.14(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + react-native-quick-crypto@1.0.18(expo-build-properties@1.0.10(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: '@craftzdog/react-native-buffer': 6.1.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) events: 3.3.0 @@ -12918,9 +12892,9 @@ snapshots: util: 0.12.5 optionalDependencies: expo: 54.0.35(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(graphql@16.8.1)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.8.3) - expo-build-properties: 55.0.14(expo@54.0.35) + expo-build-properties: 1.0.10(expo@54.0.35) - react-native-quick-crypto@1.1.5(expo-build-properties@55.0.14(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + react-native-quick-crypto@1.1.5(expo-build-properties@1.0.10(expo@54.0.35))(expo@54.0.35)(react-native-nitro-modules@0.35.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-quick-base64@3.0.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: '@craftzdog/react-native-buffer': 6.1.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) events: 3.3.0 @@ -12934,7 +12908,7 @@ snapshots: util: 0.12.5 optionalDependencies: expo: 54.0.35(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(graphql@16.8.1)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.8.3) - expo-build-properties: 55.0.14(expo@54.0.35) + expo-build-properties: 1.0.10(expo@54.0.35) react-native-reanimated@4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.7)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: From 05bc3c4a90f68f8a23e278cc276ddbaeefc2b1c5 Mon Sep 17 00:00:00 2001 From: Michael J Feher Date: Wed, 22 Jul 2026 14:25:57 -0400 Subject: [PATCH 2/9] wip: fixup transport, connection notice for invalid wallet, task cards for sub-agents --- .../components/chat/chat-reconnect.test.tsx | 35 ++- __tests__/lib/ac2/stream.test.ts | 125 ++++++++ __tests__/lib/ac2/transport.test.ts | 69 +++++ components/chat/ChatScreen.tsx | 67 ++++- components/chat/ChatTimeline.tsx | 4 + components/chat/ConnectionNoticeBanner.tsx | 80 +++++ components/chat/ReconnectBar.tsx | 2 +- components/chat/TaskCard.tsx | 109 +++++++ hooks/useConnection.ts | 177 ++++++++++- lib/ac2/README.md | 28 +- lib/ac2/index.ts | 17 +- lib/ac2/stream.ts | 98 +++++- lib/ac2/streamControlFrame.ts | 58 +++- lib/ac2/transport.ts | 283 ++++++++++++------ stores/messages.ts | 106 ++++++- 15 files changed, 1126 insertions(+), 132 deletions(-) create mode 100644 __tests__/lib/ac2/stream.test.ts create mode 100644 components/chat/ConnectionNoticeBanner.tsx create mode 100644 components/chat/TaskCard.tsx diff --git a/__tests__/components/chat/chat-reconnect.test.tsx b/__tests__/components/chat/chat-reconnect.test.tsx index 491bd58..c40decb 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: () => {} }), @@ -78,6 +78,9 @@ function baseConnection() { openConversation: jest.fn(), closeConversation: jest.fn(), remoteThreads: [], + connectionNotice: null, + dismissConnectionNotice: jest.fn(), + isRegistered: true, }; } @@ -147,4 +150,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/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/components/chat/ChatScreen.tsx b/components/chat/ChatScreen.tsx index 038032e..db815a9 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'; @@ -65,6 +66,9 @@ function ChatScreen({ origin, requestId, allowPasskeyCreation = false }: ChatScr openConversation, closeConversation, remoteThreads, + connectionNotice, + dismissConnectionNotice, + isRegistered, } = useConnection(origin, requestId, { allowPasskeyCreation }); const { approveSigning, rejectSigning, approveKey, rejectKey } = useAc2Responders({ @@ -234,27 +238,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 ? ( ; } + 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 dadd7a7..83bc3f3 100644 --- a/components/chat/ReconnectBar.tsx +++ b/components/chat/ReconnectBar.tsx @@ -31,7 +31,7 @@ function ReconnectBar({ onReconnect, isError, peerOffline, serviceUnavailable }: const { colorScheme } = useColorScheme(); const palette = colorScheme === 'dark' ? THEME.dark : THEME.light; const message = serviceUnavailable - ? 'Service unavailable. Not connected to the signaling service — retrying…' + ? '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 diff --git a/components/chat/TaskCard.tsx b/components/chat/TaskCard.tsx new file mode 100644 index 0000000..fe03ab9 --- /dev/null +++ b/components/chat/TaskCard.tsx @@ -0,0 +1,109 @@ +import { formatTime } from '@/components/chat/format'; +import { Text } from '@/components/ui/text'; +import { THEME } from '@/lib/theme'; +import type { Message } from '@/stores/messages'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useColorScheme } from 'nativewind'; +import * as React from 'react'; +import { ActivityIndicator, Pressable, View } from 'react-native'; + +interface TaskCardProps { + message: Message; +} + +function TaskCard({ message }: TaskCardProps) { + const { colorScheme } = useColorScheme(); + const palette = colorScheme === 'dark' ? THEME.dark : THEME.light; + const [expanded, setExpanded] = React.useState(false); + + const status = message.taskStatus || 'running'; + const title = message.taskTitle || 'Background task'; + const result = message.taskResult; + const prompt = message.taskPrompt; + const hasPrompt = !!prompt && prompt.trim().length > 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 2d815fe..efe28d7 100644 --- a/hooks/useConnection.ts +++ b/hooks/useConnection.ts @@ -1,5 +1,11 @@ import { useProvider } from '@/hooks/useProvider'; -import type { HeartbeatMonitor, MonitoredPeerConnection, PresenceResult } from '@/lib/ac2'; +import type { + ConnectionNotice, + HeartbeatMonitor, + MonitoredPeerConnection, + PresenceResult, + ScopedConnectionNotice, +} from '@/lib/ac2'; import { attachHeartbeatChannel, createAc2Client, @@ -10,8 +16,10 @@ import { generateThid, isPeerOffline, isPeerUnreachableError, + isRegistrationBlockingNotice, monitorPeerConnection, queryPresence, + selectConnectionNoticeForRequest, sendConversationClose, sendConversationOpen, subscribeToPresence, @@ -133,6 +141,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 { @@ -263,6 +286,36 @@ export function useConnection( 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); @@ -580,16 +633,46 @@ export function useConnection( // When the peer is absent it simply waits — the next `presence` broadcast (or // a manual Reconnect) re-invokes this once both parties are back in the room. const maybeNegotiate = useCallback(() => { - if (userStoppedRef.current) return; - if (!socketReadyRef.current) return; - if (isConnectedRef.current || transportInFlightRef.current) return; - if (autoReconnectTimerRef.current) return; + // Log why a negotiation attempt is (or isn't) started. When the app is + // stuck on "Connecting…" after the agent restarts, this pinpoints whether + // the wallet even TRIED to negotiate — and if not, exactly which gate held + // it back (socket not ready, already connecting, peer not present, etc.). + if (userStoppedRef.current) { + console.log('[ac2] maybeNegotiate: skipped — user stopped the session'); + return; + } + if (!socketReadyRef.current) { + console.log('[ac2] maybeNegotiate: skipped — signaling socket not ready'); + return; + } + if (isConnectedRef.current || transportInFlightRef.current) { + console.log( + `[ac2] maybeNegotiate: skipped — ${ + isConnectedRef.current ? 'already connected' : 'a negotiation is already in flight' + }`, + ); + return; + } + if (autoReconnectTimerRef.current) { + console.log('[ac2] maybeNegotiate: skipped — an auto-reconnect is already pending'); + return; + } // Both peers must be present (deviceCount >= 2) before we negotiate p2p. if (isPeerOffline(peerPresenceRef.current)) { + console.log( + `[ac2] maybeNegotiate: waiting — peer not present (deviceCount=${ + peerPresenceRef.current?.deviceCount ?? 'unknown' + }); will retry on the next presence broadcast`, + ); setIsLoading(false); setPeerOffline(true); return; } + console.log( + `[ac2] maybeNegotiate: peer present (deviceCount=${ + peerPresenceRef.current?.deviceCount ?? 'unknown' + }) — starting p2p (re)negotiation`, + ); performReconnectRef.current(); }, []); const maybeNegotiateRef = useRef(maybeNegotiate); @@ -606,6 +689,11 @@ export function useConnection( const handlePeerOffline = useCallback(() => { // Respect an explicit user disconnect — nothing to keep present for. if (userStoppedRef.current) return; + console.log( + `[ac2] handlePeerOffline: peer left the room (deviceCount=${ + peerPresenceRef.current?.deviceCount ?? 'unknown' + }) — tearing down p2p, keeping socket; awaiting peer to return`, + ); // Cancel any pending automatic reconnect: retrying is pointless while the // peer is absent and would otherwise flip the UI back into "Connecting…". if (autoReconnectTimerRef.current) { @@ -687,6 +775,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; @@ -722,6 +817,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 }, @@ -757,6 +858,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)'); @@ -1099,14 +1206,30 @@ export function useConnection( setPeerPresence(presence); peerPresenceRef.current = presence; if (isPeerOffline(presence)) { - // The peer isn't in the requestId room. Presence is authoritative - // and immediate, so react now whether or not a chat is live: if we - // were connected, the peer just left, so proactively tear the - // transport down and show "Peer offline" instead of waiting out the - // heartbeat/ICE watchdog; if we weren't, surface the same clean - // inline notice rather than an endless "Connecting…". Only progress + // The peer isn't in the requestId room — but the WebRTC data channel + // is the source of truth for an established p2p connection, and it + // survives signaling-server loss. If a chat is currently live + // (`isConnectedRef`), a presence drop is almost always a signaling + // artifact (most commonly the signaling server restarting and not + // yet re-counting the still-connected peer), NOT a real departure. + // Tearing down here would needlessly restart a healthy p2p + // connection on every signaling blip, so ignore it: a genuinely gone + // peer is caught by the data channel's own detectors (the heartbeat + // watchdog and ICE connectivity monitor), which is the standard + // "drop only when the data channel fails" behavior. + // + // When NOT connected there is no live p2p connection to trust, so a + // presence drop is authoritative and immediate: proactively tear + // down any half-open transport and surface a clean inline "Peer + // offline" notice instead of an endless "Connecting…". Only progress // to connecting again once the peer is back (the else branch). - handlePeerOfflineRef.current(); + if (isConnectedRef.current) { + console.log( + '[ac2] presence shows peer offline but the p2p data channel is live — ignoring (the data channel is authoritative; a real drop is handled by the heartbeat/ICE monitors)', + ); + } else { + handlePeerOfflineRef.current(); + } } else { // Both peers are present: (re)negotiate the p2p transport. setPeerOffline(false); @@ -1184,14 +1307,28 @@ export function useConnection( if (!client) return; // Peers must both be present (deviceCount >= 2) before negotiating p2p. if (isPeerOffline(peerPresenceRef.current)) { + console.log( + `[ac2] negotiateTransport: aborted — peer not present (deviceCount=${ + peerPresenceRef.current?.deviceCount ?? 'unknown' + })`, + ); setIsLoading(false); setPeerOffline(true); return; } + console.log( + `[ac2] negotiateTransport: opening p2p transport (nonce=${reconnectNonce}, deviceCount=${ + peerPresenceRef.current?.deviceCount ?? 'unknown' + })`, + ); transportInFlightRef.current = true; setIsLoading(true); 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); try { // Apply one STX-prefixed control frame from the agent's stream channel. @@ -1209,6 +1346,19 @@ 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 }); + } + }, }); // Presence is subscribed on the persistent socket (socket effect), so it @@ -1514,5 +1664,8 @@ export function useConnection( openConversation, closeConversation, remoteThreads, + connectionNotice, + dismissConnectionNotice, + isRegistered, }; } 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/index.ts b/lib/ac2/index.ts index 3beb205..6b35c8f 100644 --- a/lib/ac2/index.ts +++ b/lib/ac2/index.ts @@ -34,8 +34,21 @@ export { subscribeToPresence, } from './presence'; export type { PresenceResult, PresenceSocket } from './presence'; -export { parseStreamControlFrame, STX } from './stream'; -export type { AgentPresence, StreamControlFrame } from './stream'; +export { + isRegistrationBlockingNotice, + normalizeNoticeFrame, + parseStreamControlFrame, + REGISTRATION_BLOCKING_NOTICE_CODES, + selectConnectionNoticeForRequest, + STX, +} from './stream'; +export type { + AgentPresence, + ConnectionNotice, + NoticeLevel, + ScopedConnectionNotice, + StreamControlFrame, +} from './stream'; export { createAc2Transport, waitForSignalSocketConnected } from './transport'; export type { Ac2TransportSetup, CreateAc2TransportOptions } from './transport'; 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 470d0b6..17d2153 100644 --- a/lib/ac2/transport.ts +++ b/lib/ac2/transport.ts @@ -98,104 +98,215 @@ export async function createAc2Transport( await waitForSignalSocketConnected(signalClient); installSignalCandidateNormalizer(signalClient); - // Presence is handled outside the SignalClient, directly on the socket: keep - // the caller informed of how many devices are connected for this requestId. - if (onPresence) { - const socket = (signalClient as any).socket; - if (socket) subscribeToPresence(socket, onPresence); - } + // Make the signaling handshake observable for the duration of this one + // negotiation. When a (re)connect stalls at "Connecting…", the logs show + // exactly which step never arrived — most tellingly an `offer-description` + // we sent with NO following `answer-description` (see the helper's docs). + const disposeSignalingDiagnostics = attachSignalingDiagnostics(signalClient, requestId); - const peerPromise = signalClient.peer( - requestId, - 'answer', - { - iceServers: DEFAULT_ICE_SERVERS, - }, - { - dataChannels: DEFAULT_DATA_CHANNELS, - }, - ); + try { + // Presence is handled outside the SignalClient, directly on the socket: keep + // the caller informed of how many devices are connected for this requestId. + if (onPresence) { + const socket = (signalClient as any).socket; + if (socket) subscribeToPresence(socket, onPresence); + } - 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 peerPromise = signalClient.peer( + requestId, + 'answer', + { + iceServers: DEFAULT_ICE_SERVERS, + }, + { + dataChannels: DEFAULT_DATA_CHANNELS, + }, + ); - 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; - } - onAbort = abort; - signal.addEventListener('abort', onAbort); + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + console.warn( + `[ac2][signal] negotiate: TIMEOUT after ${SIGNALING_TIMEOUT_MS}ms — no answer-description arrived; the peer never answered our offer (it likely wasn't linked/armed when we sent it).`, + ); + 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); }); - racers.push(abortPromise); - } - const datachannel = await Promise.race(racers).finally(() => { - if (timeoutId !== undefined) clearTimeout(timeoutId); - if (signal && onAbort) signal.removeEventListener('abort', onAbort); - }); + 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; + } + onAbort = abort; + signal.addEventListener('abort', onAbort); + }); + racers.push(abortPromise); + } - // 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); + const datachannel = await Promise.race(racers).finally(() => { + if (timeoutId !== undefined) clearTimeout(timeoutId); + if (signal && onAbort) signal.removeEventListener('abort', onAbort); + }); - // 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') { + // 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); + + // 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') { + try { + signalClient.peerClient?.close(); + } catch { + /* noop */ + } + } + throw err; + } + + return { client: signalClient, datachannel }; + } finally { + disposeSignalingDiagnostics(); + } +} + +/** + * 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 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 { - signalClient.peerClient?.close(); + const detail = describe ? describe(...args) : ''; + log(`[ac2][signal] ${shortId} ${event} ${since()}${detail ? ` — ${detail}` : ''}`); } catch { - /* noop */ + /* diagnostics only */ } + }; + try { + emitter.on(event, listener); + handlers.push([event, listener]); + } catch { + /* diagnostics only */ } - throw err; - } + }; - return { client: signalClient, datachannel }; + // 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 */ + } + }; + + 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 { + emitter.off(event, listener); + } catch { + /* diagnostics only */ + } + } + }; } /** diff --git a/stores/messages.ts b/stores/messages.ts index 62fa8d1..a08aa07 100644 --- a/stores/messages.ts +++ b/stores/messages.ts @@ -13,16 +13,24 @@ export interface Message { * Message kind. `'text'` (default) is a normal chat bubble. `'tool'` is a * durable record of one tool/exec step the agent ran during a turn, rendered * as a distinct "tool card" (with the command + output) rather than a chat - * bubble. Legacy messages persisted before tool cards carry no `kind` and are - * treated as `'text'`. + * bubble. `'task'` is a self-contained background-task card. Legacy messages + * persisted before tool cards carry no `kind` and are treated as `'text'`. */ - kind?: 'text' | 'tool'; + kind?: 'text' | 'tool' | 'task'; /** Tool name for a `kind: 'tool'` card (e.g. `exec`, `write`). */ tool?: string; /** The command/invocation the agent ran, when the runtime surfaces it. */ command?: string; /** The (possibly truncated) tool output/result text. */ output?: string; + /** Title for a `kind: 'task'` card. */ + taskTitle?: string; + /** The prompt/input that started the background task. */ + taskPrompt?: string; + /** Current lifecycle status of the task. */ + taskStatus?: 'running' | 'completed' | 'failed' | 'stopped'; + /** The final result or error message from the task. */ + taskResult?: string; /** * Conversation/thread id this message belongs to. A single connection can * multiplex several conversations (see `ac2/ConversationOpen`); messages are @@ -135,6 +143,60 @@ export function addToolActivity(activity: { }); } +/** + * Upsert a durable background-task card on a conversation thread, keyed by the + * agent-supplied `taskId`. Background tasks emit status changes and results + * incrementally: the agent re-emits the same `taskId` to flip a card from + * `running` to a terminal status and attach the result. + */ +export function addTaskActivity(activity: { + taskId: string; + address: string; + origin: string; + requestId: string; + thid: string; + title?: string; + prompt?: string; + status?: Message['taskStatus']; + result?: string; +}) { + messagesStore.setState((state) => { + const id = `task-${activity.requestId}-${activity.taskId}`; + const existingIndex = state.messages.findIndex((m) => m.id === id); + if (existingIndex !== -1) { + // Refresh the existing card in place: only overwrite fields the new + // frame actually carries, so a status-only refresh doesn't wipe an + // earlier title/prompt. + const messages = [...state.messages]; + const prev = messages[existingIndex]; + messages[existingIndex] = { + ...prev, + ...(activity.title ? { taskTitle: activity.title } : {}), + ...(activity.prompt ? { taskPrompt: activity.prompt } : {}), + ...(activity.status ? { taskStatus: activity.status } : {}), + ...(activity.result !== undefined ? { taskResult: activity.result } : {}), + }; + return { ...state, messages }; + } + const card: Message = { + id, + text: '', + sender: 'peer', + timestamp: Date.now(), + address: activity.address, + origin: activity.origin, + requestId: activity.requestId, + thid: activity.thid, + kind: 'task', + ...(activity.title ? { taskTitle: activity.title } : {}), + ...(activity.prompt ? { taskPrompt: activity.prompt } : {}), + ...(activity.status ? { taskStatus: activity.status } : {}), + ...(activity.result !== undefined ? { taskResult: activity.result } : {}), + }; + return { ...state, messages: [...state.messages, card] }; + }); +} + /** * Replace the locally-stored history for a single conversation thread with the * agent-supplied history, restoring a conversation the wallet may not have @@ -148,7 +210,7 @@ export function setThreadHistory( requestId: string, thid: string, history: { - role: 'user' | 'assistant' | 'tool'; + role: 'user' | 'assistant' | 'tool' | 'task'; text: string; at?: number; // Tool-card fields, present only for `role: 'tool'` entries. @@ -156,18 +218,24 @@ export function setThreadHistory( tool?: string; command?: string; output?: string; + // Task-card fields, present only for `role: 'task'` entries. + id?: string; + title?: string; + prompt?: string; + status?: string; + result?: string; }[], ) { messagesStore.setState((state) => { - // When the replayed history itself carries tool cards (a fresh device / + // When the replayed history itself carries tool/task cards (a fresh device / // cleared store recovering everything from the agent), we replace the whole - // thread — chat *and* tool cards. Otherwise the agent only sent chat text, - // so we must keep any tool cards already collected live on this device, - // since wiping them would drop exec activity the replay can't restore. - const historyHasTools = history.some((h) => h.role === 'tool'); + // thread — chat, tool and task cards. Otherwise the agent only sent chat text, + // so we must keep any durable cards already collected live on this device, + // since wiping them would drop activity the replay can't restore. + const historyHasTools = history.some((h) => h.role === 'tool' || h.role === 'task'); const retained = state.messages.filter( (m) => - (!historyHasTools && m.kind === 'tool') || + (!historyHasTools && (m.kind === 'tool' || m.kind === 'task')) || m.address !== address || m.origin !== origin || m.requestId !== requestId || @@ -194,6 +262,24 @@ export function setThreadHistory( ...(h.output !== undefined ? { output: h.output } : {}), }; } + if (h.role === 'task') { + const taskId = h.id ?? `restored-${index}`; + return { + id: `task-${requestId}-${taskId}`, + text: '', + sender: 'peer', + timestamp: typeof h.at === 'number' ? h.at : Date.now() + index, + address, + origin, + requestId, + thid, + kind: 'task', + taskTitle: h.title, + taskPrompt: h.prompt, + taskStatus: h.status as Message['taskStatus'], + taskResult: h.result, + }; + } return { id: `restored-${requestId}-${thid}-${index}`, text: h.text, From b33571bfe49d1b8dfca5b2132665b44138082919 Mon Sep 17 00:00:00 2001 From: Michael J Feher Date: Wed, 22 Jul 2026 16:34:53 -0400 Subject: [PATCH 3/9] fix: leaking transport presence listener --- hooks/useConnection.ts | 26 +++++++++++++++++++++++++- lib/ac2/transport.ts | 22 ++++++++++++++++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/hooks/useConnection.ts b/hooks/useConnection.ts index efe28d7..d1f8440 100644 --- a/hooks/useConnection.ts +++ b/hooks/useConnection.ts @@ -276,6 +276,10 @@ export function useConnection( // Disposer for the socket-level `presence` subscription (lives with the // socket, not the transport). const presenceUnsubRef = useRef<(() => void) | null>(null); + // Disposer for the transport-level `onPresence` listener (`createAc2Transport`). + // The listener sits on the long-lived signaling socket, so it is torn down + // with the socket rather than per negotiation. + const transportPresenceUnsubRef = useRef<(() => void) | null>(null); // True once the persistent socket is established AND connected, so p2p // negotiation may be attempted (subject to the both-peers-present gate). const socketReadyRef = useRef(false); @@ -430,6 +434,14 @@ export function useConnection( } presenceUnsubRef.current = null; } + if (transportPresenceUnsubRef.current) { + try { + transportPresenceUnsubRef.current(); + } catch { + /* noop */ + } + transportPresenceUnsubRef.current = null; + } socketReadyRef.current = false; setIsSocketConnected(false); if (clientRef.current) { @@ -1363,7 +1375,7 @@ export function useConnection( // Presence is subscribed on the persistent socket (socket effect), so it // is intentionally NOT re-subscribed here. - const { datachannel } = await createAc2Transport({ + const { datachannel, disposePresence } = await createAc2Transport({ requestId, signalClient: client, signal: runAbort.signal, @@ -1401,6 +1413,18 @@ export function useConnection( }, }); + // The transport's `onPresence` listener lives on the persistent socket, + // so track its disposer for the socket teardown path (`closeSocket`). + // Replace any prior disposer first so a superseded run cannot leak one. + if (transportPresenceUnsubRef.current) { + try { + transportPresenceUnsubRef.current(); + } catch { + /* noop */ + } + } + transportPresenceUnsubRef.current = disposePresence; + if (!active) { // This run was superseded while negotiation was still winding down. // Avoid hard-closing the native peer here: Android's WebRTC bridge may diff --git a/lib/ac2/transport.ts b/lib/ac2/transport.ts index 17d2153..92ea65d 100644 --- a/lib/ac2/transport.ts +++ b/lib/ac2/transport.ts @@ -46,6 +46,13 @@ export interface Ac2TransportSetup { client: SignalClient; /** The control plane DataChannel (`ac2-v1`). */ datachannel: RTCDataChannel; + /** + * Removes the `onPresence` listener from the (long-lived) signaling socket. + * Call it when tearing down this transport so successive negotiations for the + * same `requestId` do not accumulate presence listeners. No-op when + * `onPresence` was not supplied. + */ + disposePresence: () => void; } export interface CreateAc2TransportOptions { @@ -104,12 +111,18 @@ export async function createAc2Transport( // we sent with NO following `answer-description` (see the helper's docs). const disposeSignalingDiagnostics = attachSignalingDiagnostics(signalClient, requestId); + // The `onPresence` listener lives on the long-lived signaling socket, which is + // reused across socket.io reconnects and across successive negotiations for + // the same `requestId`. Own its unsubscribe so it can be released with the + // connection (returned below) instead of leaking a listener per negotiation. + let disposePresence: () => void = () => {}; + try { // Presence is handled outside the SignalClient, directly on the socket: keep // the caller informed of how many devices are connected for this requestId. if (onPresence) { const socket = (signalClient as any).socket; - if (socket) subscribeToPresence(socket, onPresence); + if (socket) disposePresence = subscribeToPresence(socket, onPresence); } const peerPromise = signalClient.peer( @@ -205,7 +218,12 @@ export async function createAc2Transport( throw err; } - return { client: signalClient, datachannel }; + return { client: signalClient, datachannel, disposePresence }; + } catch (err) { + // On a failed negotiation nothing downstream owns the disposer (the setup is + // never returned), so release the presence listener here to avoid a leak. + disposePresence(); + throw err; } finally { disposeSignalingDiagnostics(); } From 33c23cc8b6a5bb08886432e857af21b507ad1811 Mon Sep 17 00:00:00 2001 From: Michael J Feher Date: Wed, 22 Jul 2026 17:22:33 -0400 Subject: [PATCH 4/9] wip: link rejects invalid peers --- hooks/useConnection.ts | 18 ++++++++++++++++ lib/ac2/index.ts | 1 + lib/ac2/presence.ts | 17 +++++++++++++++ lib/ac2/transport.ts | 48 +++++++++++++++++++++++++++++++++++++++--- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/hooks/useConnection.ts b/hooks/useConnection.ts index d1f8440..c68b372 100644 --- a/hooks/useConnection.ts +++ b/hooks/useConnection.ts @@ -15,6 +15,7 @@ import { describeSelectedCandidatePair, generateThid, isPeerOffline, + isPeerRejectedError, isPeerUnreachableError, isRegistrationBlockingNotice, monitorPeerConnection, @@ -594,6 +595,23 @@ export function useConnection( // Drop every stale transport ref so the next reconnect isn't blocked by // the connection effect's guard. clearTransport(); + // A room refusal (two-peer lockdown: the session already has its two + // devices) is TERMINAL — retrying can't free a slot, so short-circuit the + // auto-reconnect budget and surface an actionable "session full" message + // at once instead of hammering the server with rejected attempts. + if (isPeerRejectedError(error)) { + setIsReconnecting(false); + setIsLoading(false); + setPeerOffline(false); + if (error) setError(error); + Alert.alert( + 'Session Full', + error?.message || + 'This session already has the maximum number of devices connected. Ask the other device to disconnect and try again.', + [{ text: 'OK' }], + ); + return; + } // 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). diff --git a/lib/ac2/index.ts b/lib/ac2/index.ts index 6b35c8f..314d6e3 100644 --- a/lib/ac2/index.ts +++ b/lib/ac2/index.ts @@ -27,6 +27,7 @@ export type { export { hasPeerPresence, isPeerOffline, + isPeerRejectedError, isPeerUnreachableError, normalizePresence, PRESENCE_EVENT, diff --git a/lib/ac2/presence.ts b/lib/ac2/presence.ts index e79a32a..90e63a5 100644 --- a/lib/ac2/presence.ts +++ b/lib/ac2/presence.ts @@ -153,6 +153,23 @@ export function isPeerUnreachableError(error?: { message?: unknown } | null): bo 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 diff --git a/lib/ac2/transport.ts b/lib/ac2/transport.ts index 92ea65d..e061054 100644 --- a/lib/ac2/transport.ts +++ b/lib/ac2/transport.ts @@ -4,7 +4,7 @@ * SDK and the controller UI consume. */ -import { SignalClient } from '@algorandfoundation/liquid-client'; +import { LinkError, SignalClient } from '@algorandfoundation/liquid-client'; import { subscribeToPresence, type PresenceResult } from './presence'; @@ -116,6 +116,9 @@ export async function createAc2Transport( // the same `requestId`. Own its unsubscribe so it can be released with the // connection (returned below) instead of leaking a listener per negotiation. let disposePresence: () => void = () => {}; + // Same for the one-shot `link-error` listener installed below: own its + // detacher so it never outlives this negotiation. + let disposeLinkError: () => void = () => {}; try { // Presence is handled outside the SignalClient, directly on the socket: keep @@ -153,6 +156,40 @@ export async function createAc2Transport( const racers: Promise[] = [peerPromise, timeoutPromise]; + // A room refusal under the two-peer lockdown (the session already holds its + // one wallet + one peer, or the same role twice) is delivered by the + // gateway as a generic `exception` event on the signaling socket, NOT as an + // `answer-description`. Watch for it (scoped to THIS requestId) and reject + // immediately with the client's typed `LinkError`, so a full session fails + // in <1s with an actionable "session full" message instead of hanging out + // the 30s answer-description timeout and being misread as "peer offline". + const socket = (signalClient as any).socket; + if (socket && typeof socket.on === 'function') { + let linkErrorReject: (err: Error) => void = () => {}; + const linkErrorPromise = new Promise((_, reject) => { + linkErrorReject = reject; + }); + const onException = (payload: any) => { + if (payload?.event !== 'link-error' || payload?.requestId !== requestId) return; + signalClient.peerClient?.close(); + linkErrorReject( + new LinkError(payload?.message ?? 'Liquid Auth refused the link for this requestId', { + reason: payload?.reason, + requestId, + }), + ); + }; + socket.on('exception', onException); + disposeLinkError = () => { + try { + socket.off?.('exception', onException); + } catch { + /* noop */ + } + }; + racers.push(linkErrorPromise); + } + // 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. @@ -188,6 +225,9 @@ export async function createAc2Transport( const datachannel = await Promise.race(racers).finally(() => { if (timeoutId !== undefined) clearTimeout(timeoutId); if (signal && onAbort) signal.removeEventListener('abort', onAbort); + // The link-refusal watcher is only relevant while negotiating; detach it + // regardless of which racer won so it can't fire against a later run. + disposeLinkError(); }); // Surface the negotiated peer connection so the caller can watch it for @@ -220,9 +260,11 @@ export async function createAc2Transport( return { client: signalClient, datachannel, disposePresence }; } catch (err) { - // On a failed negotiation nothing downstream owns the disposer (the setup is - // never returned), so release the presence listener here to avoid a leak. + // On a failed negotiation nothing downstream owns the disposers (the setup + // is never returned), so release both the presence and link-refusal + // listeners here to avoid a leak. disposePresence(); + disposeLinkError(); throw err; } finally { disposeSignalingDiagnostics(); From 87ff152abf4b0a2071c94421e7cb9e8733abb25f Mon Sep 17 00:00:00 2001 From: Michael J Feher Date: Thu, 23 Jul 2026 12:03:37 -0400 Subject: [PATCH 5/9] chore: bind connections to service --- __tests__/lib/ac2/nativeChannel.test.ts | 141 +++++++++++ __tests__/lib/ac2/nativeTransport.test.ts | 240 ++++++++++++++++++ jest.config.js | 2 +- lib/ac2/VENDORED.md | 47 ++++ lib/ac2/nativeChannel.ts | 239 ++++++++++++++++++ lib/ac2/nativeTransport.ts | 283 ++++++++++++++++++++++ lib/ac2/transport.ts | 6 +- 7 files changed, 954 insertions(+), 4 deletions(-) create mode 100644 __tests__/lib/ac2/nativeChannel.test.ts create mode 100644 __tests__/lib/ac2/nativeTransport.test.ts create mode 100644 lib/ac2/VENDORED.md create mode 100644 lib/ac2/nativeChannel.ts create mode 100644 lib/ac2/nativeTransport.ts diff --git a/__tests__/lib/ac2/nativeChannel.test.ts b/__tests__/lib/ac2/nativeChannel.test.ts new file mode 100644 index 0000000..fe52878 --- /dev/null +++ b/__tests__/lib/ac2/nativeChannel.test.ts @@ -0,0 +1,141 @@ +import { NativeDataChannel, NativePeerConnection } from '@/lib/ac2/nativeChannel'; +import { monitorPeerConnection } from '@/lib/ac2/peerConnectionMonitor'; + +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('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..b143fcf --- /dev/null +++ b/__tests__/lib/ac2/nativeTransport.test.ts @@ -0,0 +1,240 @@ +import type { NativeDataChannel } from '@/lib/ac2/nativeChannel'; +import { + AC2_CONTROL_CHANNEL, + createNativeAc2Transport, + type LiquidAuthNativeApi, +} 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; + 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 = () => {}; + + 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 () => {}), + 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), + }; + + 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), + 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); + + // 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('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); + }); + +}); diff --git a/jest.config.js b/jest.config.js index cb365bd..bbc847c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,7 +3,7 @@ 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', 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/nativeChannel.ts b/lib/ac2/nativeChannel.ts new file mode 100644 index 0000000..4e6dda0 --- /dev/null +++ b/lib/ac2/nativeChannel.ts @@ -0,0 +1,239 @@ +/** + * 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; + onmessage: ((ev: DataChannelMessageEvent) => void) | null = null; + + private _readyState: DataChannelReadyState = 'connecting'; + private readonly _send: (label: string, message: string) => void; + private readonly _listeners = new Map void>>(); + + constructor(label: string, send: (label: string, message: string) => void) { + this.label = label; + this._send = send; + } + + get readyState(): DataChannelReadyState { + return this._readyState; + } + + /** 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); + } + + 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 { + const event: DataChannelMessageEvent = { data: message }; + try { + this.onmessage?.(event); + } catch { + /* consumer handler threw; do not break dispatch */ + } + this._emit('message', event); + } + + /** + * 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..6515ac9 --- /dev/null +++ b/lib/ac2/nativeTransport.ts @@ -0,0 +1,283 @@ +/** + * 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; +} + +/** Native-broadcast presence payload (mirrors {@link PresenceResult}). */ +export interface NativePresenceEvent { + requestId: string; + deviceCount: number; + online: boolean; +} + +/** 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; +} + +/** + * 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 }, + ): Promise; + cancel(): Promise; + 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; +} + +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; + /** 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, + sendToChannel: mod.sendToChannel, + disconnect: mod.disconnect, + addMessageListener: mod.addMessageListener, + addStateChangeListener: mod.addStateChangeListener, + addConnectionStateListener: mod.addConnectionStateListener, + addPresenceListener: mod.addPresenceListener, + addLinkErrorListener: mod.addLinkErrorListener, + }; +} + +/** + * 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, + 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) => channels.get(e.channel)?.dispatchMessage(e.message)), + native.addStateChangeListener((e) => 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(); + + // 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 }); + + 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/transport.ts b/lib/ac2/transport.ts index e061054..4ac2b23 100644 --- a/lib/ac2/transport.ts +++ b/lib/ac2/transport.ts @@ -9,7 +9,7 @@ import { LinkError, SignalClient } from '@algorandfoundation/liquid-client'; import { subscribeToPresence, type PresenceResult } from './presence'; /** 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'], }, @@ -24,7 +24,7 @@ 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 }, @@ -37,7 +37,7 @@ const SOCKET_CONNECT_TIMEOUT_MS = 10000; // 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']); From 6f0ddc7f2fc69f192ad582eb6d8a37dd6486bb8f Mon Sep 17 00:00:00 2001 From: Michael J Feher Date: Thu, 23 Jul 2026 14:03:01 -0400 Subject: [PATCH 6/9] chore: vendor react-native-liquid-auth --- .oxfmtignore | 1 + .oxlintrc.json | 2 +- README.md | 43 ++ hooks/useConnection.ts | 353 +++++------ jest.config.js | 6 + lib/ac2/index.ts | 22 +- lib/ac2/nativeTransport.ts | 47 ++ lib/ac2/transport.ts | 250 +------- metro.config.js | 12 + modules/react-native-liquid-auth/LICENSE | 21 + modules/react-native-liquid-auth/VENDORED.md | 64 ++ .../android/build.gradle | 35 ++ .../android/src/main/AndroidManifest.xml | 15 + .../algorand/liquid/LiquidAuthNativeModule.kt | 321 ++++++++++ .../algorand/auth/connect/AuthMessage.kt | 58 ++ .../algorand/auth/connect/IceCandidateExt.kt | 12 + .../algorand/auth/connect/JSONObjectExt.kt | 8 + .../algorand/auth/connect/PeerApi.kt | 363 +++++++++++ .../algorand/auth/connect/SignalClient.kt | 387 ++++++++++++ .../algorand/auth/connect/SignalInterface.kt | 82 +++ .../algorand/auth/connect/SignalService.kt | 251 ++++++++ .../algorand/auth/connect/VENDORED.md | 56 ++ .../expo-module.config.json | 9 + .../ios/LiquidAuthNative.podspec | 31 + .../ios/LiquidAuthNativeModule.swift | 235 +++++++ .../ios/LiquidAuthSDK/DataChannelConfig.swift | 69 +++ .../LiquidAuthSDK/DataChannelDelegate.swift | 82 +++ .../ios/LiquidAuthSDK/LinkError.swift | 81 +++ .../ios/LiquidAuthSDK/LiquidAuthError.swift | 52 ++ .../LiquidAuthSDK/LiquidAuthPeerType.swift | 41 ++ .../ios/LiquidAuthSDK/Logger.swift | 58 ++ .../ios/LiquidAuthSDK/PeerApi.swift | 347 +++++++++++ .../ios/LiquidAuthSDK/SignalClient.swift | 581 ++++++++++++++++++ .../ios/LiquidAuthSDK/SignalService.swift | 264 ++++++++ .../react-native-liquid-auth/ios/VENDORED.md | 106 ++++ modules/react-native-liquid-auth/package.json | 21 + .../src/LiquidAuthNative.types.ts | 126 ++++ .../src/LiquidAuthNativeModule.ts | 66 ++ .../src/LiquidAuthNativeModule.web.ts | 52 ++ modules/react-native-liquid-auth/src/index.ts | 150 +++++ .../src/nativeChannel.ts | 239 +++++++ tsconfig.json | 3 +- 42 files changed, 4575 insertions(+), 447 deletions(-) create mode 100644 modules/react-native-liquid-auth/LICENSE create mode 100644 modules/react-native-liquid-auth/VENDORED.md create mode 100644 modules/react-native-liquid-auth/android/build.gradle create mode 100644 modules/react-native-liquid-auth/android/src/main/AndroidManifest.xml create mode 100644 modules/react-native-liquid-auth/android/src/main/java/co/algorand/liquid/LiquidAuthNativeModule.kt create mode 100644 modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/AuthMessage.kt create mode 100644 modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/IceCandidateExt.kt create mode 100644 modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/JSONObjectExt.kt create mode 100644 modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/PeerApi.kt create mode 100644 modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalClient.kt create mode 100644 modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalInterface.kt create mode 100644 modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalService.kt create mode 100644 modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/VENDORED.md create mode 100644 modules/react-native-liquid-auth/expo-module.config.json create mode 100644 modules/react-native-liquid-auth/ios/LiquidAuthNative.podspec create mode 100644 modules/react-native-liquid-auth/ios/LiquidAuthNativeModule.swift create mode 100644 modules/react-native-liquid-auth/ios/LiquidAuthSDK/DataChannelConfig.swift create mode 100644 modules/react-native-liquid-auth/ios/LiquidAuthSDK/DataChannelDelegate.swift create mode 100644 modules/react-native-liquid-auth/ios/LiquidAuthSDK/LinkError.swift create mode 100644 modules/react-native-liquid-auth/ios/LiquidAuthSDK/LiquidAuthError.swift create mode 100644 modules/react-native-liquid-auth/ios/LiquidAuthSDK/LiquidAuthPeerType.swift create mode 100644 modules/react-native-liquid-auth/ios/LiquidAuthSDK/Logger.swift create mode 100644 modules/react-native-liquid-auth/ios/LiquidAuthSDK/PeerApi.swift create mode 100644 modules/react-native-liquid-auth/ios/LiquidAuthSDK/SignalClient.swift create mode 100644 modules/react-native-liquid-auth/ios/LiquidAuthSDK/SignalService.swift create mode 100644 modules/react-native-liquid-auth/ios/VENDORED.md create mode 100644 modules/react-native-liquid-auth/package.json create mode 100644 modules/react-native-liquid-auth/src/LiquidAuthNative.types.ts create mode 100644 modules/react-native-liquid-auth/src/LiquidAuthNativeModule.ts create mode 100644 modules/react-native-liquid-auth/src/LiquidAuthNativeModule.web.ts create mode 100644 modules/react-native-liquid-auth/src/index.ts create mode 100644 modules/react-native-liquid-auth/src/nativeChannel.ts 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..dcdd92d 100644 --- a/README.md +++ b/README.md @@ -131,3 +131,46 @@ 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. + +### 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/hooks/useConnection.ts b/hooks/useConnection.ts index c68b372..706dfaa 100644 --- a/hooks/useConnection.ts +++ b/hooks/useConnection.ts @@ -7,25 +7,24 @@ import type { ScopedConnectionNotice, } from '@/lib/ac2'; import { + addNativePresenceListener, attachHeartbeatChannel, + cancelNativeNegotiation, createAc2Client, - createAc2Transport, createHeartbeatMonitor, + createNativeAc2Transport, DEFAULT_THID, - describeSelectedCandidatePair, generateThid, isPeerOffline, isPeerRejectedError, isPeerUnreachableError, isRegistrationBlockingNotice, monitorPeerConnection, - queryPresence, selectConnectionNoticeForRequest, sendConversationClose, sendConversationOpen, - subscribeToPresence, - summarizeSelectedCandidatePair, - waitForSignalSocketConnected, + startNativeService, + stopNativeService, } from '@/lib/ac2'; import { createControlFrameHandler } from '@/lib/ac2/streamControlFrame'; import { findWalletAccount } from '@/lib/keystore/wallet-account'; @@ -49,11 +48,9 @@ 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 @@ -217,7 +214,13 @@ export function useConnection( // Heartbeat liveness watchdog (ping/pong over `ac2-heartbeat`). Started in // `onOpen`, 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. @@ -277,9 +280,10 @@ export function useConnection( // Disposer for the socket-level `presence` subscription (lives with the // socket, not the transport). const presenceUnsubRef = useRef<(() => void) | null>(null); - // Disposer for the transport-level `onPresence` listener (`createAc2Transport`). - // The listener sits on the long-lived signaling socket, so it is torn down - // with the socket rather than per negotiation. + // Disposer for the transport-level presence listener. With the native path + // presence is subscribed on the persistent service (socket effect), so the + // per-negotiation transport's presence disposer is a no-op; this ref is kept + // only for symmetry with the socket teardown path. const transportPresenceUnsubRef = useRef<(() => void) | null>(null); // True once the persistent socket is established AND connected, so p2p // negotiation may be attempted (subject to the both-peers-present gate). @@ -381,44 +385,31 @@ export function useConnection( } heartbeatChannelRef.current = null; } - // Tear down ONLY the p2p peer, keeping the persistent signaling socket - // (and its presence subscription) alive so the app stays connected to the - // service after a chat drop — enabling presence checks and renegotiation - // over the same socket without a fresh auth/passkey. The socket itself is - // owned by the socket effect (see `closeSocket`). - const client = clientRef.current; - if (client) { + // 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 the socket effect (see `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`. - client.peerClient?.close(); - } catch { - /* noop */ - } - // Allow a fresh `peer()` on the reused SignalClient (it refuses to run - // while a peer/requestId is still in progress). - client.peerClient = undefined; - // Detach the per-negotiation listeners the SDK (`peer()`/`signal()`) and - // `createAc2Transport` add on each negotiation, so reusing this socket - // for the next attempt doesn't accumulate duplicate `data-channel` / - // candidate / description handlers that would double-apply signaling. - try { - client.off('data-channel'); - } catch { - /* noop */ - } - const socket = client.socket as any; - try { - socket?.off?.('offer-candidate'); - socket?.off?.('answer-candidate'); - socket?.off?.('offer-description'); - socket?.off?.('answer-description'); + 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. + if (nativeStartedRef.current) { + cancelNativeNegotiation().catch(() => { + /* best-effort */ + }); } }, []); @@ -445,34 +436,24 @@ export function useConnection( } socketReadyRef.current = false; setIsSocketConnected(false); - if (clientRef.current) { - try { - // `close(true)` detaches listeners AND disconnects the underlying - // socket.io connection. - clientRef.current.close(true); - } catch { - /* noop */ - } - clientRef.current = null; + 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 (connect / failure) stay in place, + // ready to re-enable if the native module later surfaces peer stats. }, []); const logCandidatePairRef = useRef(logCandidatePair); logCandidatePairRef.current = logCandidatePair; @@ -534,10 +515,11 @@ export function useConnection( reconnectAttemptRef.current = 0; setReconnectAttempt(0); setIsReconnecting(false); - if (!clientRef.current) { - // The persistent socket was fully torn down (an explicit disconnect): - // rebuild it. The socket effect re-authenticates, reconnects, and drives - // presence-gated p2p negotiation once both peers are present again. + if (!nativeStartedRef.current) { + // The persistent native service was fully torn down (an explicit + // disconnect): rebuild it. The socket effect re-authenticates, restarts + // the service, and drives presence-gated p2p negotiation once both peers + // are present again. setError(null); setPeerOffline(false); setIsConnected(false); @@ -1021,9 +1003,9 @@ export function useConnection( return; } - // The persistent socket is already established for this session — the - // socket effect only builds it once (it survives p2p chat drops). - if (clientRef.current) { + // The persistent native service is already started for this session — the + // socket effect only starts it once (it survives p2p chat drops). + if (nativeStartedRef.current) { return; } // Never resurrect a session the user explicitly disconnected. @@ -1166,70 +1148,32 @@ export function useConnection( 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); - - if (!active) return; - - if (cookie) { - options.extraHeaders = { Cookie: cookie }; - options.transports = ['polling']; - options.transportOptions = { - polling: { extraHeaders: { Cookie: cookie } }, - }; - } - } - - const client = new SignalClient(origin, options); - - if (!active) return; - - clientRef.current = client; - //@ts-ignore - client.authenticated = true; - - // Wait for the socket to actually connect before wiring any listeners. - // `SignalClient` initializes its socket asynchronously (it dynamically - // imports socket.io-client), so `client.socket` is `undefined` right - // after construction — subscribing to presence or connect/disconnect - // events before this point throws "Cannot read property 'on' of - // undefined". Awaiting here guarantees `client.socket` exists. - await waitForSignalSocketConnected(client); + // 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 old + // in-process socket.io client did). 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; - - // Track socket connectivity so the chat surface can show "Service - // unavailable" while the signaling service is unreachable. The socket is - // kept alive across p2p chat drops; socket.io auto-reconnects transient - // drops without rebuilding the client, and on each (re)connect the - // server rejoins us to the requestId room and rebroadcasts presence. - const socket = client.socket as any; - const onSocketConnect = () => { - if (!active) return; - setIsSocketConnected(true); - socketReadyRef.current = true; - maybeNegotiateRef.current(); - }; - const onSocketDisconnect = () => { - if (!active) return; - setIsSocketConnected(false); - socketReadyRef.current = false; - }; - socket?.on?.('connect', onSocketConnect); - socket?.on?.('disconnect', onSocketDisconnect); - - // Presence lives with the socket (outside the p2p transport) so it keeps - // working across chat drops and drives presence-gated renegotiation: - // peers must both be present in the requestId room before negotiating. - presenceUnsubRef.current = subscribeToPresence(socket, (presence) => { + nativeStartedRef.current = true; + + // Presence lives with the persistent service (outside a single p2p + // negotiation) so it keeps working across chat drops and drives + // presence-gated renegotiation: 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: unlike the old socket path there is no active + // presence *query*, so the first negotiation decision is made once the + // first broadcast arrives rather than being seeded up-front. + const presenceSub = addNativePresenceListener((e) => { if (!active) return; + 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}`, ); @@ -1266,23 +1210,19 @@ export function useConnection( maybeNegotiateRef.current(); } }); + presenceUnsubRef.current = () => presenceSub.remove(); + // The native service is up; treat that as "socket connected" for the + // chat surface. (The native module does not expose signaling-socket + // connect/disconnect events, so this stays true until the service is + // explicitly stopped; a transient native reconnect is transparent and + // re-drives negotiation via the presence broadcast above.) setIsSocketConnected(true); socketReadyRef.current = true; console.log(`[ac2] socket phase done in ${Date.now() - setupStartedAt}ms`); - // Seed presence so the first negotiation decision is based on a real - // room count instead of an unknown; broadcasts drive it afterwards. A - // failed query is non-fatal (fall back to broadcasts). - try { - const seeded = await queryPresence(socket, requestId); - if (!active) return; - setPeerPresence(seeded); - peerPresenceRef.current = seeded; - } catch (err) { - console.log('[ac2] initial presence query failed (will rely on broadcasts)', err); - } - // Attempt the first p2p negotiation (gated on both peers being present). + // Attempt the first p2p negotiation (gated on both peers being present; + // if presence is not yet known it waits for the first broadcast). maybeNegotiateRef.current(); } catch (err: any) { // A superseded run (cleanup fired, or a request was aborted) must do @@ -1316,14 +1256,14 @@ export function useConnection( }; }, [origin, requestId, accounts.length > 0, keys.length > 0, socketNonce]); - // Negotiate (and re-negotiate) the p2p transport over the PERSISTENT socket. - // Keyed on the reconnect nonce so each manual/auto/presence-driven attempt - // runs as its own superseded-safe run. Reuses `clientRef.current` (the - // socket) and NEVER closes it on teardown — only the peer/data-channels are + // Negotiate (and re-negotiate) the p2p transport over the PERSISTENT native + // service. Keyed on the reconnect nonce so each manual/auto/presence-driven + // attempt runs as its own superseded-safe run. Reuses the started native + // service and NEVER stops it on teardown — only the peer/data-channels are // torn down, so the app stays connected to the service between chats. useEffect(() => { - // Nothing to negotiate until the persistent socket exists and is connected. - if (!clientRef.current || !socketReadyRef.current) return; + // Nothing to negotiate until the persistent native service is started. + if (!nativeStartedRef.current || !socketReadyRef.current) return; if (isConnectedRef.current) return; let active = true; @@ -1333,8 +1273,7 @@ export function useConnection( async function negotiateTransport() { if (userStoppedRef.current) return; if (transportInFlightRef.current) return; - const client = clientRef.current; - if (!client) return; + if (!nativeStartedRef.current) return; // Peers must both be present (deviceCount >= 2) before negotiating p2p. if (isPeerOffline(peerPresenceRef.current)) { console.log( @@ -1391,11 +1330,16 @@ export function useConnection( }, }); - // Presence is subscribed on the persistent socket (socket effect), so it - // is intentionally NOT re-subscribed here. - const { datachannel, disposePresence } = await createAc2Transport({ + // Presence is subscribed on the persistent native service (socket + // effect), so it is intentionally NOT re-subscribed per negotiation. + // `createNativeAc2Transport` drives the native background service + // (`start` -> `connect('answer', { dataChannels })` -> wait for `ac2-v1` + // open) and routes native events into `RTCDataChannel`-shaped shims, so + // the wiring below is unchanged from the old in-process path aside from + // the shim casts at the `RTCDataChannel`-typed helper boundaries. + const transport = await createNativeAc2Transport({ + url: origin, requestId, - signalClient: client, signal: runAbort.signal, onPeerConnection: (pc) => { // Stash the peer connection; the connectivity monitor is attached in @@ -1406,19 +1350,22 @@ export function useConnection( 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 (!active) 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; // Any inbound stream frame is proof of peer liveness. @@ -1430,18 +1377,23 @@ export function useConnection( } }, }); + const { datachannel } = transport; - // The transport's `onPresence` listener lives on the persistent socket, - // so track its disposer for the socket teardown path (`closeSocket`). + // Track this negotiation's listener disposer so `clearTransport` / the + // effect cleanup can detach the native message/state/ICE listeners. // Replace any prior disposer first so a superseded run cannot leak one. - if (transportPresenceUnsubRef.current) { + if (transportDisposeRef.current) { try { - transportPresenceUnsubRef.current(); + transportDisposeRef.current(); } catch { /* noop */ } } - transportPresenceUnsubRef.current = disposePresence; + transportDisposeRef.current = transport.dispose; + // The native presence listener is persistent (socket effect); the + // transport's own presence disposer is a no-op here, tracked only for + // symmetry with the socket teardown path. + transportPresenceUnsubRef.current = transport.disposePresence; if (!active) { // This run was superseded while negotiation was still winding down. @@ -1452,7 +1404,7 @@ export function useConnection( 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 @@ -1460,7 +1412,7 @@ 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, @@ -1645,37 +1597,28 @@ export function useConnection( } heartbeatChannelRef.current = null; } - const client = clientRef.current; - if (client) { - // Only hard-close the peer once this run owned an established transport. - // For a superseded run, `runAbort.abort()` above already cancelled the - // logical attempt; force-closing the native peer here can race - // Android's in-flight `setRemoteDescription` and crash. NEVER close the - // socket here — it is owned by the socket effect and must stay alive. - if (hadEstablishedTransport) { - try { - client.peerClient?.close(); - } catch { - /* noop */ - } - } - client.peerClient = undefined; - // Detach the per-negotiation listeners so reusing this socket for the - // next attempt doesn't accumulate duplicate handlers. + // Detach this negotiation's native listeners (message/state/ICE) so + // reusing the service for the next attempt doesn't accumulate duplicate + // handlers. Own-disposer, set once the transport was established. + if (transportDisposeRef.current) { try { - client.off('data-channel'); - } catch { - /* noop */ - } - const s = client.socket as any; - try { - s?.off?.('offer-candidate'); - s?.off?.('answer-candidate'); - s?.off?.('offer-description'); - s?.off?.('answer-description'); + transportDisposeRef.current(); } catch { /* noop */ } + transportDisposeRef.current = null; + } + // Only cancel the native peer once this run owned an established + // transport. For a superseded run, `runAbort.abort()` above already + // cancelled the in-flight native negotiation (`createNativeAc2Transport` + // races the abort signal and calls `cancel()` itself), so cancelling + // again is unnecessary. NEVER stop the service here — it is owned by the + // socket effect and must stay alive so the app remains connected between + // chats. + if (hadEstablishedTransport && nativeStartedRef.current) { + cancelNativeNegotiation().catch(() => { + /* best-effort */ + }); } }; }, [origin, requestId, reconnectNonce]); diff --git a/jest.config.js b/jest.config.js index bbc847c..6f2dabe 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,6 +8,12 @@ module.exports = { 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/index.ts b/lib/ac2/index.ts index 314d6e3..caa77cb 100644 --- a/lib/ac2/index.ts +++ b/lib/ac2/index.ts @@ -50,7 +50,25 @@ export type { ScopedConnectionNotice, StreamControlFrame, } from './stream'; -export { createAc2Transport, waitForSignalSocketConnected } from './transport'; -export type { Ac2TransportSetup, CreateAc2TransportOptions } from './transport'; +export { NativeDataChannel, NativePeerConnection } from './nativeChannel'; +export type { DataChannelMessageEvent, DataChannelReadyState } from './nativeChannel'; +export { + AC2_CONTROL_CHANNEL, + addNativePresenceListener, + cancelNativeNegotiation, + createNativeAc2Transport, + startNativeService, + stopNativeService, +} from './nativeTransport'; +export type { + CreateNativeAc2TransportOptions, + LiquidAuthNativeApi, + NativeAc2TransportSetup, + NativeDataChannelInit, + NativeIceServer, + NativeLinkErrorEvent, + NativePresenceEvent, + NativeSubscription, +} from './nativeTransport'; export type { AC2BaseMessage as Ac2Message } from '@algorandfoundation/ac2-sdk/schema'; diff --git a/lib/ac2/nativeTransport.ts b/lib/ac2/nativeTransport.ts index 6515ac9..179c065 100644 --- a/lib/ac2/nativeTransport.ts +++ b/lib/ac2/nativeTransport.ts @@ -153,6 +153,53 @@ function getDefaultNativeApi(): LiquidAuthNativeApi { }; } +/** + * 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(); +} + +/** + * 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); +} + /** * Open the AC2 control plane over the native background service. Side-channels * (`ac2-stream`, `ac2-heartbeat`) are surfaced via `onSideChannel`. Resolves diff --git a/lib/ac2/transport.ts b/lib/ac2/transport.ts index 4ac2b23..c484a26 100644 --- a/lib/ac2/transport.ts +++ b/lib/ac2/transport.ts @@ -1,12 +1,17 @@ /** - * 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 { LinkError, SignalClient } from '@algorandfoundation/liquid-client'; - -import { subscribeToPresence, type PresenceResult } from './presence'; +import type { SignalClient } from '@algorandfoundation/liquid-client'; /** Default ICE config for the Liquid Auth signaling pair. */ export const DEFAULT_ICE_SERVERS = [ @@ -30,9 +35,8 @@ export const DEFAULT_DATA_CHANNELS = { '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 @@ -41,236 +45,6 @@ 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; - /** - * Removes the `onPresence` listener from the (long-lived) signaling socket. - * Call it when tearing down this transport so successive negotiations for the - * same `requestId` do not accumulate presence listeners. No-op when - * `onPresence` was not supplied. - */ - disposePresence: () => void; -} - -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; - /** - * Optional presence listener. When provided, server-broadcast `presence` - * updates for the `requestId` are forwarded here so the caller can track how - * many devices are connected (and decide whether reconnecting is worthwhile). - * Handled outside `SignalClient`, directly on the signaling socket. - */ - onPresence?: (presence: PresenceResult) => void; -} - -/** - * Open the AC2 control plane DataChannel against an already-authenticated - * `SignalClient`. Side-channels (`ac2-stream`, `ac2-heartbeat`) are - * surfaced via `onSideChannel`. - */ -export async function createAc2Transport( - opts: CreateAc2TransportOptions, -): Promise { - const { requestId, signalClient, onSideChannel, onPeerConnection, signal, onPresence } = 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); - - // Make the signaling handshake observable for the duration of this one - // negotiation. When a (re)connect stalls at "Connecting…", the logs show - // exactly which step never arrived — most tellingly an `offer-description` - // we sent with NO following `answer-description` (see the helper's docs). - const disposeSignalingDiagnostics = attachSignalingDiagnostics(signalClient, requestId); - - // The `onPresence` listener lives on the long-lived signaling socket, which is - // reused across socket.io reconnects and across successive negotiations for - // the same `requestId`. Own its unsubscribe so it can be released with the - // connection (returned below) instead of leaking a listener per negotiation. - let disposePresence: () => void = () => {}; - // Same for the one-shot `link-error` listener installed below: own its - // detacher so it never outlives this negotiation. - let disposeLinkError: () => void = () => {}; - - try { - // Presence is handled outside the SignalClient, directly on the socket: keep - // the caller informed of how many devices are connected for this requestId. - if (onPresence) { - const socket = (signalClient as any).socket; - if (socket) disposePresence = subscribeToPresence(socket, onPresence); - } - - 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(() => { - console.warn( - `[ac2][signal] negotiate: TIMEOUT after ${SIGNALING_TIMEOUT_MS}ms — no answer-description arrived; the peer never answered our offer (it likely wasn't linked/armed when we sent it).`, - ); - 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]; - - // A room refusal under the two-peer lockdown (the session already holds its - // one wallet + one peer, or the same role twice) is delivered by the - // gateway as a generic `exception` event on the signaling socket, NOT as an - // `answer-description`. Watch for it (scoped to THIS requestId) and reject - // immediately with the client's typed `LinkError`, so a full session fails - // in <1s with an actionable "session full" message instead of hanging out - // the 30s answer-description timeout and being misread as "peer offline". - const socket = (signalClient as any).socket; - if (socket && typeof socket.on === 'function') { - let linkErrorReject: (err: Error) => void = () => {}; - const linkErrorPromise = new Promise((_, reject) => { - linkErrorReject = reject; - }); - const onException = (payload: any) => { - if (payload?.event !== 'link-error' || payload?.requestId !== requestId) return; - signalClient.peerClient?.close(); - linkErrorReject( - new LinkError(payload?.message ?? 'Liquid Auth refused the link for this requestId', { - reason: payload?.reason, - requestId, - }), - ); - }; - socket.on('exception', onException); - disposeLinkError = () => { - try { - socket.off?.('exception', onException); - } catch { - /* noop */ - } - }; - racers.push(linkErrorPromise); - } - - // 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; - } - 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); - // The link-refusal watcher is only relevant while negotiating; detach it - // regardless of which racer won so it can't fire against a later run. - disposeLinkError(); - }); - - // 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); - - // 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') { - try { - signalClient.peerClient?.close(); - } catch { - /* noop */ - } - } - throw err; - } - - return { client: signalClient, datachannel, disposePresence }; - } catch (err) { - // On a failed negotiation nothing downstream owns the disposers (the setup - // is never returned), so release both the presence and link-refusal - // listeners here to avoid a leak. - disposePresence(); - disposeLinkError(); - throw err; - } finally { - disposeSignalingDiagnostics(); - } -} - /** * Attach lightweight, additive diagnostics to a `SignalClient` for the duration * of one negotiation, so a stalled handshake ("Connecting…" that never 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..9b51b2e --- /dev/null +++ b/modules/react-native-liquid-auth/VENDORED.md @@ -0,0 +1,64 @@ +# 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 + +## 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). + +## 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 + +The iOS podspec pulls `WebRTC-lib`, which can 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..feb572f --- /dev/null +++ b/modules/react-native-liquid-auth/android/build.gradle @@ -0,0 +1,35 @@ +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 + implementation "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..6d4bc5a --- /dev/null +++ b/modules/react-native-liquid-auth/android/src/main/java/co/algorand/liquid/LiquidAuthNativeModule.kt @@ -0,0 +1,321 @@ +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 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.SignalClient +import foundation.algorand.auth.connect.SignalService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +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 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" + } + + private var signalService: SignalService? = null + private var serviceConnection: ServiceConnection? = null + private val httpClient = OkHttpClient() + 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) + + /** + * 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 + ) + 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>?, options: Map?, promise: Promise -> + val service = signalService + if (service == null) { + promise.reject("E_NOT_STARTED", "Signaling service not started, call start() first", null) + return@AsyncFunction + } + val activity = currentActivity + scope.launch { + try { + service.peer( + requestId, + type, + parseIceServers(iceServers), + parseDataChannels(options), + null, + onTrack = { track -> + sendEvent( + ON_TRACK, + mapOf( + "id" to track.id(), + "kind" to track.kind(), + "enabled" to track.enabled() + ) + ) + }, + onPresence = { presence -> sendEvent(ON_PRESENCE, jsonToMap(presence)) }, + onLinkError = { error -> sendEvent(ON_LINK_ERROR, jsonToMap(error)) }, + onConnectionStateChange = { state -> + sendEvent(ON_CONNECTION_STATE_CHANGE, mapOf("state" to state)) + } + ) + service.handleMessages( + activity, + { label, msg -> sendEvent(ON_MESSAGE, mapOf("channel" to label, "message" to msg)) }, + { label, state -> sendEvent(ON_STATE_CHANGE, mapOf("channel" to label, "state" to state)) }, + createNotificationBuilder(), + NOTIFICATION_ID, + activity::class.java + ) + promise.resolve(null) + } catch (e: CancellationException) { + promise.reject("E_ABORTED", "Connection aborted", e) + } catch (e: Exception) { + promise.reject("E_CONNECT", e.message, e) + } + } + } + + /** + * Abort an in-flight [connect] negotiation. The pending `connect` promise + * rejects with `E_ABORTED`. + */ + AsyncFunction("cancel") { promise: Promise -> + signalService?.cancel() + promise.resolve(null) + } + + /** + * Send a message over the primary (`liquid`) data channel. + */ + Function("send") { message: String -> + val service = signalService ?: throw Exception("Signaling service not started") + service.send(message) + } + + /** + * Send a message over a specific named data channel. + */ + Function("sendToChannel") { channel: String, message: String -> + val service = signalService ?: throw Exception("Signaling service not started") + service.send(channel, message) + } + + /** + * Stop the signaling client and unbind/stop the foreground service. + */ + AsyncFunction("disconnect") { promise: Promise -> + signalService?.stop() + unbindService() + promise.resolve(null) + } + + OnDestroy { + unbindService() + } + } + + private fun bindService(onConnected: () -> Unit) { + if (signalService != null) { + onConnected() + return + } + createNotificationChannel() + val connection = object : ServiceConnection { + override fun onServiceDisconnected(name: ComponentName?) { + signalService = null + } + + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + val localBinder = binder as SignalService.LocalBinder + signalService = localBinder.getServerInstance() + onConnected() + } + } + serviceConnection = connection + val intent = Intent(context, SignalService::class.java) + context.startService(intent) + context.bindService(intent, connection, Context.BIND_AUTO_CREATE) + } + + private fun unbindService() { + serviceConnection?.let { + try { + context.unbindService(it) + } catch (e: Exception) { + // Service was not bound + } + } + serviceConnection = null + try { + context.stopService(Intent(context, SignalService::class.java)) + } catch (e: Exception) { + // ignore + } + signalService = null + } + + /** + * Flatten a (single-level) [JSONObject] into a map so it can be forwarded as + * an event payload. Presence (`{ requestId, deviceCount, online }`) and + * signaling exceptions (`{ event, requestId, reason, message }`) are flat. + */ + private fun jsonToMap(json: JSONObject): Map { + val map = mutableMapOf() + val keys = json.keys() + while (keys.hasNext()) { + val key = keys.next() + val value = json.opt(key) + map[key] = if (value == JSONObject.NULL) null else value + } + return map + } + + private fun parseDataChannels(options: Map?): Map? { + @Suppress("UNCHECKED_CAST") + val dataChannels = options?.get("dataChannels") as? Map ?: return null + if (dataChannels.isEmpty()) { + return null + } + return dataChannels.mapValues { (_, value) -> + val init = DataChannel.Init() + val config = value as? Map<*, *> ?: return@mapValues init + (config["ordered"] as? Boolean)?.let { init.ordered = it } + (config["maxRetransmits"] as? Number)?.let { init.maxRetransmits = it.toInt() } + (config["maxRetransmitTimeMs"] as? Number)?.let { init.maxRetransmitTimeMs = it.toInt() } + (config["maxPacketLifeTime"] as? Number)?.let { init.maxRetransmitTimeMs = it.toInt() } + (config["protocol"] as? String)?.let { init.protocol = it } + (config["negotiated"] as? Boolean)?.let { init.negotiated = it } + (config["id"] as? Number)?.let { init.id = it.toInt() } + init + } + } + + private fun parseIceServers(iceServers: List>?): List { + if (iceServers.isNullOrEmpty()) { + return listOf( + PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer() + ) + } + return iceServers.mapNotNull { server -> + val urls = when (val u = server["urls"]) { + is String -> listOf(u) + is List<*> -> u.filterIsInstance() + else -> emptyList() + } + if (urls.isEmpty()) { + return@mapNotNull null + } + val builder = PeerConnection.IceServer.builder(urls) + (server["username"] as? String)?.let { builder.setUsername(it) } + (server["credential"] as? String)?.let { builder.setPassword(it) } + builder.createIceServer() + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (manager.getNotificationChannel(CHANNEL_ID) == null) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW + ) + manager.createNotificationChannel(channel) + } + } + } + + private fun createNotificationBuilder(): NotificationCompat.Builder { + return NotificationCompat.Builder(context, CHANNEL_ID) + .setContentTitle(CHANNEL_NAME) + .setContentText("Connected to the signaling service") + .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth) + .setOngoing(true) + } +} diff --git a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/AuthMessage.kt b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/AuthMessage.kt new file mode 100644 index 0000000..300b8c2 --- /dev/null +++ b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/AuthMessage.kt @@ -0,0 +1,58 @@ +package foundation.algorand.auth.connect + +import android.net.Uri +import android.util.Log +import org.json.JSONObject + +private fun Uri.findParameterValue(parameterName: String): String? { + return query?.split('&')?.map { + val parts = it.split('=') + val name = parts.firstOrNull() ?: "" + val value = parts.drop(1).firstOrNull() ?: "" + Pair(name, value) + }?.firstOrNull{it.first == parameterName}?.second +} +class AuthMessage( + var origin: String, + val requestId: String +) { + + companion object { + const val TAG = "connect.Message" + fun fromUri(uri: Uri): AuthMessage { + Log.d(TAG, "fromUri($uri)") + val origin = "https://${uri.host}" + val requestId = uri.findParameterValue("requestId").toString() + return AuthMessage(origin, requestId) + } + /** + * Parse the Uri string + * + * `liquid:///?requestId=` + */ + fun fromString(stringContents: String): AuthMessage { + Log.d(TAG, "fromString($stringContents)") + if(stringContents.startsWith("liquid://")) { + return fromUri(Uri.parse(stringContents)) + } else { + // Fallback + val json = JSONObject(stringContents) + if (!json.has("origin")) { + throw IllegalArgumentException("Invalid QR code: missing 'origin' field") + } + if (!json.has("requestId")) { + throw IllegalArgumentException("Invalid QR code: missing 'requestId' field") + } + val origin = json.get("origin").toString() + val requestId = json.get("requestId").toString() + return AuthMessage(origin, requestId) + } + } + } + fun toJSON() : JSONObject { + val result = JSONObject() + result.put("origin", origin) + result.put("requestId", requestId) + return result + } +} diff --git a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/IceCandidateExt.kt b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/IceCandidateExt.kt new file mode 100644 index 0000000..a0d1535 --- /dev/null +++ b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/IceCandidateExt.kt @@ -0,0 +1,12 @@ +package foundation.algorand.auth.connect + +import org.json.JSONObject +import org.webrtc.IceCandidate + +fun IceCandidate.toJSON(): JSONObject { + return JSONObject().apply { + put("candidate", sdp) + put("sdpMid", sdpMid) + put("sdpMLineIndex", sdpMLineIndex) + } +} diff --git a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/JSONObjectExt.kt b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/JSONObjectExt.kt new file mode 100644 index 0000000..77a51fc --- /dev/null +++ b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/JSONObjectExt.kt @@ -0,0 +1,8 @@ +package foundation.algorand.auth.connect + +import org.json.JSONObject +import org.webrtc.IceCandidate + +fun JSONObject.toIceCandidate(): IceCandidate { + return IceCandidate(getString("sdpMid"), getInt("sdpMLineIndex"), getString("candidate")) +} diff --git a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/PeerApi.kt b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/PeerApi.kt new file mode 100644 index 0000000..9c5e013 --- /dev/null +++ b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/PeerApi.kt @@ -0,0 +1,363 @@ +package foundation.algorand.auth.connect + +import android.content.Context +import android.util.Log +import org.webrtc.* +import java.nio.ByteBuffer +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +class PeerApi(context: Context) { + companion object { + const val TAG = "connect.PeerApi" + } + + // Primary Data Channel to send and receive messages (kept for backwards + // compatibility). Points at the `liquid` channel when present, otherwise the + // most recently created/received channel. + private var dataChannel: DataChannel? = null + + // All negotiated Data Channels, keyed by their label. This allows callers to + // open and address multiple named channels (e.g. `ac2-v1`, `ac2-stream`). + val dataChannels = mutableMapOf() + + // Invoked when a remote media track is added to the connection. + var onTrack: ((MediaStreamTrack) -> Unit)? = null + + // Invoked when the peer connection's ICE connection state changes + // (e.g. `CONNECTED`, `DISCONNECTED`, `FAILED`). Lets consumers monitor + // connectivity/reduced connection stats without a direct handle on the + // native `PeerConnection`. + var onConnectionStateChange: ((String) -> Unit)? = null + + // Create the Peer Connection Factory + private var peerConnectionFactory: PeerConnectionFactory + + init { + PeerConnectionFactory.initialize( + PeerConnectionFactory.InitializationOptions.builder(context) + .setEnableInternalTracer(true) + .createInitializationOptions() + ) + peerConnectionFactory = PeerConnectionFactory + .builder() + .setOptions(PeerConnectionFactory.Options().apply { + disableEncryption = false + disableNetworkMonitor = false + }) + .createPeerConnectionFactory() + } + + // Current Peer Connection + var peerConnection: PeerConnection? = null + + /** + * Create a new Peer Connection + */ + fun createPeerConnection( + onIceCandidate: (IceCandidate) -> Unit, + onDataChannel: (DataChannel) -> Unit, + iceServers: List? = listOf( + PeerConnection.IceServer.builder("stun:stun.l.google.com:19302") + .createIceServer() + ) + ) { + if (peerConnection !== null) { + peerConnection?.close() + } + + peerConnection = peerConnectionFactory.createPeerConnection( + iceServers, + object : PeerConnection.Observer { + override fun onIceCandidate(p0: IceCandidate?) { + p0?.let { + onIceCandidate(it) + } + } + + override fun onDataChannel(p0: DataChannel?) { + Log.d(TAG, "onDataChannel($p0)") + p0?.let { + dataChannels[it.label()] = it + dataChannel = it + onDataChannel(it) + } + } + + override fun onIceConnectionChange(p0: PeerConnection.IceConnectionState?) { + Log.d(TAG, "onIceConnectionChange($p0)") + p0?.let { onConnectionStateChange?.invoke(it.toString()) } + if (p0 === PeerConnection.IceConnectionState.FAILED) { + Log.e(TAG, "ICE Connection Failed") + } + } + + override fun onIceConnectionReceivingChange(p0: Boolean) { + Log.d(TAG, "onIceConnectionReceivingChange($p0)") + } + + override fun onIceGatheringChange(p0: PeerConnection.IceGatheringState?) { + Log.d(TAG, "onIceGatheringChange($p0)") + } + + override fun onAddStream(p0: MediaStream?) { + Log.d(TAG, "onAddStream($p0)") + } + + override fun onSignalingChange(p0: PeerConnection.SignalingState?) { + Log.d(TAG, "onSignalingChange($p0)") + } + + override fun onIceCandidatesRemoved(p0: Array?) { + Log.d(TAG, "onIceCandidatesRemoved($p0)") + } + + override fun onRemoveStream(p0: MediaStream?) { + Log.d(TAG, "onRemoveStream($p0)") + } + + override fun onRenegotiationNeeded() { + Log.d(TAG, "onRenegotiationNeeded()") + } + + override fun onAddTrack(p0: RtpReceiver?, p1: Array?) { + Log.d(TAG, "onAddTrack($p0, $p1)") + p0?.track()?.let { onTrack?.invoke(it) } + } + } + ) + } + suspend fun createPeerConnection(onIceCandidate: (IceCandidate) -> kotlin.Unit, iceServers: List? = listOf( + PeerConnection.IceServer.builder("stun:stun.l.google.com:19302") + .createIceServer()) ): DataChannel { + return suspendCoroutine { continuation -> + createPeerConnection(onIceCandidate,{ + continuation.resume(it) + },iceServers) + } + } + /** + * Add a local media track to the Peer Connection. + * + * Mirrors the `options.tracks` handling in the `liquid-auth-js` client, + * where each supplied track is added to the peer before negotiation. + */ + fun addTrack(track: MediaStreamTrack, streamIds: List = emptyList()) { + if (peerConnection === null) { + throw Exception("peerConnection is null, ensure you are connected") + } + peerConnection?.addTrack(track, streamIds) + } + + /** + * Add an ICE Candidate + */ + fun addIceCandidate(candidate: IceCandidate) { + if (peerConnection === null) { + throw Exception("peerConnection is null, ensure you are connected") + } + peerConnection?.addIceCandidate(candidate) + } + fun setLocalDescription(description: SessionDescription, onSessionDescription: (SessionDescription?) -> Unit) { + if (peerConnection === null) { + throw Exception("peerConnection is null, ensure you are connected") + } + peerConnection?.setLocalDescription(createSDPObserver(onSessionDescription), description) + } + /** + * Set the Remote Description + * + * Handles Remote Description with a Callback Function + */ + fun setRemoteDescription(description: SessionDescription, onSessionDescription: (SessionDescription?) -> Unit) { + if (peerConnection === null) { + throw Exception("peerConnection is null, ensure you are connected") + } + peerConnection?.setRemoteDescription(createSDPObserver(onSessionDescription), description) + } + + /** + * Set the Remote Description + * + * Handles Remote Description using Coroutines + */ + suspend fun setRemoteDescription(description: SessionDescription): SessionDescription? { + return suspendCoroutine { continuation -> + setRemoteDescription(description) { sessionDescription -> + continuation.resume(sessionDescription) + } + } + } + + /** + * Create an SDP Observer + * + * Used for Local and Remote Description handling + */ + private fun createSDPObserver(onSessionDescription: (SessionDescription?) -> Unit): SdpObserver { + return object : SdpObserver { + override fun onSetFailure(p0: String?) { + Log.e(TAG, "onSetFailure: $p0") + } + + override fun onSetSuccess() { + Log.d(TAG, "onSetSuccess") + onSessionDescription(peerConnection?.localDescription) + } + + override fun onCreateSuccess(p0: SessionDescription?) { + Log.e(TAG, "onCreateSuccess") + onSessionDescription(p0) + } + + override fun onCreateFailure(p0: String?) { + Log.e(TAG, "onCreateFailure: $p0") + onSessionDescription(null) + + } + } + } + fun createAnswer(onSessionDescription: (SessionDescription?) -> Unit) { + Log.d(TAG, "createAnswer") + if (peerConnection === null) { + throw Exception("peerConnection is null") + } + peerConnection?.createAnswer(createSDPObserver(onSessionDescription), MediaConstraints()) + } + suspend fun createAnswer(): SessionDescription? { + return suspendCoroutine { continuation -> + createAnswer { sessionDescription -> + continuation.resume(sessionDescription) + } + } + } + /** + * Create an Offer + * + * Handles Offer Creation with a Callback Function + */ + fun createOffer(onSessionDescription: (SessionDescription?)->Unit) { + if (peerConnection === null) { + throw Exception("peerConnection is null") + } + peerConnection?.createOffer(createSDPObserver(onSessionDescription), MediaConstraints()) + } + + /** + * Create an Offer + * + * Handles Offer Creation using Coroutines + */ + suspend fun createOffer(): SessionDescription? { + return suspendCoroutine { continuation -> + createOffer { sessionDescription -> + continuation.resume(sessionDescription) + } + } + } + + /** + * Create an observer for a specific [dataChannel]. + * + * The channel `label` is forwarded to every callback so multiple named + * channels can be multiplexed onto the same handlers. + */ + fun createDataChannelObserver( + dataChannel: DataChannel, + label: String, + onMessage: (label: String, message: String) -> Unit, + onStateChange: ((label: String, state: String?) -> Unit)? = null, + onBufferedAmountChange: ((label: String, amount: Long) -> Unit)? = null + ): DataChannel.Observer { + if (peerConnection === null) { + throw Exception("peerConnection is null") + } + return object : DataChannel.Observer { + override fun onBufferedAmountChange(p0: Long) { + Log.d(TAG, "onBufferedAmountChange($label, $p0)") + onBufferedAmountChange?.invoke(label, p0) + } + + override fun onStateChange() { + Log.d(TAG, "onStateChange($label)") + onStateChange?.invoke(label, dataChannel.state().toString()) + } + + /** + * Handle DataChannel messages + */ + override fun onMessage(p0: DataChannel.Buffer?) { + Log.d(TAG, "onMessage($label, $p0)") + p0?.data?.let { + val bytes = ByteArray(it.remaining()) + p0.data.get(bytes) + val payload = String(bytes) + onMessage(label, payload) + } + } + } + } + + /** + * Create a named Data Channel. + * + * The optional [init] mirrors `RTCDataChannelInit` from `liquid-auth-js` + * (`ordered`, `maxRetransmits`, `negotiated`, etc.). The created channel is + * tracked by label so it can be addressed later via [send] or [getDataChannel]. + */ + fun createDataChannel(label: String, init: DataChannel.Init = DataChannel.Init()): DataChannel? { + if (peerConnection === null) { + throw Exception("peerConnection is null") + } + dataChannels[label]?.close() + val channel = peerConnection?.createDataChannel(label, init) + if (channel != null) { + dataChannels[label] = channel + dataChannel = channel + } + return channel + } + + /** + * Lookup a previously negotiated channel by label. + */ + fun getDataChannel(label: String): DataChannel? { + return dataChannels[label] + } + + /** + * Send a message over the primary (or `liquid`) channel. + */ + fun send(message: String) { + val channel = dataChannels["liquid"] ?: dataChannel + ?: throw Exception("dataChannel is null") + sendToChannel(channel, message) + } + + /** + * Send a message over a specific named channel. + */ + fun send(label: String, message: String) { + val channel = dataChannels[label] + ?: throw Exception("dataChannel '$label' is null") + sendToChannel(channel, message) + } + + private fun sendToChannel(channel: DataChannel, message: String) { + if (channel.state() !== DataChannel.State.OPEN) { + throw Exception("dataChannel '${channel.label()}' is not open") + } + val buffer = ByteBuffer.wrap(message.toByteArray()) + channel.send(DataChannel.Buffer(buffer, false)) + } + + fun destroy() { + dataChannels.values.forEach { it.close() } + dataChannels.clear() + dataChannel?.close() + peerConnection?.close() + peerConnection?.dispose() + peerConnection = null + dataChannel = null + } +} diff --git a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalClient.kt b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalClient.kt new file mode 100644 index 0000000..e77592f --- /dev/null +++ b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalClient.kt @@ -0,0 +1,387 @@ +package foundation.algorand.auth.connect + +import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import io.socket.client.Ack +import io.socket.client.IO +import io.socket.client.Socket +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.json.JSONObject +import org.webrtc.PeerConnection +import org.webrtc.DataChannel +import org.webrtc.IceCandidate +import org.webrtc.MediaStreamTrack +import org.webrtc.SessionDescription +import qrcode.QRCode +import qrcode.color.Colors +import java.io.ByteArrayOutputStream +import java.util.* +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + + +/** + * Signal Client + * + * Has two modes: + * - Offer: Create a Peer Offer + * - Answer: Create a Peer Answer + */ +class SignalClient( + /** + * Origin of the Service + */ + override val url: String, + /** + * Android Context + */ + override val context: Context, + /** + * HTTP Client + */ + override val client: OkHttpClient, +) : SignalInterface { + companion object { + const val TAG = "connect.SignalClient" + fun generateRequestId(): String { + return SignalInterface.generateRequestId() + } + } + + var type: String? = null + override var socket: Socket? = null + var peerClient: PeerApi? = null + private val scope = CoroutineScope(Dispatchers.Main) + + /** + * Server-broadcast `presence` updates for the current `requestId` room + * (`{ requestId, deviceCount, online }`). Set before [peer] so the listener + * is registered when the socket is created. + */ + var onPresence: ((JSONObject) -> Unit)? = null + + /** + * Signaling `exception` events (e.g. a `link-error` room refusal under the + * two-peer lockdown, carrying `event`/`reason`/`requestId`). Set before + * [peer] so the listener is registered when the socket is created. + */ + var onLinkError: ((JSONObject) -> Unit)? = null + + /** + * Forwarded to [PeerApi.onConnectionStateChange] when the peer is created, + * so callers can observe ICE connection state without a native handle. + */ + var onConnectionStateChange: ((String) -> Unit)? = null + + // In-flight negotiation bookkeeping, so [cancel] can abort a pending [peer]. + private var peerJob: Job? = null + private var peerContinuation: Continuation? = null + private var peerResumed = false + + /** + * Resume the pending [peer] continuation at most once with [result]. + */ + private fun resumePeer(result: DataChannel?) { + if (peerResumed) return + peerResumed = true + val continuation = peerContinuation + peerContinuation = null + continuation?.resume(result) + } + + /** + * Fail the pending [peer] continuation at most once with [error]. + */ + private fun failPeer(error: Throwable) { + if (peerResumed) return + peerResumed = true + val continuation = peerContinuation + peerContinuation = null + continuation?.resumeWithException(error) + } + + /** + * Abort an in-flight [peer] negotiation: cancel the negotiation coroutine, + * fail the pending continuation with a [CancellationException] so the caller + * unblocks promptly, and tear the socket + peer down. + */ + fun cancel() { + peerJob?.cancel() + peerJob = null + failPeer(CancellationException("Peer negotiation cancelled")) + disconnect() + } + + /** + * Generate a random Request ID + */ + override fun generateRequestId(): String { + return SignalClient.generateRequestId() + } + + /** + * Generate a QR Code + */ + override fun qrCode( + requestId: String, + logo: Bitmap?, + logoSize: Int?, + color: String?, + backgroundColor: String?, + ): Bitmap { + val size = logoSize ?: 200 + val scaledLogo = logo?.let { Bitmap.createScaledBitmap(it, size, size, false) } + val stream = ByteArrayOutputStream() + scaledLogo?.compress(Bitmap.CompressFormat.PNG, 100, stream) + val data = "liquid://${url.replace("https://", "")}/?requestId=$requestId" + val image = QRCode.ofSquares() + .withColor(Colors.css(color ?: "#9966FF")) + .withBackgroundColor(Colors.css(backgroundColor ?: "#15121B")) + .withLogo(stream.toByteArray(), size, size) + .build(data) + .render() + .nativeImage() + if (image !is Bitmap) { + throw Exception("Invalid Type") + } + return image + } + + /** + * Top Level Peer Connection + * + * The type parameter is used to specify the type of remote peer + */ + override suspend fun peer( + requestId: String, + type: String, + iceServers: List?, + dataChannels: Map?, + tracks: List? + ): DataChannel? { + createSocket() + return suspendCoroutine { continuation -> + peerContinuation = continuation + peerResumed = false + peerJob = scope.launch { + val clientType = if (type === "offer") "answer" else "offer" + peerClient = PeerApi(context) + peerClient?.onConnectionStateChange = onConnectionStateChange + // Note: the peer continuation is resumed at most once via + // resumePeer(), even though several remote data channels can + // arrive when the peer opens multiple channels. + // Buffer ICE Candidates if they arrive before the Peer Connection is established + val candidatesBuffer = mutableListOf() + // If we are waiting on an offer, create a link to the address + if(type === "offer"){ + link(requestId) + } + // Listen to Remote ICE Candidates + socket!!.on("${type}-candidate") { + Log.d( + TAG, + "onIce${type.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() }}Candidate(${it[0]})" + ) + val candidate = it[0] as JSONObject + // Buffer Candidates if the Peer Connection is not established + if (peerClient!!.peerConnection === null) { + candidatesBuffer.add(candidate.toIceCandidate()) + } else { + peerClient?.addIceCandidate(candidate.toIceCandidate()) + } + } + // Create Peer Connection + peerClient!!.createPeerConnection( + // Handle Local ICECandidates + { iceCandidate -> + Log.d( + TAG, + "onIce${clientType.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() }}Candidate(${iceCandidate.toJSON()})" + ) + // Send Local ICECandidates to Peer + socket?.emit("${clientType}-candidate", iceCandidate.toJSON()) + },{ + // Handle a Data Channel from the Peer + // This only happens for a client that creates an Answer, + // Offer clients are responsible for creating a datachannel. + // A peer can open several named channels; resume with the + // first one (preferring `liquid`), and let the remaining + // channels populate `peerClient.dataChannels`. + resumePeer(it) + }, + iceServers + ) + + // Add any local media tracks before negotiation begins + tracks?.forEach { track -> + peerClient?.addTrack(track) + } + + // Wait for Offer, then create Answer + if (type === "offer") { + val sdp = signal(type) + Log.d(TAG, "Recieved the SDP!(${sdp})") + peerClient?.setRemoteDescription(sdp) + Log.d(TAG, "Set the SDP!(${sdp})") + if(candidatesBuffer.isNotEmpty()){ + candidatesBuffer.forEach { candidate -> + peerClient?.addIceCandidate(candidate) + } + } + peerClient?.createAnswer { answerDescription -> + peerClient!!.setLocalDescription(answerDescription!!) { hasDescription -> + if(hasDescription === null) { + throw Exception("Failed to set local description") + } + } + Log.d(TAG, "createAnswer(${answerDescription.description})") + socket!!.emit("answer-description", answerDescription.description.toString()) + } + + } + // Create an Offer, wait for answer + else if (type === "answer") { + // Create the DataChannel(s). Defaults to a single `liquid` + // channel, but callers may request several named channels. + val channelConfig = if (dataChannels.isNullOrEmpty()) { + mapOf("liquid" to DataChannel.Init()) + } else { + dataChannels + } + val channels = mutableMapOf() + channelConfig.forEach { (label, init) -> + peerClient?.createDataChannel(label, init)?.let { channels[label] = it } + } + // Prefer the `liquid` channel, otherwise fall back to the first + val dc = channels["liquid"] ?: channels.values.firstOrNull() + // Create the Peering Offer + val offer = peerClient?.createOffer() + peerClient?.setLocalDescription(offer!!) { + if(it === null) { + throw Exception("Failed to set local description") + } + } + Log.d(TAG, "peer.createOffer(${offer?.description})") + socket!!.emit("offer-description", offer?.description.toString()) + val sdp = signal(type) + Log.d(TAG, "peer.onAnswer(${sdp})") + peerClient!!.setRemoteDescription(sdp) { + if(it === null){ + throw Exception("Failed to set remote description") + } + if(candidatesBuffer.isNotEmpty()){ + candidatesBuffer.forEach { candidate -> + peerClient?.addIceCandidate(candidate) + } + } + } + resumePeer(dc) + } + } + } + } + + /** + * Register observers on a single named data channel. + */ + fun handleDataChannel( + dataChannel: DataChannel, + onMessage: (label: String, message: String) -> Unit, + onStateChange: ((label: String, state: String?) -> Unit)? = null, + onBufferedAmountChange: ((label: String, amount: Long) -> Unit)? = null + ) { + dataChannel.registerObserver( + peerClient!!.createDataChannelObserver( + dataChannel, + dataChannel.label(), + onMessage, + onStateChange, + onBufferedAmountChange + ) + ) + } + + /** + * Register observers on every negotiated data channel. + */ + fun handleDataChannels( + onMessage: (label: String, message: String) -> Unit, + onStateChange: ((label: String, state: String?) -> Unit)? = null, + onBufferedAmountChange: ((label: String, amount: Long) -> Unit)? = null + ) { + peerClient?.dataChannels?.values?.forEach { channel -> + handleDataChannel(channel, onMessage, onStateChange, onBufferedAmountChange) + } + } + + /** + * Wait for a Session Description + */ + override suspend fun signal(type: String): SessionDescription { + return suspendCoroutine { continuation -> + this.socket!!.once("$type-description") { + val description = it[0] as String + Log.d( + TAG, + "signal.on${type.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() }}Description($description)" + ) + val sdpType = if (type === "offer") SessionDescription.Type.OFFER else SessionDescription.Type.ANSWER + continuation.resume(SessionDescription(sdpType, description)) + } + } + } + + override suspend fun link( + requestId: String + ): LinkMessage { + return suspendCoroutine { continuation -> + val linkBody = JSONObject() + linkBody.put("requestId", requestId) + socket!!.emit("link", linkBody, Ack { args: Array -> + val response = args[0] as JSONObject + Log.d(TAG, "link.ack($response)") + continuation.resume(LinkMessage.fromJson(response.toString())) + }) + } + } + + private fun createSocket() { + // Handle existing connections + if (socket !== null) { + socket?.close() + socket?.disconnect() + } + + // Configure Socket Options to use the same client + val options = IO.Options.builder() + .build() + options.callFactory = client + options.webSocketFactory = client + + // Connect to the messages origin + socket = IO.socket(url, options) + // Forward server-broadcast presence updates and signaling exceptions + // (e.g. link-error room refusals) to the registered callbacks. + socket?.on("presence") { args -> + (args.getOrNull(0) as? JSONObject)?.let { onPresence?.invoke(it) } + } + socket?.on("exception") { args -> + (args.getOrNull(0) as? JSONObject)?.let { onLinkError?.invoke(it) } + } + socket?.connect() + } + + fun disconnect() { + socket?.close() + socket?.disconnect() + peerClient?.destroy() + } +} diff --git a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalInterface.kt b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalInterface.kt new file mode 100644 index 0000000..c5dd044 --- /dev/null +++ b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalInterface.kt @@ -0,0 +1,82 @@ +package foundation.algorand.auth.connect + +import android.content.Context +import android.graphics.Bitmap +import okhttp3.OkHttpClient +import io.socket.client.Socket +import org.json.JSONObject +import org.webrtc.PeerConnection +import org.webrtc.DataChannel +import org.webrtc.MediaStreamTrack +import org.webrtc.SessionDescription +import com.fasterxml.uuid.Generators + +class LinkMessage(val requestId: String, val wallet: String, val credId: String? = null) { + companion object { + const val TAG = "connect.LinkMessage" + fun fromJson(json: String): LinkMessage { + val data = JSONObject(json).get("data") as JSONObject + val requestId = data.get("requestId").toString() + val wallet = data.get("wallet").toString() + val credId = data.get("credId").toString() + return LinkMessage(requestId, wallet, credId) + } + } + fun toJson(): JSONObject { + val result = JSONObject() + result.put("requestId", requestId) + result.put("wallet", wallet) + result.put("credId", credId) + return result + } +} + +interface SignalInterface { + val url: String // URL of Signal Server + val client: OkHttpClient // HTTP Client + val socket: Socket? // Socket IO Client + val context: Context? // Android Context + + companion object { + fun generateRequestId(): String { + val uuid = Generators.timeBasedEpochRandomGenerator().generate() + return uuid.toString() + } + } + + /** + * Generate a random Request ID + */ + fun generateRequestId(): String + + /** + * Generate a QR Code + */ + fun qrCode(requestId: String, logo: Bitmap?, logoSize: Int? = null, color: String? = null, backgroundColor: String? = null): Bitmap + + /** + * Top Level Peer Connection + * + * @param dataChannels optional map of channel label -> [DataChannel.Init] to + * open when acting as the offerer (mirrors `options.dataChannels` in + * `liquid-auth-js`). Defaults to a single `liquid` channel. + * @param tracks optional local media tracks to add before negotiation + * (mirrors `options.tracks` in `liquid-auth-js`). + */ + suspend fun peer( + requestId: String, + type: String, + iceServers: List?, + dataChannels: Map? = null, + tracks: List? = null + ): DataChannel? + /** + * Waits for a remote client to authenticate with the server + */ + suspend fun link(requestId: String): LinkMessage + + /** + * Exchange descriptions with the remote client + */ + suspend fun signal(type: String): SessionDescription +} diff --git a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalService.kt b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalService.kt new file mode 100644 index 0000000..da89494 --- /dev/null +++ b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalService.kt @@ -0,0 +1,251 @@ +package foundation.algorand.auth.connect + +import android.app.* +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Binder +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat.Builder +import androidx.core.app.ServiceCompat +import okhttp3.OkHttpClient +import org.json.JSONObject +import org.webrtc.DataChannel +import org.webrtc.MediaStreamTrack +import org.webrtc.PeerConnection + + +class SignalService : Service() { + companion object { + const val TAG = "auth.connect.Service" + const val LIQUID_NOTIFICATION_ID = 1337 + + } + // Last known deep-link referrer + var lastKnownReferer: String? = null + var isDeepLink: Boolean = true + + // Liquid Signal Components + var signalClient: SignalClient? = null + var peerClient: PeerApi? = null + + // Native WebRTC Components + var dataChannel: DataChannel? = null + var peerConnection: PeerConnection? = null + + // Simple service binding + inner class LocalBinder : Binder() { + fun getServerInstance(): SignalService { + return this@SignalService + } + } + + // Service Binder + var mBinder: IBinder = LocalBinder() + + /** + * Handle Service Binding + */ + override fun onBind(intent: Intent): IBinder { + return mBinder + } + + /** + * Start the Service in the Foreground + */ + fun startForeground(notificationBuilder: Builder, notificationId: Int) { + try { + ServiceCompat.startForeground( + this, + notificationId, + notificationBuilder + .build(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + 0 + }, + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to start foreground service", e) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + && e is ForegroundServiceStartNotAllowedException + ) { + Log.e(TAG, "Foreground service not allowed") + } + } + } + + /** + * Notify the User + */ + fun notify( + notificationBuilder: Builder, + notificationId: Int + ) { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify( + notificationId, + notificationBuilder.build() + ) + } + + /** + * Start the Liquid WebRTC Service + * + * This creates a SignalClient and connects to the Signal Server + */ + fun start( + url: String, + httpClient: OkHttpClient, + notificationBuilder: Builder, + notificationId: Int, + activityClass: Class + ) { + startForeground(notificationBuilder.setContentIntent(createPendingIntent(activityClass, 0)), notificationId) + val isInitialized = signalClient != null + if (isInitialized) { + signalClient?.disconnect() + } + signalClient = SignalClient(url, this@SignalService, httpClient) + } + + /** + * Stop the Liquid WebRTC Service + */ + fun stop() { + signalClient?.disconnect() + signalClient = null + } + + /** + * Connect to a Peer by Request ID + * + * @param dataChannels optional map of channel label -> [DataChannel.Init] to + * open (defaults to a single `liquid` channel). + * @param tracks optional local media tracks to add before negotiation. + * @param onTrack optional callback invoked when a remote media track arrives. + * @param onPresence optional callback for server-broadcast `presence` + * updates for the `requestId` room. + * @param onLinkError optional callback for signaling `exception` events + * (e.g. `link-error` room refusals). + * @param onConnectionStateChange optional callback for peer ICE connection + * state changes (`CONNECTED`, `DISCONNECTED`, `FAILED`, ...). + */ + suspend fun peer( + requestId: String, + type: String, + iceServers: List, + dataChannels: Map? = null, + tracks: List? = null, + onTrack: ((MediaStreamTrack) -> Unit)? = null, + onPresence: ((JSONObject) -> Unit)? = null, + onLinkError: ((JSONObject) -> Unit)? = null, + onConnectionStateChange: ((String) -> Unit)? = null, + ) { + // Register the socket/peer callbacks before negotiation so the socket + // listeners (presence/exception) are attached when it is created. + signalClient?.onPresence = onPresence + signalClient?.onLinkError = onLinkError + signalClient?.onConnectionStateChange = onConnectionStateChange + dataChannel = signalClient?.peer(requestId, type, iceServers, dataChannels, tracks) + peerClient = signalClient?.peerClient + peerClient?.onTrack = onTrack + peerConnection = peerClient?.peerConnection + } + + /** + * Abort an in-flight [peer] negotiation without stopping the service. + */ + fun cancel() { + signalClient?.cancel() + } + /** + * Create a PendingIntent + * + * This PendingIntent is used to open the given Activity when a transaction message is received + */ + fun createPendingIntent(activityClass: Class, requestCode: Int = 0, msg: String? = null): PendingIntent { + val answerIntent = Intent(this@SignalService, activityClass) + answerIntent.setFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + msg?.let { + answerIntent.putExtra("msg", it) + } + return TaskStackBuilder.create(this@SignalService).run { + addNextIntentWithParentStack(answerIntent) + getPendingIntent( + requestCode, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + } + /** + * Handle Messages and State Changes + * + * When the activity is visible, it will call back to the onMessage function. + * Otherwise, it will create a notification with a PendingIntent for the given Activity + */ + fun handleMessages( + activity: Activity, + onMessage: (label: String, msg: String) -> Unit, + onStateChange: ((label: String, state: String?) -> Unit)? = null, + notificationBuilder: Builder, + notificationId: Int = LIQUID_NOTIFICATION_ID, + activityClass: Class + ) { + var requestCode = 1 + val serviceIntentRequestCode = 0 + // Register observers on every negotiated data channel + signalClient?.handleDataChannels({ label, msg -> + if (activity.hasWindowFocus()) { + onMessage(label, msg) + return@handleDataChannels + } + Log.d(TAG, "DataChannel[$label] Message: $msg") + notify( + notificationBuilder + .setContentText(msg) + .setContentIntent(createPendingIntent(activityClass, requestCode, msg)), + notificationId + ) + requestCode += 1 + }, { label, state -> + if (state == "CLOSED" || state == "CLOSING") { + notify( + notificationBuilder + .setContentText("Tap to open the app.") + .setOnlyAlertOnce(true) + .setContentIntent(createPendingIntent(activityClass, serviceIntentRequestCode,null)) + , notificationId + ) + } + onStateChange?.invoke(label, state) + }) + } + + fun updateLastKnownReferer(referer: String?) { + lastKnownReferer = referer + } + + fun updateDeepLinkFlag(isDeepLink: Boolean) { + this.isDeepLink = isDeepLink + } + + /** + * Send a Message over the primary (`liquid`) channel + */ + fun send(msg: String) { + Log.d(TAG, "Sending: $msg from $lastKnownReferer") + peerClient?.send(msg) + } + + /** + * Send a Message over a specific named channel + */ + fun send(label: String, msg: String) { + Log.d(TAG, "Sending to [$label]: $msg from $lastKnownReferer") + peerClient?.send(label, msg) + } +} diff --git a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/VENDORED.md b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/VENDORED.md new file mode 100644 index 0000000..5c05993 --- /dev/null +++ b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/VENDORED.md @@ -0,0 +1,56 @@ +# Vendored native library base — Android signaling + +> Provenance note for the Liquid Auth consolidation (see +> `react-native-liquid-auth/docs/CONSOLIDATION_PLAN.md`, decision **D1**). + +This `foundation.algorand.auth.connect` package is a **vendored copy** of the +signaling / WebRTC stack from the original Android SDK. + +| | | +| --- | --- | +| **Upstream repo** | `algorandfoundation/liquid-auth-android` | +| **Upstream path** | `liquid/src/main/java/foundation/algorand/auth/connect/` | +| **Copied from commit** | `05b51f2609c15c8daa74848ce83f4a1fad397a85` (2026-03-07) | +| **Portion vendored** | Signaling only (`SignalService`, `SignalClient`, `PeerApi`, `AuthMessage`, `SignalInterface`, extensions) | + +## Sync direction + +**Upstream → vendored copy (one-way).** Edit the originals in +`liquid-auth-android` first, then sync the change *down* into this copy. Do not +treat this copy as the source of truth. + +## Local divergence + +This copy had evolved beyond the `05b51f2` upstream commit to support the React +Native binding (multiple named data channels, media-track surfacing, +channel-labeled message/state callbacks, `send(label, msg)`). + +**Divergence captured upstream (consolidation branch `chore/consolidation`).** +Those evolutions have now been back-ported *into the upstream originals* so the +originals and this vendored copy expose the same "top-level signal client" +contract (peer type, named data channels, channel-labeled callbacks, +channel-addressed send). The upstream SDK kept its own extras that this copy +does not need (Hilt `@Inject` DI on `SignalClient`, ML Kit `Barcode` parsing in +`AuthMessage`); those are intentionally **not** mirrored here. + +As a result the two trees are aligned again and future upstream → copy syncs no +longer have to re-derive the multi-channel / track additions. Keep the public +method/callback shapes aligned with the shared contract shared with the JS +client (`@algorandfoundation/liquid-client`) and the iOS SDK +(`liquid-auth-ios`). + +## Phase 2 additions (kept in sync, upstream → copy) + +The native API gaps the wallet needs (consolidation **Phase 2**) were added to +the upstream originals **and** mirrored here in the same change set, preserving +the one-way sync direction: + +- `SignalClient.onPresence` / `onLinkError` — forward the signaling socket's + `presence` broadcast and `exception` (link-error) events. +- `SignalClient.onConnectionStateChange` (wired to `PeerApi.onConnectionStateChange`) + — surface ICE connection-state changes. +- `SignalClient.cancel()` / `SignalService.cancel()` — abort an in-flight + negotiation (fails the pending continuation with a `CancellationException`). + +These are byte-for-byte the same additions in both trees; only the upstream's +`@Inject` constructor on `SignalClient` differs (not mirrored here). diff --git a/modules/react-native-liquid-auth/expo-module.config.json b/modules/react-native-liquid-auth/expo-module.config.json new file mode 100644 index 0000000..96b044e --- /dev/null +++ b/modules/react-native-liquid-auth/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["apple", "android", "web"], + "apple": { + "modules": ["LiquidAuthNativeModule"] + }, + "android": { + "modules": ["co.algorand.liquid.LiquidAuthNativeModule"] + } +} diff --git a/modules/react-native-liquid-auth/ios/LiquidAuthNative.podspec b/modules/react-native-liquid-auth/ios/LiquidAuthNative.podspec new file mode 100644 index 0000000..5b0b292 --- /dev/null +++ b/modules/react-native-liquid-auth/ios/LiquidAuthNative.podspec @@ -0,0 +1,31 @@ +Pod::Spec.new do |s| + s.name = 'LiquidAuthNative' + s.version = '1.0.0' + s.summary = 'React Native bindings for the Liquid Auth signaling service' + s.description = 'Native bindings that expose the Liquid Auth WebRTC signaling service to React Native / Expo apps.' + s.author = 'Algorand Foundation' + s.homepage = 'https://github.com/algorandfoundation/react-native-liquid-auth' + s.platforms = { + :ios => '16.4', + :tvos => '16.4' + } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + # Native dependencies for the vendored LiquidAuthSDK signaling stack + # (ported from liquid-auth-ios — see VENDORED.md). Socket.IO drives the + # signaling transport; WebRTC (stasel/WebRTC binary, module `WebRTC`) provides + # the peer connection / data channels. + s.dependency 'Socket.IO-Client-Swift' + s.dependency 'WebRTC-lib' + + # Swift/Objective-C compatibility + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_VERSION' => '5.9', + } + + # Includes both the module bindings and the vendored LiquidAuthSDK/ sources. + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/modules/react-native-liquid-auth/ios/LiquidAuthNativeModule.swift b/modules/react-native-liquid-auth/ios/LiquidAuthNativeModule.swift new file mode 100644 index 0000000..c7d860d --- /dev/null +++ b/modules/react-native-liquid-auth/ios/LiquidAuthNativeModule.swift @@ -0,0 +1,235 @@ +import ExpoModulesCore +import WebRTC + +/** + * iOS bindings for the Liquid Auth signaling service. + * + * Wraps the vendored `LiquidAuthSDK` signaling stack (ported from + * `liquid-auth-ios`, see `ios/VENDORED.md`) so wallets can drive the WebRTC + * signaling handshake from JavaScript. This mirrors the Android module's JS + * contract: `start`/`connect`/`cancel`/`send`/`sendToChannel`/`disconnect` + * plus the `onMessage`/`onStateChange`/`onTrack`/`onPresence`/`onLinkError`/ + * `onConnectionStateChange` events. + * + * Unlike Android there is no foreground `Service`; the shared + * `SignalService.shared` singleton owns the connection. Long-lived background + * execution on iOS is subject to platform constraints (see the consolidation + * plan's Phase 3 open question). + */ +public class LiquidAuthNativeModule: Module { + /// The signaling origin captured from `start(url:)`, reused as the + /// `connectToPeer` origin (iOS re-creates the socket per negotiation). + private var signalUrl: String? + /// The in-flight `connect` promise, resolved when the first channel opens and + /// rejected on link-error/abort. Only one negotiation is tracked at a time. + private var pendingConnect: Promise? + + public func definition() -> ModuleDefinition { + Name("LiquidAuthNative") + + Events( + "onMessage", + "onStateChange", + "onTrack", + "onPresence", + "onLinkError", + "onConnectionStateChange" + ) + + /** + * Generate a random request id. + */ + Function("generateRequestId") { () -> String in + UUID().uuidString + } + + /** + * Parse a `liquid:///?requestId=` URI into its `origin` and + * `requestId` parts. + */ + Function("parseMessage") { (value: String) -> [String: String] in + guard let parsed = Self.parseLiquidUri(value) else { + throw NSError( + domain: "LiquidAuthNative", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Invalid Liquid Auth URI: \(value)"] + ) + } + return ["origin": parsed.origin, "requestId": parsed.requestId] + } + + /** + * Start the signaling service and connect the signaling client to the given + * origin. The origin is reused by `connect`. + */ + AsyncFunction("start") { (url: String, promise: Promise) in + self.signalUrl = url + SignalService.shared.start(url: url, httpClient: URLSession.shared) + promise.resolve(nil) + } + + /** + * Connect to a remote peer by `requestId`. `type` is the remote peer type + * (`"offer"` or `"answer"`). Resolves when the primary data channel opens; + * rejects with `E_LINK_ERROR` on a refused link or `E_ABORTED` on cancel. + */ + AsyncFunction("connect") { ( + requestId: String, + type: String, + iceServers: [[String: Any]]?, + options: [String: Any]?, + promise: Promise + ) in + guard let peerType = LiquidAuthPeerType(rawValue: type) else { + promise.reject("E_INVALID_TYPE", "Invalid peer type '\(type)', expected 'offer' or 'answer'") + return + } + guard let origin = self.signalUrl else { + promise.reject("E_NOT_STARTED", "Signaling service not started, call start() first") + return + } + + self.pendingConnect = promise + let servers = Self.parseIceServers(iceServers) + let channels = Self.parseDataChannels(options) + + SignalService.shared.connectToPeer( + requestId: requestId, + type: peerType, + origin: origin, + iceServers: servers, + dataChannels: channels, + onMessage: { [weak self] channel, message in + self?.sendEvent("onMessage", ["channel": channel, "message": message]) + }, + onStateChange: { [weak self] channel, state in + var payload: [String: Any] = ["channel": channel] + if let state { payload["state"] = state } + self?.sendEvent("onStateChange", payload) + }, + onLinkError: { [weak self] error in + guard let self else { return } + var payload: [String: Any] = ["event": "link-error"] + if let reason = error.rawReason { payload["reason"] = reason } + if let requestId = error.requestId { payload["requestId"] = requestId } + if let message = error.message { payload["message"] = message } + self.sendEvent("onLinkError", payload) + if let pending = self.pendingConnect { + self.pendingConnect = nil + pending.reject("E_LINK_ERROR", error.errorDescription ?? "Link refused") + } + }, + onConnected: { [weak self] in + guard let self, let pending = self.pendingConnect else { return } + self.pendingConnect = nil + pending.resolve(nil) + }, + onPresence: { [weak self] presence in + self?.sendEvent("onPresence", presence) + }, + onConnectionStateChange: { [weak self] state in + self?.sendEvent("onConnectionStateChange", ["state": state]) + } + ) + } + + /** + * Abort an in-flight `connect` negotiation. The pending `connect` promise + * rejects with `E_ABORTED`. + */ + AsyncFunction("cancel") { (promise: Promise) in + SignalService.shared.cancel() + if let pending = self.pendingConnect { + self.pendingConnect = nil + pending.reject("E_ABORTED", "Connection aborted") + } + promise.resolve(nil) + } + + /** + * Send a message over the primary (`liquid`) data channel. + */ + Function("send") { (message: String) in + SignalService.shared.sendMessage(message) + } + + /** + * Send a message over a specific named data channel. + */ + Function("sendToChannel") { (channel: String, message: String) in + SignalService.shared.sendMessage(message, to: channel) + } + + /** + * Stop the signaling client and tear down the connection. + */ + AsyncFunction("disconnect") { (promise: Promise) in + SignalService.shared.stop() + if let pending = self.pendingConnect { + self.pendingConnect = nil + pending.reject("E_ABORTED", "Connection closed") + } + promise.resolve(nil) + } + } + + // MARK: - Parsing helpers + + /// Parse a `liquid:///?requestId=` URI into its origin + requestId. + private static func parseLiquidUri(_ uri: String) -> (origin: String, requestId: String)? { + guard let components = URLComponents(string: uri), + let host = components.host, + let requestId = components.queryItems?.first(where: { $0.name == "requestId" })?.value + else { + return nil + } + return (origin: host, requestId: requestId) + } + + /// Convert the JS ICE server descriptors into `RTCIceServer`s, defaulting to + /// a public STUN server when none are provided (mirrors the Android module). + private static func parseIceServers(_ iceServers: [[String: Any]]?) -> [RTCIceServer] { + guard let iceServers, !iceServers.isEmpty else { + return [RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"])] + } + return iceServers.compactMap { server in + let urls: [String] + if let single = server["urls"] as? String { + urls = [single] + } else if let many = server["urls"] as? [String] { + urls = many + } else { + return nil + } + if urls.isEmpty { return nil } + if let username = server["username"] as? String, + let credential = server["credential"] as? String { + return RTCIceServer(urlStrings: urls, username: username, credential: credential) + } + return RTCIceServer(urlStrings: urls) + } + } + + /// Convert the JS `options.dataChannels` map into `DataChannelConfig`s, + /// defaulting to a single `liquid` channel (mirrors the Android module). + private static func parseDataChannels(_ options: [String: Any]?) -> [String: DataChannelConfig] { + guard let raw = options?["dataChannels"] as? [String: Any], !raw.isEmpty else { + return DataChannelConfig.defaultChannels + } + var result: [String: DataChannelConfig] = [:] + for (label, value) in raw { + let config = value as? [String: Any] ?? [:] + let packetLifeTime = (config["maxPacketLifeTime"] as? NSNumber) + ?? (config["maxRetransmitTimeMs"] as? NSNumber) + result[label] = DataChannelConfig( + ordered: config["ordered"] as? Bool, + maxRetransmits: (config["maxRetransmits"] as? NSNumber)?.intValue, + maxPacketLifeTime: packetLifeTime?.intValue, + channelProtocol: config["protocol"] as? String, + negotiated: config["negotiated"] as? Bool, + channelId: (config["id"] as? NSNumber)?.intValue + ) + } + return result + } +} diff --git a/modules/react-native-liquid-auth/ios/LiquidAuthSDK/DataChannelConfig.swift b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/DataChannelConfig.swift new file mode 100644 index 0000000..ecc0317 --- /dev/null +++ b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/DataChannelConfig.swift @@ -0,0 +1,69 @@ +/* + * Copyright 2025 Algorand Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import WebRTC + +// MARK: - DataChannelConfig + +/// Configuration for a single named data channel. +/// +/// This mirrors the `RTCDataChannelInit` options accepted by the JavaScript +/// client's `SignalClient.peer()` (`options.dataChannels`) and the +/// `DataChannelInit` type exposed by `react-native-liquid-auth`, so the same +/// shared shape describes a channel on every platform. All fields are optional; +/// omitted values fall back to the WebRTC defaults. +public struct DataChannelConfig: Sendable, Equatable { + public var ordered: Bool? + public var maxRetransmits: Int? + public var maxPacketLifeTime: Int? + public var channelProtocol: String? + public var negotiated: Bool? + public var channelId: Int? + + public init( + ordered: Bool? = nil, + maxRetransmits: Int? = nil, + maxPacketLifeTime: Int? = nil, + channelProtocol: String? = nil, + negotiated: Bool? = nil, + channelId: Int? = nil + ) { + self.ordered = ordered + self.maxRetransmits = maxRetransmits + self.maxPacketLifeTime = maxPacketLifeTime + self.channelProtocol = channelProtocol + self.negotiated = negotiated + self.channelId = channelId + } + + /// The single `liquid` data channel opened when no channels are supplied, + /// preserving the previous single-channel behaviour. + public static let defaultChannels: [String: DataChannelConfig] = ["liquid": DataChannelConfig()] + + /// Build an `RTCDataChannelConfiguration` from this shared shape, applying + /// only the fields that were explicitly provided. + func toRTCConfiguration() -> RTCDataChannelConfiguration { + let config = RTCDataChannelConfiguration() + if let ordered { config.isOrdered = ordered } + if let maxRetransmits { config.maxRetransmits = Int32(maxRetransmits) } + if let maxPacketLifeTime { config.maxPacketLifeTime = Int32(maxPacketLifeTime) } + if let channelProtocol { config.`protocol` = channelProtocol } + if let negotiated { config.isNegotiated = negotiated } + if let channelId { config.channelId = Int32(channelId) } + return config + } +} diff --git a/modules/react-native-liquid-auth/ios/LiquidAuthSDK/DataChannelDelegate.swift b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/DataChannelDelegate.swift new file mode 100644 index 0000000..0a5bebb --- /dev/null +++ b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/DataChannelDelegate.swift @@ -0,0 +1,82 @@ +/* + * Copyright 2025 Algorand Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import WebRTC + +// MARK: - DataChannelDelegate + +class DataChannelDelegate: NSObject, RTCDataChannelDelegate { + private let onMessage: (String) -> Void + private let onStateChange: (String?) -> Void? + private let onBufferedAmountChange: ((UInt64) -> Void)? + private let onChannelAvailable: ((RTCDataChannel) -> Void)? + private weak var signalService: SignalService? + + init( + signalService: SignalService?, + onMessage: @escaping (String) -> Void, + onStateChange: ((String?) -> Void)? = nil, + onBufferedAmountChange: ((UInt64) -> Void)? = nil, + onChannelAvailable: ((RTCDataChannel) -> Void)? = nil + ) { + self.signalService = signalService + self.onMessage = onMessage + self.onStateChange = onStateChange! + self.onBufferedAmountChange = onBufferedAmountChange + self.onChannelAvailable = onChannelAvailable + } + + func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) { + // Ensure signalService.dataChannel is set to the active channel + if let service = signalService, service.dataChannel !== dataChannel { + Logger + .debug( + "DataChannelDelegate: Setting signalService.dataChannel from " + + "didReceiveMessageWith: \(ObjectIdentifier(dataChannel))" + ) + service.dataChannel = dataChannel + } + onChannelAvailable?(dataChannel) + if let message = String(data: buffer.data, encoding: .utf8) { + Logger.debug("💬 DataChannel: Received message: \(message) on channel: \(ObjectIdentifier(dataChannel))") + onMessage(message) + } + } + + func dataChannel(_: RTCDataChannel, didChangeBufferedAmount amount: UInt64) { + onBufferedAmountChange?(amount) + } + + func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) { + let state = dataChannel.readyState.description + if state == "open" { + Logger.info("✅ DataChannel: State changed to OPEN") + } + onStateChange(state) + } +} + +extension RTCDataChannelState { + var description: String { + switch self { + case .connecting: return "connecting" + case .open: return "open" + case .closing: return "closing" + case .closed: return "closed" + @unknown default: return "unknown" + } + } +} diff --git a/modules/react-native-liquid-auth/ios/LiquidAuthSDK/LinkError.swift b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/LinkError.swift new file mode 100644 index 0000000..dba99dd --- /dev/null +++ b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/LinkError.swift @@ -0,0 +1,81 @@ +/* + * Copyright 2025 Algorand Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +// MARK: - LinkErrorReason + +/// The reason a `link` request was refused by the signaling server. +/// +/// Mirrors the `LinkErrorReason` union in the JavaScript client +/// (`@algorandfoundation/liquid-client` `errors.ts`). The raw values match the +/// strings the server sends on the wire. +public enum LinkErrorReason: String, Sendable, Equatable { + case roomFull = "room-full" + case duplicateAdmin = "duplicate-admin" + case duplicatePeer = "duplicate-peer" + + /// Parse a wire/string reason, falling back to `.roomFull` semantics is + /// intentionally *not* done: unknown reasons return `nil` so callers can + /// decide how to treat an unrecognised refusal. + public init?(rawString: String) { + self.init(rawValue: rawString) + } +} + +// MARK: - LinkError + +/// A typed error raised when the signaling server refuses a `link` request +/// (e.g. the room is full or a duplicate peer/admin tried to join). +/// +/// Mirrors the `LinkError` class in the JavaScript client so the same shared +/// shape — a `reason` plus the offending `requestId` — describes a refused link +/// on every platform. Callers can fast-fail on this instead of waiting out a +/// negotiation timeout and misclassifying the peer as offline. +public struct LinkError: Error, LocalizedError, Equatable { + /// The machine-readable refusal reason, when the server supplied a known one. + public let reason: LinkErrorReason? + /// The raw reason string as received on the wire (preserved even when it is + /// not one of the known `LinkErrorReason` cases). + public let rawReason: String? + /// The `requestId` the refusal applies to, when provided. + public let requestId: String? + /// A human-readable message, when the server supplied one. + public let message: String? + + public init( + reason: LinkErrorReason?, + rawReason: String? = nil, + requestId: String? = nil, + message: String? = nil + ) { + self.reason = reason + self.rawReason = rawReason ?? reason?.rawValue + self.requestId = requestId + self.message = message + } + + public var errorDescription: String? { + if let message, !message.isEmpty { + return message + } + let reasonText = rawReason ?? reason?.rawValue ?? "unknown" + if let requestId { + return "Link refused (\(reasonText)) for requestId \(requestId)" + } + return "Link refused (\(reasonText))" + } +} diff --git a/modules/react-native-liquid-auth/ios/LiquidAuthSDK/LiquidAuthError.swift b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/LiquidAuthError.swift new file mode 100644 index 0000000..eb2d661 --- /dev/null +++ b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/LiquidAuthError.swift @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Algorand Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +public enum LiquidAuthError: Error, LocalizedError { + case invalidURL(String) + case invalidJSON(String) + case networkError(Error) + case authenticationFailed(String) + case signingFailed(Error) + case invalidChallenge + case missingRequiredField(String) + case serverError(String) + case userCanceled + + public var errorDescription: String? { + switch self { + case let .invalidURL(url): + "Invalid URL: \(url)" + case let .invalidJSON(context): + "Invalid JSON: \(context)" + case let .networkError(error): + "Network error: \(error.localizedDescription)" + case let .authenticationFailed(reason): + "Authentication failed: \(reason)" + case let .signingFailed(error): + "Signing failed: \(error.localizedDescription)" + case .invalidChallenge: + "Invalid challenge received" + case let .missingRequiredField(field): + "Missing required field: \(field)" + case let .serverError(message): + "Server error: \(message)" + case .userCanceled: + "Operation was canceled by user" + } + } +} diff --git a/modules/react-native-liquid-auth/ios/LiquidAuthSDK/LiquidAuthPeerType.swift b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/LiquidAuthPeerType.swift new file mode 100644 index 0000000..2342657 --- /dev/null +++ b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/LiquidAuthPeerType.swift @@ -0,0 +1,41 @@ +/* + * Copyright 2025 Algorand Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +// MARK: - LiquidAuthPeerType + +/// The type of the *remote* peer we are connecting to. +/// +/// This mirrors the `'offer' | 'answer'` union used by the JavaScript client +/// (`@algorandfoundation/liquid-client`) and the `react-native-liquid-auth` +/// binding (`LiquidAuthPeerType`), so the same shared shape describes a peer +/// across every platform. The raw values match the strings emitted on the wire. +/// +/// - `answer`: the local device creates the offer (acts as the offerer). +/// - `offer`: the local device waits for the offer and answers it. +public enum LiquidAuthPeerType: String, Sendable, Equatable { + case offer + case answer + + /// Parse a wire/string peer type into the shared enum. + /// + /// Returns `nil` for any value that is not one of the two known peer types, + /// so callers can reject malformed input instead of silently guessing. + public init?(rawString: String) { + self.init(rawValue: rawString) + } +} diff --git a/modules/react-native-liquid-auth/ios/LiquidAuthSDK/Logger.swift b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/Logger.swift new file mode 100644 index 0000000..bbe3944 --- /dev/null +++ b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/Logger.swift @@ -0,0 +1,58 @@ +/* + * Copyright 2025 Algorand Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +// MARK: - LogLevel + +public enum LogLevel: Int { + case error = 0 + case info = 1 + case debug = 2 +} + +// MARK: - Logger + +public enum Logger { + public static var currentLevel: LogLevel = .info + + /// Logs an error message + /// + /// - Parameter message: The error message to log + public static func error(_ message: String) { + if currentLevel.rawValue >= LogLevel.error.rawValue { + NSLog("❌ [ERROR] %@", message) + } + } + + /// Logs an informational message + /// + /// - Parameter message: The info message to log + public static func info(_ message: String) { + if currentLevel.rawValue >= LogLevel.info.rawValue { + NSLog("ℹ️ [INFO] %@", message) + } + } + + /// Logs a debug message + /// + /// - Parameter message: The debug message to log + public static func debug(_ message: String) { + if currentLevel.rawValue >= LogLevel.debug.rawValue { + NSLog("🐞 [DEBUG] %@", message) + } + } +} diff --git a/modules/react-native-liquid-auth/ios/LiquidAuthSDK/PeerApi.swift b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/PeerApi.swift new file mode 100644 index 0000000..1fef04e --- /dev/null +++ b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/PeerApi.swift @@ -0,0 +1,347 @@ +/* + * Copyright 2025 Algorand Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import WebRTC + +// MARK: - PeerApi + +class PeerApi { + private let peerConnectionFactory: RTCPeerConnectionFactory + var peerConnection: RTCPeerConnection? + private var peerConnectionDelegate: PeerConnectionDelegate? + /// The primary (first-created) local data channel — kept for the + /// convenience `send(_:)` that targets the primary channel. + private var dataChannel: RTCDataChannel? + /// All locally-created data channels, keyed by label, so callers can route + /// a message to a specific named channel (e.g. `ac2-v1` / `ac2-stream`). + private var namedDataChannels: [String: RTCDataChannel] = [:] + private let onDataChannel: (RTCDataChannel) -> Void + private var dataChannelDelegates: [RTCDataChannel: DataChannelDelegate] = [:] + private weak var signalService: SignalService? + /// Invoked when the peer connection's ICE connection state changes + /// (e.g. `CONNECTED`, `DISCONNECTED`, `FAILED`). Lets consumers monitor + /// connectivity without a direct handle on the `RTCPeerConnection`, mirroring + /// `PeerApi.onConnectionStateChange` in the Android SDK. + var onConnectionStateChange: ((String) -> Void)? + + init( + iceServers: [RTCIceServer], + poolSize: Int, + signalService: SignalService?, + onDataChannel: @escaping (RTCDataChannel) -> Void, + onIceCandidate: @escaping (RTCIceCandidate) -> Void + ) { + self.signalService = signalService + self.onDataChannel = onDataChannel + // Initialize the PeerConnectionFactory + RTCPeerConnectionFactory.initialize() + peerConnectionFactory = RTCPeerConnectionFactory() + + // Create the PeerConnection configuration + let configuration = RTCConfiguration() + configuration.iceServers = iceServers + configuration.iceCandidatePoolSize = Int32(poolSize) + configuration.sdpSemantics = .unifiedPlan + configuration.continualGatheringPolicy = .gatherContinually + + let delegate = PeerConnectionDelegate( + onIceCandidate: onIceCandidate, + onDataChannel: onDataChannel, + onConnectionStateChange: { state in + Logger.debug("PeerAPI: Peer connection state changed: \(state.rawValue)") + }, + onIceConnectionStateChange: { [weak self] state in + self?.onConnectionStateChange?(state.stateDescription) + } + ) + + // Create the PeerConnection + let constraints = RTCMediaConstraints( + mandatoryConstraints: ["OfferToReceiveAudio": "false", "OfferToReceiveVideo": "false"], + optionalConstraints: ["DtlsSrtpKeyAgreement": "true"] + ) + + peerConnectionDelegate = delegate + peerConnection = peerConnectionFactory.peerConnection( + with: configuration, + constraints: constraints, + delegate: delegate + ) + } + + // Create a new Peer Connection + func createPeerConnection( + onIceCandidate: @escaping (RTCIceCandidate) -> Void, + onDataChannel: @escaping (RTCDataChannel) -> Void, + onConnectionStateChange: @escaping (RTCPeerConnectionState) -> Void, + iceServers: [RTCIceServer] + ) { + let configuration = RTCConfiguration() + configuration.iceServers = iceServers + configuration.sdpSemantics = .unifiedPlan + configuration.continualGatheringPolicy = .gatherContinually + + let constraints = RTCMediaConstraints( + mandatoryConstraints: ["OfferToReceiveAudio": "false", "OfferToReceiveVideo": "false"], + optionalConstraints: ["DtlsSrtpKeyAgreement": "true"] + ) + peerConnection = peerConnectionFactory.peerConnection( + with: configuration, + constraints: constraints, + delegate: PeerConnectionDelegate( + onIceCandidate: onIceCandidate, + onDataChannel: onDataChannel, + onConnectionStateChange: onConnectionStateChange + ) + ) + } + + // Add an ICE Candidate + func addIceCandidate(_ candidate: RTCIceCandidate) throws { + guard let peerConnection else { + throw NSError( + domain: "PeerApi", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "PeerConnection is null, ensure you are connected"] + ) + } + peerConnection.add(candidate, completionHandler: { error in + if let error { + Logger.error("PeerAPI: addIceCandidate: Failed to add ICE candidate: \(error)") + } else { + Logger.debug("PeerAPI: addIceCandidate: ICE candidate added successfully.") + } + }) + } + + // Set the Local Description + func setLocalDescription(_ description: RTCSessionDescription, completion: @escaping (Error?) -> Void) { + guard let peerConnection else { + Logger.error("PeerAPI: PeerConnection is null, ensure you are connected") + return + } + Logger.debug("PeerAPI: Setting local description: \(description.type.rawValue)") + peerConnection.setLocalDescription(description, completionHandler: completion) + } + + func setRemoteDescription(_ description: RTCSessionDescription, completion: @escaping (Error?) -> Void) { + guard let peerConnection else { + Logger.error("PeerAPI: PeerConnection is null, ensure you are connected") + return + } + + if peerConnection.signalingState == .haveLocalOffer && description.type == .offer { + Logger + .error("PeerAPI: PeerAPI setRemoteDescription: Cannot set remote offer while in have-local-offer state") + return + } + + Logger.debug("PeerAPI: Setting remote description: \(description.type.rawValue)") + peerConnection.setRemoteDescription(description, completionHandler: completion) + } + + // Create an Offer + func createOffer(completion: @escaping (RTCSessionDescription?) -> Void) { + guard let peerConnection else { + Logger.error("PeerAPI: PeerConnection is null, ensure you are connected") + completion(nil) + return + } + peerConnection.offer(for: RTCMediaConstraints( + mandatoryConstraints: ["OfferToReceiveAudio": "false", "OfferToReceiveVideo": "false"], + optionalConstraints: ["DtlsSrtpKeyAgreement": "true"] + )) { sdp, error in + if let error { + Logger.error("PeerAPI: Failed to create offer: \(error)") + completion(nil) + } else { + completion(sdp) + } + } + } + + // Create an Answer + func createAnswer(completion: @escaping (RTCSessionDescription?) -> Void) { + guard let peerConnection else { + Logger.error("PeerAPI: PeerConnection is null, ensure you are connected") + return + } + peerConnection.answer(for: RTCMediaConstraints( + mandatoryConstraints: ["OfferToReceiveAudio": "false", "OfferToReceiveVideo": "false"], + optionalConstraints: ["DtlsSrtpKeyAgreement": "true"] + )) { sdp, error in + if let error { + Logger.error("PeerAPI: Failed to create answer: \(error)") + completion(nil) + } else { + completion(sdp) + } + } + } + + // Create a Data Channel + func createDataChannel( + label: String, + config: DataChannelConfig = DataChannelConfig(), + onMessage: @escaping (String) -> Void, + onStateChange: @escaping (String?) -> Void + ) -> RTCDataChannel? { + Logger.debug("PeerAPI: Creating data channel with label: \(label)") + let channel = peerConnection?.dataChannel(forLabel: label, configuration: config.toRTCConfiguration()) + + if let channel { + let delegate = DataChannelDelegate( + signalService: signalService, + onMessage: onMessage, + onStateChange: onStateChange + ) + channel.delegate = delegate + dataChannelDelegates[channel] = delegate + namedDataChannels[label] = channel + // The first channel created becomes the primary channel targeted by + // the convenience `send(_:)`. + if dataChannel == nil { + dataChannel = channel + } + Logger.debug("PeerApi: DataChannelDelegate assigned to data channel: \(channel.label)") + } + + return channel + } + + // Send a message through the primary Data Channel + func send(_ message: String) { + guard let dataChannel else { + Logger.error("PeerAPI: peerApi: Data channel is not available.") + return + } + + let buffer = RTCDataBuffer(data: message.data(using: .utf8)!, isBinary: false) + dataChannel.sendData(buffer) + } + + // Send a message through a specific named Data Channel + func send(_ message: String, to label: String) { + guard let channel = namedDataChannels[label] else { + Logger.error("PeerAPI: peerApi: Data channel '\(label)' is not available.") + return + } + + let buffer = RTCDataBuffer(data: message.data(using: .utf8)!, isBinary: false) + channel.sendData(buffer) + } + + // Close the Peer Connection + func close() { + for channel in namedDataChannels.values { + channel.close() + } + dataChannel?.close() + peerConnection?.close() + namedDataChannels.removeAll() + dataChannel = nil + peerConnection = nil + } +} + +// MARK: - PeerConnectionDelegate + +// Delegate to handle PeerConnection events +class PeerConnectionDelegate: NSObject, RTCPeerConnectionDelegate { + private let onIceCandidate: (RTCIceCandidate) -> Void + private let onDataChannel: (RTCDataChannel) -> Void + private let onConnectionStateChange: (RTCPeerConnectionState) -> Void + private let onIceConnectionStateChange: ((RTCIceConnectionState) -> Void)? + + init( + onIceCandidate: @escaping (RTCIceCandidate) -> Void, + onDataChannel: @escaping (RTCDataChannel) -> Void, + onConnectionStateChange: @escaping (RTCPeerConnectionState) -> Void, + onIceConnectionStateChange: ((RTCIceConnectionState) -> Void)? = nil + ) { + Logger.debug("PeerAPI: PeerConnectionDelegate initialized") + self.onIceCandidate = onIceCandidate + self.onDataChannel = onDataChannel + self.onConnectionStateChange = onConnectionStateChange + self.onIceConnectionStateChange = onIceConnectionStateChange + } + + func peerConnection(_: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) { + Logger.debug("PeerAPI: Data channel opened: \(dataChannel.label)") + onDataChannel(dataChannel) + } + + func peerConnection(_: RTCPeerConnection, didAdd stream: RTCMediaStream) { + Logger.debug("PeerAPI: Media stream added: \(stream)") + } + + func peerConnection(_: RTCPeerConnection, didRemove stream: RTCMediaStream) { + Logger.debug("PeerAPI: Media stream removed: \(stream)") + } + + func peerConnectionShouldNegotiate(_: RTCPeerConnection) { + Logger.debug("PeerAPI: Renegotiation needed") + } + + func peerConnection(_: RTCPeerConnection, didChange newState: RTCIceConnectionState) { + Logger.debug("PeerAPI: ICE connection state changed: \(newState)") + onIceConnectionStateChange?(newState) + } + + func peerConnection(_: RTCPeerConnection, didChange newState: RTCIceGatheringState) { + Logger.debug("PeerAPI: ICE gathering state changed: \(newState)") + } + + func peerConnection(_: RTCPeerConnection, didChange stateChanged: RTCSignalingState) { + Logger.debug("PeerAPI: ICE signaling state changed: \(stateChanged)") + } + + func peerConnection(_: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) { + Logger.debug("PeerAPI: ICE candidate: \(candidate)") + onIceCandidate(candidate) + } + + func peerConnection(_: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) { + Logger.debug("PeerAPI: ICE candidates removed: \(candidates)") + } + + func peerConnection(_: RTCPeerConnection, didChange newState: RTCPeerConnectionState) { + Logger.debug("PeerAPI: Peer connection state changed: \(newState.rawValue)") + onConnectionStateChange(newState) + } +} + +// MARK: - RTCIceConnectionState description + +extension RTCIceConnectionState { + /// Uppercase state name matching the Android SDK's + /// `IceConnectionState.toString()` (`NEW`/`CHECKING`/`CONNECTED`/...), so + /// the `onConnectionStateChange` payload is identical across platforms. + var stateDescription: String { + switch self { + case .new: return "NEW" + case .checking: return "CHECKING" + case .connected: return "CONNECTED" + case .completed: return "COMPLETED" + case .failed: return "FAILED" + case .disconnected: return "DISCONNECTED" + case .closed: return "CLOSED" + case .count: return "COUNT" + @unknown default: return "UNKNOWN" + } + } +} diff --git a/modules/react-native-liquid-auth/ios/LiquidAuthSDK/SignalClient.swift b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/SignalClient.swift new file mode 100644 index 0000000..ef78cc5 --- /dev/null +++ b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/SignalClient.swift @@ -0,0 +1,581 @@ +/* + * Copyright 2025 Algorand Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import CoreImage +import SocketIO +import WebRTC + +// MARK: - SignalClient + +public class SignalClient { + private let manager: SocketManager + private let socket: SocketIOClient + weak var service: SignalService? + private var sdpHandler: ((String) -> Void)? + var peerClient: PeerApi? + private var candidatesBuffer: [RTCIceCandidate] = [] + private var eventQueue: [(String, QueuedEventData)] = [] + private var dataChannelDelegates: [RTCDataChannel: DataChannelDelegate] = [:] + private var onLinkError: ((LinkError) -> Void)? + var onSocketConnected: (() -> Void)? + /// Server-broadcast `presence` updates for the current `requestId` room + /// (`{ requestId, deviceCount, online }`). Set before ``connectToPeer`` so + /// the socket listener is registered when the socket is created. Mirrors + /// `SignalClient.onPresence` in the Android SDK. + var onPresence: (([String: Any]) -> Void)? + /// Forwarded to ``PeerApi/onConnectionStateChange`` when the peer is created, + /// so callers can observe ICE connection state without a native handle. + var onConnectionStateChange: ((String) -> Void)? + + init(url: String, service: SignalService) { + self.service = service + + // Initialize the Socket.IO manager and client + manager = SocketManager(socketURL: URL(string: "https://\(url)")!, config: [.log(false), .compress]) + socket = manager.defaultSocket + + // Set up event listeners + setupSocketListeners() + } + + // swiftlint:disable:next function_body_length + public func connectToPeer( + requestId: String, + type: LiquidAuthPeerType, + iceServers: [RTCIceServer], + dataChannels: [String: DataChannelConfig] = DataChannelConfig.defaultChannels, + onDataChannelOpen: @escaping (RTCDataChannel) -> Void, + onMessage: @escaping (String, String) -> Void, + onStateChange: @escaping (String, String?) -> Void, + onLinkError: ((LinkError) -> Void)? = nil + ) -> RTCDataChannel? { + // Clean up any existing peer connection + peerClient?.close() + peerClient = nil + + self.onLinkError = onLinkError + installLinkErrorListeners(requestId: requestId) + + Logger.debug("SignalClient: Attempting to connect to peer with requestId: \(requestId), type: \(type.rawValue)") + + peerClient = PeerApi( + iceServers: iceServers, + poolSize: 10, + signalService: service, + onDataChannel: { [weak self] dataChannel in + Logger.debug("SignalClient: onDataChannel called with: \(dataChannel.label)") + Logger.debug("Received data channel from remote peer: \(dataChannel.label)") + let label = dataChannel.label + let delegate = DataChannelDelegate( + signalService: self?.service, + onMessage: { message in + Logger.info("💬 SignalClient: Received message on \(label): \(message)") + onMessage(label, message) + }, + onStateChange: { state in + Logger.debug("SignalClient: Data channel \(label) state changed: \(state ?? "unknown")") + onStateChange(label, state) + if state == "open" { + Logger.info("✅ SignalClient: Open and ready: \(label)") + Logger + .debug( + "SignalService: Setting dataChannel to " + + "\(ObjectIdentifier(dataChannel)) label: \(label)" + ) + onDataChannelOpen(dataChannel) + } + }, + onChannelAvailable: { [weak self] channel in + if self?.service?.dataChannel !== channel { + Logger + .debug( + "SignalClient: Setting dataChannel from " + + "didReceiveMessageWith: \(ObjectIdentifier(channel))" + ) + self?.service?.dataChannel = channel + } + } + ) + dataChannel.delegate = delegate + self?.dataChannelDelegates[dataChannel] = delegate + Logger.debug("SignalClient: DataChannelDelegate assigned to remote data channel: \(label)") + + if dataChannel.readyState == .open { + Logger.info("✅ SignalClient: Open and ready (immediate): \(label)") + Logger + .debug( + "SignalService: Setting dataChannel to " + + "\(ObjectIdentifier(dataChannel)) label: \(label)" + ) + onDataChannelOpen(dataChannel) + } + }, + onIceCandidate: { [weak self] candidate in + guard let self else { return } + Logger.debug("Generated ICE candidate: \(candidate)") + let candidateEvent = (type == .offer) ? "answer-candidate" : "offer-candidate" + send(event: candidateEvent, data: [ + "candidate": candidate.sdp, + "sdpMid": candidate.sdpMid ?? "", + "sdpMLineIndex": candidate.sdpMLineIndex, + ]) + } + ) + + peerClient?.onConnectionStateChange = onConnectionStateChange + + if peerClient?.peerConnection != nil { + Logger.info("SignalClient: Peer connection created successfully.") + } else { + Logger.error("SignalClient: Failed to create peer connection!") + } + + if type == .answer { + // Initiator logic (creates and sends offer) + Logger.info("Answer (initiator): sending link request") + send(event: "link", data: ["requestId": requestId]) + + guard let peerClient, peerClient.peerConnection != nil else { + Logger.error("PeerClient or its peerConnection is nil!") + return nil + } + + // Open every requested named data channel (defaulting to a single + // `liquid` channel), mirroring `SignalClient.peer()`'s + // `options.dataChannels` map in the JavaScript client. The `liquid` + // channel (or the first one created) is returned as the primary. + let channels = dataChannels.isEmpty ? DataChannelConfig.defaultChannels : dataChannels + var primaryChannel: RTCDataChannel? + for (label, config) in channels { + let channel = peerClient.createDataChannel( + label: label, + config: config, + onMessage: { message in onMessage(label, message) }, + onStateChange: { state in onStateChange(label, state) } + ) + if label == "liquid" || primaryChannel == nil { + primaryChannel = channel + } + } + + peerClient.createOffer { offer in + guard let offer else { + Logger.error("Failed to create offer: Offer is nil") + return + } + Logger.info("Answer (initiator): Setting local description") + peerClient.setLocalDescription(offer) { error in + if let error { + Logger.error("Failed to set local description: \(error)") + } else { + Logger.debug("Answer (initiator): Sending offer description") + self.send(event: "offer-description", sdp: offer.sdp) + } + } + } + return primaryChannel + } else if type == .offer { + // Responder logic (waits for offer, then sends answer) + Logger.info("Offer (responder): Waiting for remote offer") + send(event: "link", data: ["requestId": requestId]) + + // Listen for the offer-description event (only for responder) + socket.off("offer-description") + socket.on("offer-description") { [weak self] data, _ in + guard let self, let eventData = data.first as? [String: Any], + let sdp = eventData["sdp"] as? String, + let type = sdpType(from: eventData["type"] as? String) else { return } + Logger.info("Offer (responder): Received SDP type: \(type) : \(sdp)") + let sessionDescription = RTCSessionDescription(type: type, sdp: sdp) + + peerClient?.setRemoteDescription(sessionDescription, completion: { error in + if let error { + Logger.error("Failed to set remote description: \(error)") + } else { + Logger.info("Offer (responder): Remote description set successfully.") + + self.peerClient?.createAnswer { answer in + guard let answer else { + Logger.error("Failed to create answer: Answer is nil") + return + } + Logger.info("Offer (responder): Setting local description") + self.peerClient?.setLocalDescription(answer) { error in + if let error { + Logger.error("Failed to set local description: \(error)") + } else { + Logger.info("Offer (responder): Sending answer description") + self + .send(event: "answer-description", + sdp: answer + .sdp) // ["type": stringFromSdpType(answer.type), "sdp": answer.sdp]) + } + } + } + } + }) + } + return nil + } + return nil + } + + // MARK: - Connect to the Socket.IO Server + + func connectSocket() { + if socket.status != .connected { + Logger.debug("Socket is not connected. Attempting to connect...") + socket.connect() + } else { + Logger.debug("Socket is already connected.") + } + } + + func disconnectSocket() { + socket.disconnect() + handleDisconnect() + } + + /// Abort an in-flight negotiation: tear the peer connection down and + /// disconnect the socket so the caller unblocks promptly. Mirrors + /// `SignalClient.cancel()` in the Android SDK. + func cancel() { + peerClient?.close() + peerClient = nil + socket.disconnect() + } + + private func handleDisconnect() { + Logger.debug("Handling Socket.IO disconnection...") + peerClient?.close() + peerClient = nil + } + + // MARK: - Link Error Handling + + /// Listen for a refused `link` so callers can fast-fail with a typed + /// ``LinkError`` instead of waiting out a negotiation timeout. Mirrors the + /// JavaScript client, which races the `link` ack against a `requestId`-scoped + /// `exception` carrying `event: "link-error"`. + private func installLinkErrorListeners(requestId: String) { + socket.off("link-error") + socket.on("link-error") { [weak self] data, _ in + guard let self, let eventData = data.first as? [String: Any] else { return } + Logger.error("SignalClient: Received link-error: \(eventData)") + handleLinkError(eventData, expectedRequestId: requestId) + } + + socket.off("exception") + socket.on("exception") { [weak self] data, _ in + guard let self, let eventData = data.first as? [String: Any] else { return } + // The server delivers a refusal as a generic `exception` carrying + // `event: "link-error"`; filter the same shape the JS client does. + guard (eventData["event"] as? String) == "link-error" else { return } + Logger.error("SignalClient: Received link-error via exception: \(eventData)") + handleLinkError(eventData, expectedRequestId: requestId) + } + } + + private func handleLinkError(_ data: [String: Any], expectedRequestId: String) { + let rawReason = data["reason"] as? String + let receivedRequestId = data["requestId"] as? String + // Only surface refusals scoped to this negotiation's requestId (or those + // that omit it), matching the JS client's requestId-scoped filtering. + if let receivedRequestId, receivedRequestId != expectedRequestId { return } + let message = data["message"] as? String + let error = LinkError( + reason: rawReason.flatMap { LinkErrorReason(rawValue: $0) }, + rawReason: rawReason, + requestId: receivedRequestId ?? expectedRequestId, + message: message + ) + onLinkError?(error) + } + + // MARK: - Send Data Channel Messages + + /// Send a string over the primary (`liquid`) data channel. + func sendData(_ message: String) { + peerClient?.send(message) + } + + /// Send a string over a specific named data channel. + func sendData(_ message: String, to label: String) { + peerClient?.send(message, to: label) + } + + // MARK: - Set Up Socket.IO Listeners + + private func setupSocketListeners() { + socket.on(clientEvent: .connect) { _, _ in + Logger.debug("Socket.IO connected") + self.onSocketConnected?() + self.processEventQueue() + } + + socket.on(clientEvent: .disconnect) { _, _ in + Logger.debug("Socket.IO disconnected") + self.handleDisconnect() + } + + if service?.currentPeerType == .offer { + socket.on("offer-description") { [weak self] data, _ in + guard let self, let eventData = data.first as? [String: Any] else { return } + Logger.debug("Received SDP offer: \(eventData)") + handleOfferDescription(eventData) + } + } + + socket.on("answer-description") { [weak self] data, _ in + guard let self else { return } + // Try to handle as dictionary first, then as string + if let eventData = data.first as? [String: Any] { + Logger.debug("Received SDP answer as dictionary: \(eventData)") + handleAnswerDescription(eventData) + } else if let sdp = data.first as? String { + Logger.debug("Received SDP answer as string: \(sdp)") + handleAnswerDescription(sdp) + } else { + Logger.error("Received SDP answer in unknown format: \(data)") + } + } + + socket.on("candidate") { [weak self] data, _ in + guard let self, let eventData = data.first as? [String: Any] else { return } + Logger.debug("Received ICE candidate: \(eventData)") + handleIceCandidate(eventData) + } + + socket.on("offer-candidate") { [weak self] data, _ in + guard let self, let eventData = data.first as? [String: Any] else { return } + Logger.debug("Received offer ICE candidate: \(eventData)") + handleIceCandidate(eventData) + } + socket.on("answer-candidate") { [weak self] data, _ in + guard let self, let eventData = data.first as? [String: Any] else { return } + Logger.debug("Received answer ICE candidate: \(eventData)") + handleIceCandidate(eventData) + } + + socket.on("presence") { [weak self] data, _ in + guard let self, let eventData = data.first as? [String: Any] else { return } + Logger.debug("Received presence: \(eventData)") + onPresence?(eventData) + } + + socket.on("link-response") { data, _ in + Logger.debug("Received link response: \(data)") + } + + socket.on("error") { data, _ in + Logger.error("Socket.IO error: \(data)") + } + } + + // MARK: - Handle WebSocket Messages + + private func handleOfferDescription(_ data: [String: Any]) { + guard let sdp = data["sdp"] as? String, + let type = sdpType(from: data["type"] as? String) + else { + Logger.error("Received SDP is missing or invalid.") + return + } + + Logger.debug("handleOfferDescription: Received SDP: \(type) : \(sdp)") + let sessionDescription = RTCSessionDescription(type: type, sdp: sdp) + + if peerClient?.peerConnection?.signalingState == .haveLocalOffer { + Logger.error("HandleOfferDescription: cannot set remote offer while in have-local-offer state") + return + } + + Logger.debug("Setting remote description with session description: \(sessionDescription)") + + peerClient?.setRemoteDescription(sessionDescription, completion: { error in + if let error { + Logger.error("Failed to set remote description: \(error)") + } else { + Logger.debug("Remote description set successfully.") + self.processBufferedCandidates() + self.peerClient?.createAnswer { answer in + guard let answer else { + Logger.error("Failed to create answer: Answer is nil") + return + } + self.peerClient?.setLocalDescription(answer) { error in + if let error { + Logger.error("Failed to set local description: \(error)") + } else { + Logger.debug("Local description set successfully.") + self.socket.emit("answer-description", ["sdp": answer.sdp]) + } + } + } + } + }) + } + + private func handleAnswerDescription(_ data: [String: Any]) { + guard let sdp = data["sdp"] as? String, + let type = sdpType(from: data["type"] as? String) + else { + Logger.error("Received SDP is missing or invalid.") + return + } + Logger.debug("handleAnswerDescription: Received SDP: \(type) : \(sdp)") + let sessionDescription = RTCSessionDescription(type: type, sdp: sdp) + + if peerClient?.peerConnection?.signalingState != .haveLocalOffer { + Logger.error("Cannot set remote answer unless in have-local-offer state") + return + } + + peerClient?.setRemoteDescription(sessionDescription, completion: { error in + if let error { + Logger.error("Failed to set remote description: \(error)") + } else { + self.processBufferedCandidates() + } + }) + } + + private func handleAnswerDescription(_ sdp: String) { + // If you know this is always an answer, you can hardcode the type + let sessionDescription = RTCSessionDescription(type: .answer, sdp: sdp) + + if peerClient?.peerConnection?.signalingState != .haveLocalOffer { + Logger.error("Cannot set remote answer unless in have-local-offer state") + return + } + + Logger.debug("handleAnswerDescription SDP: Setting remote description with session description.") + peerClient?.setRemoteDescription(sessionDescription, completion: { error in + if let error { + Logger.error("Failed to set remote description: \(error)") + } else { + self.processBufferedCandidates() + } + }) + } + + private func handleIceCandidate(_ data: [String: Any]) { + guard let candidate = data["candidate"] as? String, + let sdpMid = data["sdpMid"] as? String, + let sdpMLineIndex = data["sdpMLineIndex"] as? Int else { return } + let iceCandidate = RTCIceCandidate(sdp: candidate, sdpMLineIndex: Int32(sdpMLineIndex), sdpMid: sdpMid) + Logger.debug("Adding ICE candidate: \(iceCandidate)") + + if let peerConnection = peerClient?.peerConnection { + // Only add if remote description is set + if peerConnection.remoteDescription != nil { + peerConnection.add(iceCandidate, completionHandler: { error in + if let error { + Logger.error("handleIceCandidate: Failed to add ICE candidate: \(error)") + } else { + Logger.debug("handleIceCandidate: ICE candidate added successfully.") + } + }) + } else { + Logger.debug("Remote description not set yet, buffering ICE candidate.") + candidatesBuffer.append(iceCandidate) + } + } else { + candidatesBuffer.append(iceCandidate) + } + } + + // Process buffered ICE candidates once the peer connection is ready + private func processBufferedCandidates() { + guard let peerConnection = peerClient?.peerConnection else { return } + for iceCandidate in candidatesBuffer { + peerConnection.add(iceCandidate, completionHandler: { error in + if let error { + Logger.error("processBufferedCandidates: Failed to add ICE candidate: \(error)") + } else { + Logger.debug("processBufferedCandidates: ICE candidate added successfully.") + } + }) + } + candidatesBuffer.removeAll() + } + + // MARK: - Send Events to the Server, wth Swift Dictionary/JSON Encoding + + func send(event: String, data: [String: Any]) { + if socket.status == .connected { + Logger.debug("Emitting event immediately: \(event) with data: \(data)") + socket.emit(event, data) + } else { + Logger.debug("Socket not connected. Queuing event: \(event)") + eventQueue.append((event, .dictionary(data))) + } + } + + // Send event with data as a pure string + func send(event: String, sdp: String) { + if socket.status == .connected { + Logger.debug("Emitting event immediately: \(event) with SDP string") + socket.emit(event, sdp) + } else { + Logger.debug("Socket not connected. Queuing event: \(event)") + eventQueue.append((event, .string(sdp))) + } + } + + private func processEventQueue() { + guard socket.status == .connected else { return } + Logger.debug("Processing event queue. Number of queued events: \(eventQueue.count)") + for (event, data) in eventQueue { + switch data { + case let .dictionary(dict): + Logger.debug("Emitting queued event: \(event) with data: \(dict)") + socket.emit(event, dict) + case let .string(sdp): + Logger.debug("Emitting queued event: \(event) with SDP string") + socket.emit(event, sdp) + } + } + eventQueue.removeAll() + } +} + +private func sdpType(from typeString: String?) -> RTCSdpType? { + switch typeString { + case "offer": .offer + case "answer": .answer + case "pranswer": .prAnswer + case "rollback": .rollback + default: nil + } +} + +private func stringFromSdpType(_ type: RTCSdpType) -> String { + switch type { + case .offer: return "offer" + case .answer: return "answer" + case .prAnswer: return "pranswer" + case .rollback: return "rollback" + @unknown default: return "" + } +} + +// MARK: - QueuedEventData + +private enum QueuedEventData { + case dictionary([String: Any]) + case string(String) +} diff --git a/modules/react-native-liquid-auth/ios/LiquidAuthSDK/SignalService.swift b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/SignalService.swift new file mode 100644 index 0000000..88e0123 --- /dev/null +++ b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/SignalService.swift @@ -0,0 +1,264 @@ +/* + * Copyright 2025 Algorand Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import WebRTC + +// MARK: - SignalServiceDelegate + +protocol SignalServiceDelegate: AnyObject { + func signalService(_ service: SignalService, didReceiveStatusUpdate title: String, message: String) +} + +// MARK: - SignalService + +public class SignalService { + public static let shared = SignalService() + + weak var delegate: SignalServiceDelegate? + private var signalClient: SignalClient? + private var peerClient: PeerApi? + var dataChannel: RTCDataChannel? + /// All open data channels keyed by label, so a caller can route a message + /// to a specific named channel (e.g. `ac2-v1` / `ac2-stream`). + private var namedDataChannels: [String: RTCDataChannel] = [:] + private var peerConnection: RTCPeerConnection? + private var dataChannelDelegates: [RTCDataChannel: DataChannelDelegate] = [:] + + private var messageQueue: [String] = [] + + private var lastKnownReferer: String? + private var isDeepLink: Bool = true + + var currentPeerType: LiquidAuthPeerType? // .offer or .answer + + /// Guards the one-shot `onConnected` callback so it fires exactly once per + /// `connectToPeer`, when the first data channel reaches the `open` state. + private var didFireConnected = false + + private init() { } + + // MARK: - Public Methods + + /// Starts the signaling service + /// + /// - Parameters: + /// - url: The signaling server URL + /// - httpClient: URLSession for HTTP communications + public func start(url: String, httpClient _: URLSession) { + // Initialize the SignalClient + signalClient = SignalClient(url: url, service: self) + signalClient?.connectSocket() + delegate?.signalService( + self, + didReceiveStatusUpdate: "Signal Service", + message: "Service started successfully." + ) + } + + /// Stops the signaling service and cleans up resources + func stop() { + signalClient?.disconnectSocket() + signalClient = nil + peerClient = nil + dataChannel = nil + namedDataChannels.removeAll() + peerConnection = nil + delegate?.signalService(self, didReceiveStatusUpdate: "Signal Service", message: "Service stopped.") + } + + /// Disconnects from the signaling service + func disconnect() { + signalClient?.disconnectSocket() + delegate?.signalService( + self, + didReceiveStatusUpdate: "Signal Service", + message: "Disconnected from the signaling server." + ) + } + + /// Abort an in-flight ``connectToPeer`` negotiation without fully stopping + /// the service. Mirrors `SignalService.cancel()` in the Android SDK. + public func cancel() { + signalClient?.cancel() + } + + // MARK: - Check if the signaling service is initialized + + var isPeerClientInitialized: Bool { + peerClient != nil + } + + /// Connects to a peer using WebRTC signaling. + /// + /// The parameters mirror the shared "top-level signal client" shape used by + /// the JavaScript client and the `react-native-liquid-auth` binding: a + /// `LiquidAuthPeerType`, an optional map of named `dataChannels`, and + /// channel-labeled message/state callbacks. + /// + /// - Parameters: + /// - requestId: Unique identifier for the peer connection + /// - type: The remote peer type (`.offer` or `.answer`) + /// - origin: Origin domain for the connection + /// - iceServers: ICE servers for NAT traversal + /// - dataChannels: Named data channels to open when acting as the offerer + /// (`.answer`). Defaults to a single `liquid` channel. + /// - onMessage: Callback for received messages `(channel, message)` + /// - onStateChange: Callback for channel state changes `(channel, state)` + /// - onLinkError: Callback for a refused `link` (e.g. room full) + public func connectToPeer( + requestId: String, + type: LiquidAuthPeerType, + origin: String, + iceServers: [RTCIceServer], + dataChannels: [String: DataChannelConfig] = DataChannelConfig.defaultChannels, + onMessage: @escaping (String, String) -> Void, + onStateChange: @escaping (String, String?) -> Void, + onLinkError: ((LinkError) -> Void)? = nil, + onConnected: (() -> Void)? = nil, + onPresence: (([String: Any]) -> Void)? = nil, + onConnectionStateChange: ((String) -> Void)? = nil + ) { + currentPeerType = type + didFireConnected = false + + signalClient?.disconnectSocket() + signalClient = nil + namedDataChannels.removeAll() + + Logger.debug("Attempting to connect to peer with requestId: \(requestId), type: \(type.rawValue)") + + // Ensure the socket is connected + signalClient = SignalClient(url: origin, service: self) + // Register socket/peer callbacks before connecting so the socket + // listeners (presence) are attached when the socket is created. + signalClient?.onPresence = onPresence + signalClient?.onConnectionStateChange = onConnectionStateChange + + // Wait for socket connection before starting signaling + signalClient?.onSocketConnected = { [weak self] in + guard let self else { return } + Logger.debug("Socket connected, now starting WebRTC signaling.") + _ = signalClient?.connectToPeer( + requestId: requestId, + type: type, + iceServers: iceServers, + dataChannels: dataChannels, + onDataChannelOpen: { [weak self] dataChannel in + Logger.debug("SignalService: onDataChannelOpen called with: \(dataChannel.label)") + self?.dataChannel = dataChannel + self?.namedDataChannels[dataChannel.label] = dataChannel + Logger.debug("Data channel is open and ready: \(dataChannel.label)") + if dataChannel.readyState == .open { + self?.flushMessageQueue() + } + }, + onMessage: { channel, message in + onMessage(channel, message) + }, + onStateChange: { [weak self] channel, state in + onStateChange(channel, state) + // Resolve the caller's "connected" signal the first time any + // channel opens, on either the offerer or responder side. + if state == "open", self?.didFireConnected == false { + self?.didFireConnected = true + onConnected?() + } + }, + onLinkError: onLinkError + ) + + peerClient = signalClient?.peerClient + peerConnection = peerClient?.peerConnection + + if let peerConnection { + Logger.debug("Peer connection state: \(peerConnection.connectionState.rawValue)") + } else { + Logger.error("Peer connection is nil.") + } + + delegate?.signalService( + self, + didReceiveStatusUpdate: "Peer Connection", + message: "Connected to peer with request ID: \(requestId)." + ) + } + + signalClient?.connectSocket() + Logger.debug("ICE servers: \(iceServers)") + Logger.debug("Waiting for socket to connect before signaling.") + } + + /// Sends a message through the data channel + /// + /// - Parameter message: The message to send + public func sendMessage(_ message: String) { + if let dataChannel, dataChannel.readyState == .open { + Logger + .debug( + "SignalService: Sending on channel to \(ObjectIdentifier(dataChannel)) label: \(dataChannel.label)" + ) + let buffer = RTCDataBuffer(data: message.data(using: .utf8)!, isBinary: false) + dataChannel.sendData(buffer) + Logger.info("Message sent: \(message)") + } else if let signalClient { + // On the offerer side the primary channel is created locally and is + // tracked by the peer client rather than `dataChannel`; route + // through the client so sending works on both sides. + signalClient.sendData(message) + Logger.info("Message sent via client: \(message)") + } else { + Logger.error("sendMessage: Data channel is not available. Queuing message.") + messageQueue.append(message) + } + } + + /// Sends a message over a specific named data channel. + /// + /// Mirrors `sendToChannel(label, message)` in the JavaScript client and the + /// `react-native-liquid-auth` binding, so a caller can route to `ac2-v1` / + /// `ac2-stream` / `ac2-heartbeat` independently of the primary channel. + /// + /// - Parameters: + /// - message: The message to send + /// - label: The label of the target data channel + public func sendMessage(_ message: String, to label: String) { + if let channel = namedDataChannels[label], channel.readyState == .open { + let buffer = RTCDataBuffer(data: message.data(using: .utf8)!, isBinary: false) + channel.sendData(buffer) + Logger.info("Message sent on \(label): \(message)") + } else if let signalClient { + // On the offerer side named channels are created locally and tracked + // by the peer client; route through the client so sending works on + // both sides. + signalClient.sendData(message, to: label) + Logger.info("Message sent on \(label) via client: \(message)") + } else { + Logger.error("sendMessage: Data channel '\(label)' is not available.") + } + } + + /// Flushes queued messages when the data channel becomes available + private func flushMessageQueue() { + guard let dataChannel else { return } + for message in messageQueue { + let buffer = RTCDataBuffer(data: message.data(using: .utf8)!, isBinary: false) + dataChannel.sendData(buffer) + Logger.info("Flushed queued message: \(message)") + } + messageQueue.removeAll() + } +} diff --git a/modules/react-native-liquid-auth/ios/VENDORED.md b/modules/react-native-liquid-auth/ios/VENDORED.md new file mode 100644 index 0000000..d3acdf0 --- /dev/null +++ b/modules/react-native-liquid-auth/ios/VENDORED.md @@ -0,0 +1,106 @@ +# Vendored native library base — iOS signaling + +> Provenance note for the Liquid Auth consolidation (see +> `react-native-liquid-auth/docs/CONSOLIDATION_PLAN.md`, decisions **D1** / **D4**). + +The iOS signaling stack under `ios/LiquidAuthSDK/` is **vendored from the original +iOS SDK**, the same way the Android signaling stack is vendored under +`android/.../foundation/algorand/auth/connect/`. `LiquidAuthNativeModule.swift` +in the parent directory wraps these vendored sources (via the shared +`SignalService.shared` singleton) and exposes the same JS API as the Android +module. + +| | | +| --- | --- | +| **Upstream repo** | `algorandfoundation/liquid-auth-ios` | +| **Upstream path** | `Sources/LiquidAuthSDK/` | +| **Target commit** | `384c926d334f69e744b80b6166af3d034970170d` (2025-08-20), branch `chore/consolidation` | +| **Portion vendored** | Signaling only (the FIDO/WebAuthn portion belongs to `react-native-passkey-autofill`, per D4) | + +## Vendored files + +Signaling-only subset of `Sources/LiquidAuthSDK/`: + +- `SignalService.swift`, `SignalClient.swift`, `PeerApi.swift`, + `DataChannelDelegate.swift` +- Shared shapes: `LiquidAuthPeerType.swift`, `DataChannelConfig.swift`, + `LinkError.swift`, `LiquidAuthError.swift`, `Logger.swift` + +The FIDO/WebAuthn portion (`AssertionApi`, `AttestationApi`, +`AuthenticatorData`, `Utility`, `auth.request.json`) is intentionally **not** +vendored here — it belongs to `react-native-passkey-autofill` (D4). The +signaling files above have no dependency on that portion. + +## Sync direction + +**Upstream → vendored copy (one-way).** The vendored files are byte-identical to +the upstream sources at the target commit. The upstream SDK's public interface is +aligned with the shared "top-level signal client" shape: + +- `LiquidAuthPeerType` (`.offer` / `.answer`) instead of a raw string +- named `dataChannels: [String: DataChannelConfig]` (defaults to a single + `liquid` channel) +- channel-labeled callbacks `onMessage(channel, message)` / + `onStateChange(channel, state)` +- a typed `LinkError` / `LinkErrorReason` and an `onLinkError` callback +- channel-addressed send (`sendMessage(_:to:)`) + +## Phase-2 parity additions (captured upstream) + +The Phase-2 native API gaps that were first added on Android were also +back-ported **up** into `liquid-auth-ios` (then vendored here), so both platforms +share one contract: + +- **`onPresence`** — `SignalClient` forwards the socket `presence` broadcast; + threaded through `SignalService.connectToPeer(onPresence:)`. +- **`onConnectionStateChange`** — `PeerApi`/`PeerConnectionDelegate` forward ICE + connection-state changes as uppercase strings matching Android + (`CONNECTED`/`DISCONNECTED`/`FAILED`/...). +- **`cancel()`** — `SignalService.cancel()` / `SignalClient.cancel()` tear down an + in-flight negotiation; surfaced as the RN module's `cancel()` (pending + `connect` rejects with `E_ABORTED`). +- **`onConnected`** — a one-shot `SignalService.connectToPeer(onConnected:)` fires + when the first channel opens, so the RN `connect` promise resolves on both the + offerer and responder side. + +Porting this SDK into this directory (Phase 3 of the consolidation plan) is +**DONE**. Wiring iOS background execution appropriately remains an open question +(see the plan's §8 risks). + +## macOS compile verification + a required upstream back-port + +The vendored signaling sources were compiled on macOS (Xcode 26.2) for the iOS +Simulator (`arm64`) against WebRTC `120.0.0` (`stasel/WebRTC`, module `WebRTC`, +matching the `WebRTC-lib` pod binary) and Socket.IO `16.1.1` — they now build +clean. This surfaced **one real bug** the byte-identical vendoring had carried +unverified: + +- `DataChannelConfig.toRTCConfiguration()` assigned + `config.channelProtocol`, but `RTCDataChannelConfiguration` has no such member + — the WebRTC property is named `protocol`, so the assignment now uses the + backtick-escaped Swift keyword (`` config.`protocol` ``). + +> **⚠️ Divergence from upstream — back-port required.** This one-line fix makes +> the vendored copy differ from `liquid-auth-ios@384c926`. To keep the one-way +> (upstream → copy) sync direction, the same fix must be applied **upstream** in +> `liquid-auth-ios` (per **D7**); until then this file is intentionally ahead of +> its upstream by this fix. + +## Full ExpoModulesCore module compile (macOS) + +Beyond the standalone SDK compile above, the **whole native module** — the +`LiquidAuthNative` pod (`LiquidAuthNativeModule.swift` + all vendored +`LiquidAuthSDK/*.swift`) — was compiled through the real Expo/CocoaPods pipeline +on macOS (Xcode 26.2). The `example/` app was linked to the local +module (`"react-native-liquid-auth": "link:.."`), `expo prebuild -p ios` +generated the native project, `pod install` resolved `LiquidAuthNative` + +`ExpoModulesCore` + `Socket.IO-Client-Swift` + `WebRTC-lib`, and `xcodebuild` +(iOS Simulator `arm64`) built the target with `BUILD SUCCEEDED`, 0 errors — no +further source fixes were needed. + +> **Toolchain note.** Node/pnpm are not on `PATH` here; the JetBrains-IDE-managed +> `node v24.14.1` / `pnpm 10.33.0` was used. A transitive Expo dependency +> (`expo-modules-jsi@57.0.3`) fails to compile under this machine's +> Xcode 26.2 / Swift 6.2.3 toolchain — an Expo-toolchain issue unrelated to this +> module — and was worked around only in disposable `node_modules` to let the +> build graph reach the `LiquidAuthNative` target. diff --git a/modules/react-native-liquid-auth/package.json b/modules/react-native-liquid-auth/package.json new file mode 100644 index 0000000..13d3715 --- /dev/null +++ b/modules/react-native-liquid-auth/package.json @@ -0,0 +1,21 @@ +{ + "name": "react-native-liquid-auth", + "version": "0.0.1", + "description": "Native bindings for liquid auth (vendored Expo local module)", + "main": "src/index.ts", + "types": "src/index.ts", + "keywords": [ + "react-native", + "expo", + "react-native-liquid-auth", + "LiquidAuthNative" + ], + "repository": "https://github.com/algorandfoundation/react-native-liquid-auth", + "author": "Algorand Foundation (AlgorandFoundation)", + "license": "Apache 2.0", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } +} diff --git a/modules/react-native-liquid-auth/src/LiquidAuthNative.types.ts b/modules/react-native-liquid-auth/src/LiquidAuthNative.types.ts new file mode 100644 index 0000000..d3ee0be --- /dev/null +++ b/modules/react-native-liquid-auth/src/LiquidAuthNative.types.ts @@ -0,0 +1,126 @@ +// Public types for the Liquid Auth native bindings. + +/** + * The type of the *remote* peer we are connecting to. + * - `answer`: the local device creates the offer (acts as the offerer) + * - `offer`: the local device waits for the offer and answers it + */ +export type LiquidAuthPeerType = 'offer' | 'answer'; + +/** + * A single ICE server configuration passed to the WebRTC peer connection. + */ +export interface IceServer { + urls: string | string[]; + username?: string; + credential?: string; +} + +/** + * Configuration for a single named data channel, mirroring the + * `RTCDataChannelInit` options accepted by `liquid-auth-js`. + */ +export interface DataChannelInit { + ordered?: boolean; + maxRetransmits?: number; + maxPacketLifeTime?: number; + protocol?: string; + negotiated?: boolean; + id?: number; +} + +/** + * Extra options for {@link connect}, mirroring the `options` argument of the + * `SignalClient.peer()` method in `liquid-auth-js`. + */ +export interface LiquidAuthConnectOptions { + /** + * Named data channels to open, keyed by label (e.g. `ac2-v1`, + * `ac2-stream`). Only used when acting as the offerer (`type: 'answer'`). + * Defaults to a single `liquid` channel. + */ + dataChannels?: Record; +} + +/** + * A parsed `liquid:///?requestId=` message. + */ +export interface LiquidAuthMessage { + origin: string; + requestId: string; +} + +/** + * Payload emitted for every data-channel message received from the peer. + * `channel` is the label of the data channel the message arrived on. + */ +export interface LiquidAuthMessageEvent { + channel: string; + message: string; +} + +/** + * Payload emitted when a data-channel state changes + * (e.g. `OPEN`, `CLOSING`, `CLOSED`). `channel` is the label of the affected + * data channel. + */ +export interface LiquidAuthStateChangeEvent { + channel: string; + state: string | null; +} + +/** + * Payload emitted when a remote media track is added to the connection. + */ +export interface LiquidAuthTrackEvent { + id: string; + kind: string; + enabled: boolean; +} + +/** + * Payload emitted for server-broadcast `presence` updates, mirroring the + * `PresenceResult` shape the wallet consumes: how many devices are currently + * connected for the `requestId`. + */ +export interface LiquidAuthPresenceEvent { + requestId: string; + deviceCount: number; + online: boolean; +} + +/** + * Payload emitted when the signaling server rejects the link for a `requestId` + * (e.g. the two-peer lockdown `link-error` room refusal). Forwarded from the + * signaling socket's `exception` event. + */ +export interface LiquidAuthLinkErrorEvent { + /** The originating signaling event, typically `link-error`. */ + event?: string; + /** The `requestId` the refusal applies to, when present. */ + requestId?: string; + /** Machine-readable reason, e.g. `room-full` / `duplicate-peer`. */ + reason?: string; + /** Human-readable message from the server. */ + message?: string; +} + +/** + * Payload emitted when the peer connection's ICE connection state changes + * (e.g. `CONNECTED`, `DISCONNECTED`, `FAILED`). + */ +export interface LiquidAuthConnectionStateEvent { + state: string; +} + +/** + * Events emitted by the native module. + */ +export type LiquidAuthNativeModuleEvents = { + onMessage: (event: LiquidAuthMessageEvent) => void; + onStateChange: (event: LiquidAuthStateChangeEvent) => void; + onTrack: (event: LiquidAuthTrackEvent) => void; + onPresence: (event: LiquidAuthPresenceEvent) => void; + onLinkError: (event: LiquidAuthLinkErrorEvent) => void; + onConnectionStateChange: (event: LiquidAuthConnectionStateEvent) => void; +}; diff --git a/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.ts b/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.ts new file mode 100644 index 0000000..a7fb38f --- /dev/null +++ b/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.ts @@ -0,0 +1,66 @@ +import { NativeModule, requireNativeModule } from 'expo'; + +import { + IceServer, + LiquidAuthConnectOptions, + LiquidAuthMessage, + LiquidAuthNativeModuleEvents, + LiquidAuthPeerType, +} from './LiquidAuthNative.types'; + +declare class LiquidAuthNativeModule extends NativeModule { + /** + * Generate a random (time-based) request id. + */ + generateRequestId(): string; + + /** + * Parse a `liquid:///?requestId=` URI (or JSON payload). + */ + parseMessage(value: string): LiquidAuthMessage; + + /** + * Start (and bind to) the background signaling service and connect the + * signaling client to the given `origin`. + */ + start(url: string): Promise; + + /** + * Connect to a remote peer by `requestId`. + * + * @param requestId the request id shared out of band (e.g. via a QR code) + * @param type the *remote* peer type (`offer` or `answer`) + * @param iceServers optional ICE server list (defaults to a public STUN server) + * @param options optional connection options (e.g. named data channels) + */ + connect( + requestId: string, + type: LiquidAuthPeerType, + iceServers?: IceServer[], + options?: LiquidAuthConnectOptions + ): Promise; + + /** + * Abort an in-flight {@link connect} negotiation. The pending `connect` + * promise rejects with an `E_ABORTED` error. + */ + cancel(): Promise; + + /** + * Send a message over the primary (`liquid`) data channel. + */ + send(message: string): void; + + /** + * Send a message over a specific named data channel. + */ + sendToChannel(channel: string, message: string): void; + + /** + * Stop the signaling client and unbind/stop the background service. + */ + disconnect(): Promise; +} + +// This call loads the native module object from the JSI. +export default requireNativeModule('LiquidAuthNative'); diff --git a/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.web.ts b/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.web.ts new file mode 100644 index 0000000..2f3aff2 --- /dev/null +++ b/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.web.ts @@ -0,0 +1,52 @@ +import { registerWebModule, NativeModule } from 'expo'; + +import { + IceServer, + LiquidAuthConnectOptions, + LiquidAuthMessage, + LiquidAuthNativeModuleEvents, + LiquidAuthPeerType, +} from './LiquidAuthNative.types'; + +const UNSUPPORTED = 'LiquidAuthNative is not supported on web'; + +class LiquidAuthNativeModule extends NativeModule { + generateRequestId(): string { + throw new Error(UNSUPPORTED); + } + + parseMessage(_value: string): LiquidAuthMessage { + throw new Error(UNSUPPORTED); + } + + async start(_url: string): Promise { + throw new Error(UNSUPPORTED); + } + + async connect( + _requestId: string, + _type: LiquidAuthPeerType, + _iceServers?: IceServer[], + _options?: LiquidAuthConnectOptions + ): Promise { + throw new Error(UNSUPPORTED); + } + + async cancel(): Promise { + throw new Error(UNSUPPORTED); + } + + send(_message: string): void { + throw new Error(UNSUPPORTED); + } + + sendToChannel(_channel: string, _message: string): void { + throw new Error(UNSUPPORTED); + } + + async disconnect(): Promise { + throw new Error(UNSUPPORTED); + } +} + +export default registerWebModule(LiquidAuthNativeModule, 'LiquidAuthNativeModule'); diff --git a/modules/react-native-liquid-auth/src/index.ts b/modules/react-native-liquid-auth/src/index.ts new file mode 100644 index 0000000..a54b4aa --- /dev/null +++ b/modules/react-native-liquid-auth/src/index.ts @@ -0,0 +1,150 @@ +import type { + IceServer, + LiquidAuthConnectionStateEvent, + LiquidAuthConnectOptions, + LiquidAuthLinkErrorEvent, + LiquidAuthMessage, + LiquidAuthMessageEvent, + LiquidAuthPeerType, + LiquidAuthPresenceEvent, + LiquidAuthStateChangeEvent, + LiquidAuthTrackEvent, +} from './LiquidAuthNative.types'; +import LiquidAuthNativeModule from './LiquidAuthNativeModule'; + +export * from './LiquidAuthNative.types'; + +// RTCDataChannel/RTCPeerConnection-shaped adapters over the native event API, +// so consumers written against `react-native-webrtc` can drive the native +// background service unchanged. See `./nativeChannel`. +export * from './nativeChannel'; + +/** Subscription returned by the event listener helpers. */ +export type EventSubscription = ReturnType; + +// Re-export the native module. On web it resolves to LiquidAuthNativeModule.web.ts +// and on native platforms to LiquidAuthNativeModule.ts +export { default } from './LiquidAuthNativeModule'; + +/** + * Generate a random (time-based) request id. + */ +export function generateRequestId(): string { + return LiquidAuthNativeModule.generateRequestId(); +} + +/** + * Parse a `liquid:///?requestId=` URI (or JSON payload). + */ +export function parseMessage(value: string): LiquidAuthMessage { + return LiquidAuthNativeModule.parseMessage(value); +} + +/** + * Start (and bind to) the background signaling service and connect the + * signaling client to the given `origin`. + */ +export function start(url: string): Promise { + return LiquidAuthNativeModule.start(url); +} + +/** + * Connect to a remote peer by `requestId`. + * + * Pass `options.dataChannels` to open multiple named data channels (e.g. + * `ac2-v1`, `ac2-stream`) when acting as the offerer (`type: 'answer'`). + */ +export function connect( + requestId: string, + type: LiquidAuthPeerType, + iceServers?: IceServer[], + options?: LiquidAuthConnectOptions +): Promise { + return LiquidAuthNativeModule.connect(requestId, type, iceServers, options); +} + +/** + * Abort an in-flight {@link connect} negotiation. The pending `connect` + * promise rejects with an `E_ABORTED` error. + */ +export function cancel(): Promise { + return LiquidAuthNativeModule.cancel(); +} + +/** + * Send a message over the primary (`liquid`) data channel. + */ +export function send(message: string): void { + return LiquidAuthNativeModule.send(message); +} + +/** + * Send a message over a specific named data channel. + */ +export function sendToChannel(channel: string, message: string): void { + return LiquidAuthNativeModule.sendToChannel(channel, message); +} + +/** + * Stop the signaling client and unbind/stop the background service. + */ +export function disconnect(): Promise { + return LiquidAuthNativeModule.disconnect(); +} + +/** + * Subscribe to data-channel messages received from the peer. + */ +export function addMessageListener( + listener: (event: LiquidAuthMessageEvent) => void +): EventSubscription { + return LiquidAuthNativeModule.addListener('onMessage', listener); +} + +/** + * Subscribe to data-channel state changes (`OPEN`, `CLOSING`, `CLOSED`, ...). + */ +export function addStateChangeListener( + listener: (event: LiquidAuthStateChangeEvent) => void +): EventSubscription { + return LiquidAuthNativeModule.addListener('onStateChange', listener); +} + +/** + * Subscribe to remote media tracks added to the peer connection. + */ +export function addTrackListener( + listener: (event: LiquidAuthTrackEvent) => void +): EventSubscription { + return LiquidAuthNativeModule.addListener('onTrack', listener); +} + +/** + * Subscribe to server-broadcast `presence` updates for the connected + * `requestId` (how many devices are connected). + */ +export function addPresenceListener( + listener: (event: LiquidAuthPresenceEvent) => void +): EventSubscription { + return LiquidAuthNativeModule.addListener('onPresence', listener); +} + +/** + * Subscribe to signaling link errors (e.g. the two-peer lockdown `link-error` + * room refusal), so a full session can fail fast instead of timing out. + */ +export function addLinkErrorListener( + listener: (event: LiquidAuthLinkErrorEvent) => void +): EventSubscription { + return LiquidAuthNativeModule.addListener('onLinkError', listener); +} + +/** + * Subscribe to peer ICE connection-state changes (`CONNECTED`, `DISCONNECTED`, + * `FAILED`, ...), for connectivity monitoring after negotiation. + */ +export function addConnectionStateListener( + listener: (event: LiquidAuthConnectionStateEvent) => void +): EventSubscription { + return LiquidAuthNativeModule.addListener('onConnectionStateChange', listener); +} diff --git a/modules/react-native-liquid-auth/src/nativeChannel.ts b/modules/react-native-liquid-auth/src/nativeChannel.ts new file mode 100644 index 0000000..4e6dda0 --- /dev/null +++ b/modules/react-native-liquid-auth/src/nativeChannel.ts @@ -0,0 +1,239 @@ +/** + * 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; + onmessage: ((ev: DataChannelMessageEvent) => void) | null = null; + + private _readyState: DataChannelReadyState = 'connecting'; + private readonly _send: (label: string, message: string) => void; + private readonly _listeners = new Map void>>(); + + constructor(label: string, send: (label: string, message: string) => void) { + this.label = label; + this._send = send; + } + + get readyState(): DataChannelReadyState { + return this._readyState; + } + + /** 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); + } + + 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 { + const event: DataChannelMessageEvent = { data: message }; + try { + this.onmessage?.(event); + } catch { + /* consumer handler threw; do not break dispatch */ + } + this._emit('message', event); + } + + /** + * 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/tsconfig.json b/tsconfig.json index 95bb69b..3d1eb59 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,5 +7,6 @@ "@/*": ["./*"] } }, - "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"] + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"], + "exclude": ["modules"] } From a1ec10cfe9ae5df0a13ed0b5a29fa31da4e51617 Mon Sep 17 00:00:00 2001 From: Michael J Feher Date: Thu, 23 Jul 2026 16:48:18 -0400 Subject: [PATCH 7/9] chore: migrate fully to native sessions, include notifications --- .gitignore | 6 + README.md | 17 ++ __tests__/lib/ac2/nativeTransport.test.ts | 73 ++++++ app.config.js | 4 + hooks/useConnection.ts | 67 +++++- lib/ac2/index.ts | 3 + lib/ac2/nativeTransport.ts | 152 +++++++++++- lib/notifications.ts | 34 +++ modules/react-native-liquid-auth/VENDORED.md | 28 ++- .../android/build.gradle | 9 +- .../algorand/liquid/LiquidAuthNativeModule.kt | 227 +++++++++++++++++- .../algorand/auth/connect/SignalClient.kt | 10 +- .../algorand/auth/connect/SignalService.kt | 174 +++++++++++++- .../algorand/auth/connect/VENDORED.md | 37 +++ .../src/LiquidAuthNative.types.ts | 63 +++++ .../src/LiquidAuthNativeModule.ts | 29 +++ .../src/LiquidAuthNativeModule.web.ts | 14 ++ modules/react-native-liquid-auth/src/index.ts | 35 +++ package.json | 1 + pnpm-lock.yaml | 97 ++++++++ 20 files changed, 1053 insertions(+), 27 deletions(-) create mode 100644 lib/notifications.ts 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/README.md b/README.md index dcdd92d..e0c6e1b 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,23 @@ buildable app — no external workspace or sibling checkout required. - **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** diff --git a/__tests__/lib/ac2/nativeTransport.test.ts b/__tests__/lib/ac2/nativeTransport.test.ts index b143fcf..28dd886 100644 --- a/__tests__/lib/ac2/nativeTransport.test.ts +++ b/__tests__/lib/ac2/nativeTransport.test.ts @@ -3,6 +3,7 @@ import { AC2_CONTROL_CHANNEL, createNativeAc2Transport, type LiquidAuthNativeApi, + nativeAuthFetch, } from '@/lib/ac2/nativeTransport'; /** Flush pending microtasks so awaited `start()`/`connect()` progress. */ @@ -50,6 +51,7 @@ function createFakeNative(): FakeNative { }), ), cancel: jest.fn(async () => {}), + setActive: jest.fn(() => {}), sendToChannel: jest.fn((channel: string, m: string) => { sent.push([channel, m]); }), @@ -59,6 +61,7 @@ function createFakeNative(): FakeNative { 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 { @@ -96,6 +99,9 @@ describe('createNativeAc2Transport', () => { 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']); // Side channels are surfaced before negotiation completes. expect(sideChannels.map((c) => c.label).sort()).toEqual(['ac2-heartbeat', 'ac2-stream']); @@ -236,5 +242,72 @@ describe('createNativeAc2Transport', () => { expect(fake.api.cancel).toHaveBeenCalled(); expect(fake.activeMessageListeners()).toBe(0); }); +}); + +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/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/hooks/useConnection.ts b/hooks/useConnection.ts index 706dfaa..5623006 100644 --- a/hooks/useConnection.ts +++ b/hooks/useConnection.ts @@ -20,9 +20,11 @@ import { isPeerUnreachableError, isRegistrationBlockingNotice, monitorPeerConnection, + nativeAuthFetch, selectConnectionNoticeForRequest, sendConversationClose, sendConversationOpen, + setNativeActive, startNativeService, stopNativeService, } from '@/lib/ac2'; @@ -34,6 +36,7 @@ import { 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'; @@ -301,8 +304,9 @@ export function useConnection( // 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); + 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. @@ -746,6 +750,20 @@ export function useConnection( wasBackgroundedRef.current = true; } + // 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; returning to the foreground flips it online, replaying any + // buffered requests through the message listeners in arrival order. 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 */ + } + } + // Only react to a genuine (background|inactive) -> active transition. if (nextState !== 'active' || prevState === 'active') return; @@ -965,9 +983,18 @@ export function useConnection( // 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. + // 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 bounded auto-reconnect scheduler 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 = {}, @@ -978,7 +1005,16 @@ 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); }); @@ -1148,6 +1184,13 @@ export function useConnection( console.log('Session validation failed (ignored for debugging)'); } + // 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 old @@ -1159,6 +1202,18 @@ export function useConnection( if (!active) return; nativeStartedRef.current = true; + // We are in the foreground here (setup runs from a user action / an + // active-app resume), so mark the service online. This also sets the + // baseline after a relaunch: the persistent service may have been + // buffering requests while the app was closed, and going online now + // replays them through the message listeners. From here the AppState + // handler keeps this flag in sync with foreground/background. + try { + setNativeActive(true); + } catch { + /* native module may not implement setActive on every platform yet */ + } + // Presence lives with the persistent service (outside a single p2p // negotiation) so it keeps working across chat drops and drives // presence-gated renegotiation: peers must both be present in the diff --git a/lib/ac2/index.ts b/lib/ac2/index.ts index caa77cb..0e0de14 100644 --- a/lib/ac2/index.ts +++ b/lib/ac2/index.ts @@ -57,6 +57,9 @@ export { addNativePresenceListener, cancelNativeNegotiation, createNativeAc2Transport, + DEFAULT_AC2_QUEUE_CHANNELS, + nativeAuthFetch, + setNativeActive, startNativeService, stopNativeService, } from './nativeTransport'; diff --git a/lib/ac2/nativeTransport.ts b/lib/ac2/nativeTransport.ts index 179c065..f7be067 100644 --- a/lib/ac2/nativeTransport.ts +++ b/lib/ac2/nativeTransport.ts @@ -47,6 +47,65 @@ export interface NativeDataChannelInit { id?: number; } +/** A single notification template (mirrors the module's `NotificationTemplate`). */ +export interface NativeNotificationTemplate { + title?: string; + body?: string; +} + +/** + * Per-message-type notification config passed to the native service (mirrors + * the module's `NotificationConfig`). 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. + */ +export interface NativeNotificationConfig { + /** Channel labels to never notify for (control traffic). */ + suppressChannels?: string[]; + /** JSON field in the message used to select a template (default `type`). */ + typeKey?: string; + /** Per-message-type templates, keyed by the message's `type`. */ + templates?: Record; + /** Fallback template used when no `type` matches; omit to suppress. */ + fallback?: NativeNotificationTemplate; +} + +/** + * The wallet's default per-message-type notifications for the background + * service. Heartbeat/stream control channels never notify; AC2 signing/key + * requests get tailored copy; anything else (chat, unknown) falls back to a + * generic "new message" banner. The type keys are the AC2 message-type URIs + * (`AC2MessageTypes` in `@ac2/ac2-sdk`). + */ +export const DEFAULT_AC2_NOTIFICATIONS: NativeNotificationConfig = { + suppressChannels: ['ac2-heartbeat', 'ac2-stream'], + typeKey: 'type', + templates: { + 'ac2/SigningRequest': { + title: 'Signature request', + body: 'A request is waiting for your approval. Tap to review.', + }, + 'ac2/KeyRequest': { + title: 'Key request', + body: 'A request for account access is waiting. Tap to review.', + }, + }, + fallback: { + title: 'AC2 Wallet', + body: 'You have a new message. Tap to open.', + }, +}; + +/** + * 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']; + /** Native-broadcast presence payload (mirrors {@link PresenceResult}). */ export interface NativePresenceEvent { requestId: string; @@ -78,18 +137,31 @@ export interface LiquidAuthNativeApi { requestId: string, type: 'offer' | 'answer', iceServers?: NativeIceServer[], - options?: { dataChannels?: Record }, + options?: { + dataChannels?: Record; + notifications?: NativeNotificationConfig; + queueChannels?: string[]; + }, ): Promise; cancel(): Promise; + setActive(active: boolean): void; sendToChannel(channel: string, message: string): void; disconnect(): Promise; - addMessageListener(listener: (e: { channel: string; message: string }) => void): NativeSubscription; + 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; + request( + url: string, + method: string, + headers?: Record, + body?: string, + ): Promise<{ ok: boolean; status: number; statusText: string; body: string }>; } export interface CreateNativeAc2TransportOptions { @@ -113,6 +185,16 @@ export interface CreateNativeAc2TransportOptions { 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[]; /** Injected native module (defaults to the real `react-native-liquid-auth`). */ native?: LiquidAuthNativeApi; } @@ -150,9 +232,48 @@ function getDefaultNativeApi(): LiquidAuthNativeApi { addConnectionStateListener: mod.addConnectionStateListener, addPresenceListener: mod.addPresenceListener, addLinkErrorListener: mod.addLinkErrorListener, + request: mod.request, + setActive: mod.setActive, }; } +/** 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 @@ -188,6 +309,20 @@ export async function cancelNativeNegotiation( await native.cancel(); } +/** + * Tell the native background service whether the app is currently online + * (foregrounded, with its JS listeners attached). When set active, any + * messages the service buffered while the app was offline are replayed through + * the `onMessage` event in arrival order. Drive this from the app's + * foreground/background lifecycle so the app owns the signaling delivery state. + */ +export function setNativeActive( + active: boolean, + native: LiquidAuthNativeApi = getDefaultNativeApi(), +): void { + native.setActive(active); +} + /** * Subscribe to server-broadcast presence for the connected `requestId`. Lives * with the persistent service (not a single negotiation), mirroring how the JS @@ -219,6 +354,8 @@ export async function createNativeAc2Transport( onLinkError, iceServers = DEFAULT_ICE_SERVERS, dataChannels = DEFAULT_DATA_CHANNELS, + notifications = DEFAULT_AC2_NOTIFICATIONS, + queueChannels = DEFAULT_AC2_QUEUE_CHANNELS, native = getDefaultNativeApi(), } = opts; @@ -235,7 +372,10 @@ export async function createNativeAc2Transport( // 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)); + channels.set( + AC2_CONTROL_CHANNEL, + new NativeDataChannel(AC2_CONTROL_CHANNEL, native.sendToChannel), + ); } const peerConnection = new NativePeerConnection(); @@ -286,7 +426,11 @@ export async function createNativeAc2Transport( // 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 }); + const connectPromise = native.connect(requestId, 'answer', iceServers, { + dataChannels, + notifications, + queueChannels, + }); if (signal) { const abortPromise = new Promise((_, reject) => { 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/modules/react-native-liquid-auth/VENDORED.md b/modules/react-native-liquid-auth/VENDORED.md index 9b51b2e..1bcd8a3 100644 --- a/modules/react-native-liquid-auth/VENDORED.md +++ b/modules/react-native-liquid-auth/VENDORED.md @@ -19,6 +19,21 @@ 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): @@ -52,9 +67,16 @@ import resolves unchanged. The vendored tree is excluded from the wallet's ## Known caveat -The iOS podspec pulls `WebRTC-lib`, which can 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). +**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) diff --git a/modules/react-native-liquid-auth/android/build.gradle b/modules/react-native-liquid-auth/android/build.gradle index feb572f..d73fd55 100644 --- a/modules/react-native-liquid-auth/android/build.gradle +++ b/modules/react-native-liquid-auth/android/build.gradle @@ -20,8 +20,13 @@ android { dependencies { // Signaling transport implementation "io.socket:socket.io-client:2.1.0" - // Native WebRTC - implementation "io.getstream:stream-webrtc-android:1.1.3" + // 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 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 index 6d4bc5a..912c983 100644 --- 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 @@ -15,13 +15,22 @@ 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.NotificationContent +import foundation.algorand.auth.connect.NotificationPresenter 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 @@ -48,7 +57,13 @@ class LiquidAuthNativeModule : Module() { private var signalService: SignalService? = null private var serviceConnection: ServiceConnection? = null - private val httpClient = OkHttpClient() + // 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 @@ -149,7 +164,9 @@ class LiquidAuthNativeModule : Module() { { label, state -> sendEvent(ON_STATE_CHANGE, mapOf("channel" to label, "state" to state)) }, createNotificationBuilder(), NOTIFICATION_ID, - activity::class.java + activity::class.java, + buildNotificationPresenter(options), + parseQueueChannels(options) ) promise.resolve(null) } catch (e: CancellationException) { @@ -169,6 +186,17 @@ class LiquidAuthNativeModule : Module() { promise.resolve(null) } + /** + * Set whether the app is currently online (foregrounded, with its JS + * listeners attached). When set active, any messages the background + * service buffered while offline are replayed through the `onMessage` + * event in arrival order. The app owns this signal so it — not the + * library — controls the signaling delivery state. + */ + Function("setActive") { active: Boolean -> + signalService?.setActive(active) + } + /** * Send a message over the primary (`liquid`) data channel. */ @@ -194,11 +222,89 @@ class LiquidAuthNativeModule : Module() { promise.resolve(null) } + /** + * Perform an authenticated HTTP request through the module's shared + * cookie-jar client. Because the same client backs the [SignalService] + * socket, any session cookie (`connect.sid`) set by the response is + * automatically used by the signaling connection. This lets the wallet run + * the whole Liquid Auth HTTP exchange (attestation/assertion options + + * response, `/auth/session`) through the native client so the background + * service is authenticated (D9). + * + * Resolves with `{ ok, status, statusText, body }`; `body` is the raw + * response text (callers parse JSON themselves). + */ + AsyncFunction("request") { url: String, method: String, headers: Map?, body: String?, promise: Promise -> + scope.launch { + try { + val result = withContext(Dispatchers.IO) { + val builder = Request.Builder().url(url) + headers?.forEach { (name, value) -> builder.addHeader(name, value) } + // The Liquid Auth server derives the expected WebAuthn origin from + // the User-Agent (an Android APK client resolves to an + // `android:apk-key-hash:` origin, a browser to the web origin). If + // the caller didn't set one, OkHttp would send `okhttp/`, + // which the server can't classify (it throws parsing the OS) and + // the attestation/assertion fails. Default to an Android UA in the + // same shape the `liquid-auth-android` demo uses. + val hasUserAgent = headers?.keys?.any { it.equals("User-Agent", ignoreCase = true) } == true + if (!hasUserAgent) { + builder.header("User-Agent", defaultUserAgent()) + } + val contentType = headers + ?.entries + ?.firstOrNull { it.key.equals("Content-Type", ignoreCase = true) } + ?.value + ?: "application/json" + when (method.uppercase()) { + "GET" -> builder.get() + "HEAD" -> builder.head() + else -> builder.method( + method.uppercase(), + (body ?: "").toRequestBody(contentType.toMediaTypeOrNull()) + ) + } + httpClient.newCall(builder.build()).execute().use { response -> + mapOf( + "ok" to response.isSuccessful, + "status" to response.code, + "statusText" to response.message, + "body" to (response.body?.string() ?: "") + ) + } + } + promise.resolve(result) + } catch (e: Exception) { + promise.reject("E_REQUEST", e.message, e) + } + } + } + OnDestroy { - unbindService() + // The JS runtime is going away (the app is closing/reloading), but the + // foreground signaling service must keep running so the connection + // survives closing the app. Only detach the JS binding here; the service + // is stopped solely by an explicit disconnect(). + unbindOnly() } } + /** + * Build a User-Agent identifying this Android app to the Liquid Auth server + * (e.g. `app.perawallet.ac2.debug/1.0 (Android 14; Pixel 6; Google)`), mirroring + * the `liquid-auth-android` demo so the server resolves the expected + * `android:apk-key-hash:` WebAuthn origin. + */ + private fun defaultUserAgent(): String { + val pkg = context.packageName + val versionName = try { + context.packageManager.getPackageInfo(pkg, 0).versionName ?: "0" + } catch (e: Exception) { + "0" + } + return "$pkg/$versionName (Android ${Build.VERSION.RELEASE}; ${Build.MODEL}; ${Build.BRAND})" + } + private fun bindService(onConnected: () -> Unit) { if (signalService != null) { onConnected() @@ -222,6 +328,10 @@ class LiquidAuthNativeModule : Module() { context.bindService(intent, connection, Context.BIND_AUTO_CREATE) } + /** + * Fully tear down the service: detach the JS binding AND stop the started + * foreground service. Used only by an explicit `disconnect()`. + */ private fun unbindService() { serviceConnection?.let { try { @@ -239,6 +349,25 @@ class LiquidAuthNativeModule : Module() { signalService = null } + /** + * Detach only the JS binding WITHOUT stopping the started foreground + * service, so the signaling connection keeps running after the app is + * closed. On the next `start()`/bind the module reconnects to the same + * already-running service instance (preserving its queued messages and + * peer connection). + */ + private fun unbindOnly() { + serviceConnection?.let { + try { + context.unbindService(it) + } catch (e: Exception) { + // Service was not bound + } + } + serviceConnection = null + signalService = null + } + /** * Flatten a (single-level) [JSONObject] into a map so it can be forwarded as * an event payload. Presence (`{ requestId, deviceCount, online }`) and @@ -275,6 +404,19 @@ class LiquidAuthNativeModule : Module() { } } + /** + * Parse the `queueChannels` option: the set of data-channel labels whose + * inbound messages the background service buffers while the app is offline + * (and replays on reactivation). Returns null when omitted, meaning every + * channel is buffered. The wallet passes its deliverable channels here + * (e.g. `ac2-v1`, `ac2-stream`) so control-only traffic isn't queued. + */ + private fun parseQueueChannels(options: Map?): Set? { + val list = options?.get("queueChannels") as? List<*> ?: return null + val channels = list.filterIsInstance().toSet() + return channels.ifEmpty { null } + } + private fun parseIceServers(iceServers: List>?): List { if (iceServers.isNullOrEmpty()) { return listOf( @@ -297,6 +439,58 @@ class LiquidAuthNativeModule : Module() { } } + /** + * Build a [NotificationPresenter] from the `notifications` template map passed + * to `connect(options)`. The consumer (wallet) owns all per-message-type copy; + * this only provides the generic mechanism (channel suppression + JSON `type` + * lookup), so the notification is rendered natively even when the JS runtime + * is suspended/dead. + * + * Expected shape: + * ``` + * notifications: { + * suppressChannels: ["ac2-heartbeat", "ac2-stream"], + * typeKey: "type", // JSON field selecting a template + * templates: { "ac2/SigningRequest": { title, body } }, + * fallback: { title, body } // used when no type matches + * } + * ``` + * Returns null when no config is supplied (legacy raw-text behavior). + */ + private fun buildNotificationPresenter(options: Map?): NotificationPresenter? { + @Suppress("UNCHECKED_CAST") + val config = options?.get("notifications") as? Map ?: return null + val suppressChannels = (config["suppressChannels"] as? List<*>) + ?.filterIsInstance() + ?.toSet() + ?: emptySet() + val typeKey = config["typeKey"] as? String ?: "type" + @Suppress("UNCHECKED_CAST") + val templates = config["templates"] as? Map ?: emptyMap() + @Suppress("UNCHECKED_CAST") + val fallback = config["fallback"] as? Map + return NotificationPresenter { label, message -> + if (label in suppressChannels) { + return@NotificationPresenter null + } + val type = try { + val json = JSONObject(message) + if (json.has(typeKey)) json.optString(typeKey) else null + } catch (e: Exception) { + null + } + @Suppress("UNCHECKED_CAST") + val template = (type?.let { templates[it] } as? Map) ?: fallback + if (template == null) { + return@NotificationPresenter null + } + NotificationContent( + template["title"] as? String, + template["body"] as? String ?: message + ) + } + } + private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -319,3 +513,30 @@ class LiquidAuthNativeModule : Module() { .setOngoing(true) } } + +/** + * A minimal in-memory [CookieJar] (mirrors the `liquid-auth-android` demo's + * `Cookies` jar) so the shared [OkHttpClient] persists the `connect.sid` + * session cookie across the auth requests and the signaling socket for the + * lifetime of the module. + */ +private class LiquidCookieJar : CookieJar { + private val storage: MutableList = mutableListOf() + + @Synchronized + override fun saveFromResponse(url: HttpUrl, cookies: List) { + for (cookie in cookies) { + storage.removeAll { + it.name == cookie.name && it.domain == cookie.domain && it.path == cookie.path + } + storage.add(cookie) + } + } + + @Synchronized + override fun loadForRequest(url: HttpUrl): List { + val now = System.currentTimeMillis() + storage.removeAll { it.expiresAt < now } + return storage.filter { it.matches(url) } + } +} diff --git a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalClient.kt b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalClient.kt index e77592f..d293053 100644 --- a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalClient.kt +++ b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalClient.kt @@ -172,7 +172,7 @@ class SignalClient( peerContinuation = continuation peerResumed = false peerJob = scope.launch { - val clientType = if (type === "offer") "answer" else "offer" + val clientType = if (type == "offer") "answer" else "offer" peerClient = PeerApi(context) peerClient?.onConnectionStateChange = onConnectionStateChange // Note: the peer continuation is resumed at most once via @@ -181,7 +181,7 @@ class SignalClient( // Buffer ICE Candidates if they arrive before the Peer Connection is established val candidatesBuffer = mutableListOf() // If we are waiting on an offer, create a link to the address - if(type === "offer"){ + if(type == "offer"){ link(requestId) } // Listen to Remote ICE Candidates @@ -226,7 +226,7 @@ class SignalClient( } // Wait for Offer, then create Answer - if (type === "offer") { + if (type == "offer") { val sdp = signal(type) Log.d(TAG, "Recieved the SDP!(${sdp})") peerClient?.setRemoteDescription(sdp) @@ -248,7 +248,7 @@ class SignalClient( } // Create an Offer, wait for answer - else if (type === "answer") { + else if (type == "answer") { // Create the DataChannel(s). Defaults to a single `liquid` // channel, but callers may request several named channels. val channelConfig = if (dataChannels.isNullOrEmpty()) { @@ -333,7 +333,7 @@ class SignalClient( TAG, "signal.on${type.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() }}Description($description)" ) - val sdpType = if (type === "offer") SessionDescription.Type.OFFER else SessionDescription.Type.ANSWER + val sdpType = if (type == "offer") SessionDescription.Type.OFFER else SessionDescription.Type.ANSWER continuation.resume(SessionDescription(sdpType, description)) } } diff --git a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalService.kt b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalService.kt index da89494..03cfc32 100644 --- a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalService.kt +++ b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalService.kt @@ -45,6 +45,27 @@ class SignalService : Service() { // Service Binder var mBinder: IBinder = LocalBinder() + // --- Offline message queue ------------------------------------------- + // Buffer for data-channel messages that arrive while the consuming app is + // offline (its JS listener is not attached / it has been backgrounded or + // closed). They are replayed to [messageSink] in arrival order once the + // app comes back online (see [setActive]). This is a generic mechanism: + // the shared library never inspects message contents — the consumer + // decides which channels are buffered ([queueChannels]). + private val messageQueue = ArrayDeque>() + @Volatile + private var messageSink: ((label: String, msg: String) -> Unit)? = null + // Channels whose messages are buffered while offline. `null` means buffer + // every channel; an empty set means buffer none. + private var queueChannels: Set? = null + // App-controlled online flag. `null` = fall back to the activity's window + // focus (legacy behavior); once the consumer calls [setActive] it takes + // over so the app — not the library — owns the delivery state. + private var appActiveOverride: Boolean? = null + // Cap so a long offline period can't grow the buffer unbounded; oldest + // messages are dropped first. + private var maxQueuedMessages: Int = 200 + /** * Handle Service Binding */ @@ -52,6 +73,39 @@ class SignalService : Service() { return mBinder } + /** + * Keep the service running (and let the OS restart it if it is killed) so + * the signaling connection survives the app being backgrounded. The + * service is stopped only when the consumer explicitly disconnects. + */ + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return START_STICKY + } + + /** + * The user removed the app's task (closed the app), but the foreground + * signaling connection must keep running until the app explicitly + * disconnects. Deliberately do NOT stop the service here. + */ + override fun onTaskRemoved(rootIntent: Intent?) { + Log.d(TAG, "Task removed; keeping the signaling service alive") + super.onTaskRemoved(rootIntent) + } + + /** + * All bound clients have detached (e.g. the app was closed). Drop the stale + * message sink so buffered messages are never replayed to a dead listener; + * they stay queued for the next fresh listener that attaches via + * [handleMessages]. The started foreground service keeps running. + */ + override fun onUnbind(intent: Intent?): Boolean { + synchronized(this) { + messageSink = null + appActiveOverride = null + } + return super.onUnbind(intent) + } + /** * Start the Service in the Foreground */ @@ -118,6 +172,11 @@ class SignalService : Service() { fun stop() { signalClient?.disconnect() signalClient = null + synchronized(this) { + messageQueue.clear() + messageSink = null + appActiveOverride = null + } } /** @@ -193,20 +252,52 @@ class SignalService : Service() { onStateChange: ((label: String, state: String?) -> Unit)? = null, notificationBuilder: Builder, notificationId: Int = LIQUID_NOTIFICATION_ID, - activityClass: Class + activityClass: Class, + presenter: NotificationPresenter? = null, + queueChannels: Set? = null ) { var requestCode = 1 val serviceIntentRequestCode = 0 + // Remember where to deliver (and replay) messages, and which channels + // to buffer while offline. + this.messageSink = onMessage + this.queueChannels = queueChannels + // A fresh listener just attached (e.g. after a relaunch that reconnected + // to the still-running service). If the app is already marked online, + // replay anything buffered while it was gone to this new sink. + if (appActiveOverride == true) { + drainQueue() + } // Register observers on every negotiated data channel signalClient?.handleDataChannels({ label, msg -> - if (activity.hasWindowFocus()) { + if (isAppActive(activity)) { + // Online: flush anything buffered while offline first so the + // app's listener sees messages in arrival order, then deliver. + drainQueue() onMessage(label, msg) return@handleDataChannels } - Log.d(TAG, "DataChannel[$label] Message: $msg") + Log.d(TAG, "DataChannel[$label] Message (offline): $msg") + // Offline: buffer deliverable messages so they reach the app's + // listener once it comes back online. + if (shouldQueue(label)) { + enqueue(label, msg) + } + // Resolve the notification copy through the (optional) presenter. A + // presenter fully controls the per-message-type content and may + // suppress the notification entirely by returning null (e.g. for + // heartbeat/stream control traffic). With no presenter we keep the + // legacy behavior of showing the raw message text. + val content: NotificationContent? = + if (presenter != null) presenter.present(label, msg) + else NotificationContent(null, msg) + if (content == null) { + return@handleDataChannels + } + content.title?.let { notificationBuilder.setContentTitle(it) } notify( notificationBuilder - .setContentText(msg) + .setContentText(content.text) .setContentIntent(createPendingIntent(activityClass, requestCode, msg)), notificationId ) @@ -233,6 +324,62 @@ class SignalService : Service() { this.isDeepLink = isDeepLink } + /** + * Set whether the consuming app is currently online (its JS listener is + * attached / it is foregrounded). The app owns this signal so it controls + * the signaling delivery state. When set active, any messages buffered + * while offline are replayed to the message sink in arrival order. + */ + @Synchronized + fun setActive(active: Boolean) { + appActiveOverride = active + if (active) { + drainQueue() + } + } + + /** + * Whether messages should be delivered live. Uses the app-controlled + * [appActiveOverride] when set; otherwise falls back to the activity's + * window focus (legacy behavior). + */ + private fun isAppActive(activity: Activity): Boolean { + return appActiveOverride ?: activity.hasWindowFocus() + } + + /** Whether an inbound message on [label] should be buffered while offline. */ + private fun shouldQueue(label: String): Boolean { + val channels = queueChannels ?: return true + return label in channels + } + + @Synchronized + private fun enqueue(label: String, msg: String) { + while (messageQueue.size >= maxQueuedMessages && messageQueue.isNotEmpty()) { + messageQueue.removeFirst() + } + messageQueue.addLast(label to msg) + } + + /** Replay (and clear) every buffered message to the current sink, in order. */ + @Synchronized + private fun drainQueue() { + val sink = messageSink ?: return + while (messageQueue.isNotEmpty()) { + val (label, msg) = messageQueue.first() + try { + sink(label, msg) + } catch (e: Exception) { + // The sink is stale/dead (e.g. the app process was killed); + // stop draining and keep the remaining messages buffered for + // the next fresh listener. + Log.w(TAG, "Stopped draining message queue; sink threw", e) + return + } + messageQueue.removeFirst() + } + } + /** * Send a Message over the primary (`liquid`) channel */ @@ -249,3 +396,22 @@ class SignalService : Service() { peerClient?.send(label, msg) } } + +/** + * Content for a per-message notification produced by a [NotificationPresenter]. + */ +data class NotificationContent( + val title: String?, + val text: String? +) + +/** + * Generic seam that decides how (or whether) to present a notification for an + * inbound data-channel message while the app is backgrounded. Returning `null` + * suppresses the notification. Consumers (the RN module / wallet) fill this in + * with their own per-message-type copy, keeping message semantics out of the + * shared signaling library. + */ +fun interface NotificationPresenter { + fun present(label: String, message: String): NotificationContent? +} diff --git a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/VENDORED.md b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/VENDORED.md index 5c05993..3d990e6 100644 --- a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/VENDORED.md +++ b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/VENDORED.md @@ -54,3 +54,40 @@ the one-way sync direction: These are byte-for-byte the same additions in both trees; only the upstream's `@Inject` constructor on `SignalClient` differs (not mirrored here). + +## Notification presenter seam (kept in sync, upstream → copy) + +`SignalService.handleMessages` gained an optional `NotificationPresenter` +parameter (`(label, message) -> NotificationContent?`, `null` suppresses) plus +the top-level `NotificationContent` / `NotificationPresenter` declarations, so +the *content* of a backgrounded per-message notification is decided by the +consumer (the RN module builds a presenter from `connect(options.notifications)`) +rather than hardcoded in the shared library. With no presenter the legacy +raw-message behavior is preserved. These are byte-for-byte the same additions in +`liquid-auth-android`, `react-native-liquid-auth`, and the wallet's vendored copy +(only the two pre-existing comment lines in `liquid-auth-android`'s +`handleMessages` doc differ). + +## Survive-app-close & offline message queue (kept in sync, upstream → copy) + +`SignalService` was extended so the connection survives the app being closed and +so requests are delivered when the app comes back online: + +- **Lifecycle:** `onStartCommand` returns `START_STICKY` and `onTaskRemoved` + keeps the service alive (does not `stopSelf`), so the started foreground + service outlives the app's task. The RN module now only *unbinds* on JS + `OnDestroy` (`unbindOnly`); the service is stopped solely by an explicit + `disconnect()`. +- **Offline queue:** a generic message buffer (`messageQueue`) + app-controlled + online flag. `setActive(active)` (exposed to JS) flips the flag and, when set + active, replays buffered messages to the current sink in arrival order. + `handleMessages` gained a `queueChannels: Set?` parameter (which + labels to buffer while offline; `null` = all). `onUnbind` nulls the stale sink + so buffered messages are never replayed to a dead listener, and `drainQueue` + is exception-safe (a throwing sink stops the drain, keeping the rest queued). + +The library stays label-agnostic — it never inspects message contents and the +consumer passes the channel labels — keeping `liquid` pure while the app controls +the signaling delivery state. Byte-for-byte identical across the three trees +(only the two pre-existing `handleMessages` doc-comment lines in +`liquid-auth-android` differ). diff --git a/modules/react-native-liquid-auth/src/LiquidAuthNative.types.ts b/modules/react-native-liquid-auth/src/LiquidAuthNative.types.ts index d3ee0be..3d35fd5 100644 --- a/modules/react-native-liquid-auth/src/LiquidAuthNative.types.ts +++ b/modules/react-native-liquid-auth/src/LiquidAuthNative.types.ts @@ -29,6 +29,41 @@ export interface DataChannelInit { id?: number; } +/** + * A single notification template the native background service uses to render + * a per-message-type notification while the app is backgrounded. + */ +export interface NotificationTemplate { + /** Notification title. When omitted, the ongoing notification's title is kept. */ + title?: string; + /** Notification body text. When omitted, the raw message is used. */ + body?: string; +} + +/** + * Configuration for the per-message notifications the native background + * service shows while the app is backgrounded (or its JS runtime is + * suspended/killed). The consumer owns all copy; the native service renders + * from this map, so notifications work even when the JS runtime is not + * running. Content is keyed by the message's `type` (see {@link typeKey}). + */ +export interface NotificationConfig { + /** + * Channel labels to never notify for (e.g. `ac2-heartbeat` / `ac2-stream` + * control traffic). + */ + suppressChannels?: string[]; + /** JSON field in the message used to select a template (default `type`). */ + typeKey?: string; + /** Per-message-type templates, keyed by the value of {@link typeKey}. */ + templates?: Record; + /** + * Fallback template used when the message's type matches no entry in + * {@link templates}. Omit to suppress unmatched messages entirely. + */ + fallback?: NotificationTemplate; +} + /** * Extra options for {@link connect}, mirroring the `options` argument of the * `SignalClient.peer()` method in `liquid-auth-js`. @@ -40,6 +75,21 @@ export interface LiquidAuthConnectOptions { * Defaults to a single `liquid` channel. */ dataChannels?: Record; + /** + * Per-message-type notification content shown natively while the app is + * backgrounded. When omitted, the native service falls back to showing the + * raw message text for every channel. + */ + notifications?: NotificationConfig; + /** + * Data-channel labels whose inbound messages the background service buffers + * while the app is offline (its JS listener is not attached) and replays via + * `onMessage` once it comes back online (see `setActive`). When omitted, + * messages on every channel are buffered; pass an explicit list to buffer + * only the channels that carry deliverable app requests (e.g. `ac2-v1`, + * `ac2-stream`) and skip pure control traffic (e.g. `ac2-heartbeat`). + */ + queueChannels?: string[]; } /** @@ -113,6 +163,19 @@ export interface LiquidAuthConnectionStateEvent { state: string; } +/** + * The result of an authenticated {@link request} performed through the native + * module's shared cookie-jar HTTP client (the same client that backs the + * background signaling socket). `body` is the raw response text; callers parse + * JSON themselves. + */ +export interface LiquidAuthResponse { + ok: boolean; + status: number; + statusText: string; + body: string; +} + /** * Events emitted by the native module. */ diff --git a/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.ts b/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.ts index a7fb38f..80afab8 100644 --- a/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.ts +++ b/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.ts @@ -6,6 +6,7 @@ import { LiquidAuthMessage, LiquidAuthNativeModuleEvents, LiquidAuthPeerType, + LiquidAuthResponse, } from './LiquidAuthNative.types'; declare class LiquidAuthNativeModule extends NativeModule { @@ -46,6 +47,14 @@ declare class LiquidAuthNativeModule extends NativeModule; + /** + * Set whether the app is currently online (foregrounded, with its JS + * listeners attached). When set active, any messages the background service + * buffered while the app was offline are replayed through the `onMessage` + * event in arrival order. The app owns this signal. + */ + setActive(active: boolean): void; + /** * Send a message over the primary (`liquid`) data channel. */ @@ -60,6 +69,26 @@ declare class LiquidAuthNativeModule extends NativeModule; + + /** + * Perform an authenticated HTTP request through the module's shared + * cookie-jar client (the same client that backs the background signaling + * socket). Session cookies set by the response (e.g. `connect.sid`) are + * captured natively, so a subsequent {@link start} authenticates + * transparently. Lets a consumer run the whole Liquid Auth HTTP exchange + * (attestation/assertion options + response, `/auth/session`) natively. + * + * @param url absolute request URL + * @param method HTTP method (`GET`, `POST`, ...) + * @param headers optional request headers + * @param body optional request body (already serialized, e.g. JSON string) + */ + request( + url: string, + method: string, + headers?: Record, + body?: string + ): Promise; } // This call loads the native module object from the JSI. diff --git a/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.web.ts b/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.web.ts index 2f3aff2..1208b61 100644 --- a/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.web.ts +++ b/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.web.ts @@ -6,6 +6,7 @@ import { LiquidAuthMessage, LiquidAuthNativeModuleEvents, LiquidAuthPeerType, + LiquidAuthResponse, } from './LiquidAuthNative.types'; const UNSUPPORTED = 'LiquidAuthNative is not supported on web'; @@ -36,6 +37,10 @@ class LiquidAuthNativeModule extends NativeModule throw new Error(UNSUPPORTED); } + setActive(_active: boolean): void { + throw new Error(UNSUPPORTED); + } + send(_message: string): void { throw new Error(UNSUPPORTED); } @@ -47,6 +52,15 @@ class LiquidAuthNativeModule extends NativeModule async disconnect(): Promise { throw new Error(UNSUPPORTED); } + + async request( + _url: string, + _method: string, + _headers?: Record, + _body?: string + ): Promise { + throw new Error(UNSUPPORTED); + } } export default registerWebModule(LiquidAuthNativeModule, 'LiquidAuthNativeModule'); diff --git a/modules/react-native-liquid-auth/src/index.ts b/modules/react-native-liquid-auth/src/index.ts index a54b4aa..9296bc4 100644 --- a/modules/react-native-liquid-auth/src/index.ts +++ b/modules/react-native-liquid-auth/src/index.ts @@ -7,6 +7,7 @@ import type { LiquidAuthMessageEvent, LiquidAuthPeerType, LiquidAuthPresenceEvent, + LiquidAuthResponse, LiquidAuthStateChangeEvent, LiquidAuthTrackEvent, } from './LiquidAuthNative.types'; @@ -53,6 +54,11 @@ export function start(url: string): Promise { * * Pass `options.dataChannels` to open multiple named data channels (e.g. * `ac2-v1`, `ac2-stream`) when acting as the offerer (`type: 'answer'`). + * Pass `options.notifications` to customize (or suppress) the per-message-type + * notifications the background service shows while the app is backgrounded. + * Pass `options.queueChannels` to choose which channels the service buffers + * while the app is offline (replayed via `onMessage` once online; see + * {@link setActive}). */ export function connect( requestId: string, @@ -71,6 +77,17 @@ export function cancel(): Promise { return LiquidAuthNativeModule.cancel(); } +/** + * Set whether the app is currently online (foregrounded, with its JS listeners + * attached). When set active, any messages the background service buffered + * while the app was offline are replayed through the `onMessage` event in + * arrival order. Drive this from the app's foreground/background lifecycle so + * the app — not the library — controls the signaling delivery state. + */ +export function setActive(active: boolean): void { + return LiquidAuthNativeModule.setActive(active); +} + /** * Send a message over the primary (`liquid`) data channel. */ @@ -92,6 +109,24 @@ export function disconnect(): Promise { return LiquidAuthNativeModule.disconnect(); } +/** + * Perform an authenticated HTTP request through the native module's shared + * cookie-jar client (the same client that backs the background signaling + * socket). Session cookies set by the response (e.g. `connect.sid`) are + * captured natively, so a subsequent {@link start} authenticates + * transparently. Use this to run the whole Liquid Auth HTTP exchange + * (attestation/assertion options + response, `/auth/session`) natively so the + * background service shares the wallet's session. + */ +export function request( + url: string, + method: string = 'GET', + headers?: Record, + body?: string +): Promise { + return LiquidAuthNativeModule.request(url, method, headers, body); +} + /** * Subscribe to data-channel messages received from the peer. */ diff --git a/package.json b/package.json index 17a7ee1..bb221b1 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", "expo-linking": "~8.0.11", + "expo-notifications": "~0.32.17", "expo-router": "~6.0.23", "expo-screen-capture": "~8.0.9", "expo-sharing": "~14.0.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db4abcb..7f3854f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,6 +142,9 @@ importers: expo-linking: specifier: ~8.0.11 version: 8.0.12(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-notifications: + specifier: ~0.32.17 + version: 0.32.17(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.8.3) expo-router: specifier: ~6.0.23 version: 6.0.24(3959d441dba1e5b6b7892d35ace6b974) @@ -1215,6 +1218,9 @@ packages: '@hapi/topo@5.1.0': resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + '@ide/backoff@1.0.0': + resolution: {integrity: sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==} + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -2523,6 +2529,9 @@ packages: asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assert@2.1.0: + resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -2616,6 +2625,9 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + badgin@1.2.3: + resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -3065,6 +3077,10 @@ packages: resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} engines: {node: '>=8'} + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -3336,6 +3352,11 @@ packages: resolution: {integrity: sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + expo-application@7.0.8: + resolution: {integrity: sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==} + peerDependencies: + expo: '*' + expo-asset@12.0.13: resolution: {integrity: sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==} peerDependencies: @@ -3459,6 +3480,13 @@ packages: react: 19.1.0 react-native: '*' + expo-notifications@0.32.17: + resolution: {integrity: sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==} + peerDependencies: + expo: '*' + react: 19.1.0 + react-native: '*' + expo-router@6.0.24: resolution: {integrity: sha512-KIssKXnwjrdCxPWC8GsMTfKTwng40VrCsO16O9x6fKlfUwBW8itxY9PpCGyQeMJkiEADa+DUhtrDgl+uHg4/DA==} peerDependencies: @@ -3996,6 +4024,10 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} + is-nan@1.3.2: + resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} + engines: {node: '>= 0.4'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -4925,6 +4957,10 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -4933,6 +4969,10 @@ packages: resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} engines: {node: '>= 10'} + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -8013,6 +8053,8 @@ snapshots: dependencies: '@hapi/hoek': 9.3.0 + '@ide/backoff@1.0.0': {} + '@inquirer/external-editor@1.0.3(@types/node@22.19.19)': dependencies: chardet: 2.2.0 @@ -9417,6 +9459,14 @@ snapshots: dependencies: safer-buffer: 2.1.2 + assert@2.1.0: + dependencies: + call-bind: 1.0.9 + is-nan: 1.3.2 + object-is: 1.1.6 + object.assign: 4.1.7 + util: 0.12.5 + astral-regex@2.0.0: {} async-limiter@1.0.1: {} @@ -9560,6 +9610,8 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + badgin@1.2.3: {} + balanced-match@1.0.2: {} bare-events@2.8.3: {} @@ -9991,6 +10043,12 @@ snapshots: define-lazy-prop@2.0.0: {} + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + delayed-stream@1.0.0: {} delegates@1.0.0: @@ -10315,6 +10373,10 @@ snapshots: jest-mock: 30.4.1 jest-util: 30.4.1 + expo-application@7.0.8(expo@54.0.35): + dependencies: + expo: 54.0.35(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(graphql@16.8.1)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.8.3) + expo-asset@12.0.13(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.8.3): dependencies: '@expo/image-utils': 0.8.14(typescript@5.8.3) @@ -10450,6 +10512,22 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) + expo-notifications@0.32.17(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.8.3): + dependencies: + '@expo/image-utils': 0.8.14(typescript@5.8.3) + '@ide/backoff': 1.0.0 + abort-controller: 3.0.0 + assert: 2.1.0 + badgin: 1.2.3 + expo: 54.0.35(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(graphql@16.8.1)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.8.3) + expo-application: 7.0.8(expo@54.0.35) + expo-constants: 18.0.13(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) + transitivePeerDependencies: + - supports-color + - typescript + expo-router@6.0.24(3959d441dba1e5b6b7892d35ace6b974): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.35)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -11040,6 +11118,11 @@ snapshots: is-interactive@1.0.0: {} + is-nan@1.3.2: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + is-number@7.0.0: {} is-potential-custom-element-name@1.0.1: {} @@ -12380,10 +12463,24 @@ snapshots: object-hash@3.0.0: {} + object-is@1.1.6: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + object-keys@1.1.1: {} object-treeify@1.1.33: {} + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + on-finished@2.3.0: dependencies: ee-first: 1.1.1 From a0b437a804abaffd041397315c79a171a986f3db Mon Sep 17 00:00:00 2001 From: Michael J Feher Date: Thu, 23 Jul 2026 20:45:18 -0400 Subject: [PATCH 8/9] wip: implicit message flush and simplify notifications --- __tests__/lib/ac2/nativeChannel.test.ts | 78 ++++ __tests__/lib/ac2/nativeTransport.test.ts | 136 +++++++ hooks/useConnection.ts | 202 +++++++++-- lib/ac2/index.ts | 3 + lib/ac2/nativeChannel.ts | 96 ++++- lib/ac2/nativeTransport.ts | 196 +++++++++-- .../algorand/liquid/LiquidAuthNativeModule.kt | 177 +++++++--- .../algorand/auth/connect/PeerApi.kt | 26 +- .../algorand/auth/connect/SignalService.kt | 332 +++++++++++++++--- .../algorand/auth/connect/VENDORED.md | 106 ++++++ .../src/LiquidAuthNative.types.ts | 89 +++-- .../src/LiquidAuthNativeModule.ts | 33 +- .../src/LiquidAuthNativeModule.web.ts | 13 + modules/react-native-liquid-auth/src/index.ts | 51 ++- .../src/nativeChannel.ts | 85 ++++- 15 files changed, 1420 insertions(+), 203 deletions(-) diff --git a/__tests__/lib/ac2/nativeChannel.test.ts b/__tests__/lib/ac2/nativeChannel.test.ts index fe52878..2b4ccac 100644 --- a/__tests__/lib/ac2/nativeChannel.test.ts +++ b/__tests__/lib/ac2/nativeChannel.test.ts @@ -1,6 +1,12 @@ 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(); @@ -51,6 +57,78 @@ describe('NativeDataChannel', () => { 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(); diff --git a/__tests__/lib/ac2/nativeTransport.test.ts b/__tests__/lib/ac2/nativeTransport.test.ts index 28dd886..a3bf874 100644 --- a/__tests__/lib/ac2/nativeTransport.test.ts +++ b/__tests__/lib/ac2/nativeTransport.test.ts @@ -2,6 +2,7 @@ import type { NativeDataChannel } from '@/lib/ac2/nativeChannel'; import { AC2_CONTROL_CHANNEL, createNativeAc2Transport, + flushNativeQueue, type LiquidAuthNativeApi, nativeAuthFetch, } from '@/lib/ac2/nativeTransport'; @@ -18,6 +19,12 @@ interface FakeNative { 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; } @@ -32,6 +39,12 @@ function createFakeNative(): FakeNative { 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); @@ -51,7 +64,10 @@ function createFakeNative(): FakeNative { }), ), 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]); }), @@ -73,6 +89,9 @@ function createFakeNative(): FakeNative { emitLinkError: (e) => emit(link, e), resolveConnect: () => resolveConnect(), rejectConnect: (err) => rejectConnect(err), + setConnectionState: (s: typeof connectionState) => { + connectionState = s; + }, sent, activeMessageListeners: () => message.size, }; @@ -103,6 +122,16 @@ describe('createNativeAc2Transport', () => { // 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']); @@ -115,6 +144,99 @@ describe('createNativeAc2Transport', () => { 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[] = []; @@ -244,6 +366,20 @@ describe('createNativeAc2Transport', () => { }); }); +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('nativeAuthFetch', () => { it('maps a JSON POST onto the native request and returns a Response', async () => { const fake = createFakeNative(); diff --git a/hooks/useConnection.ts b/hooks/useConnection.ts index 5623006..09ac322 100644 --- a/hooks/useConnection.ts +++ b/hooks/useConnection.ts @@ -14,7 +14,9 @@ import { createHeartbeatMonitor, createNativeAc2Transport, DEFAULT_THID, + flushNativeQueue, generateThid, + getNativeConnectionState, isPeerOffline, isPeerRejectedError, isPeerUnreachableError, @@ -343,7 +345,7 @@ export function useConnection( // after a drop is what previously wedged the connection effect's guard // (`if (clientRef.current || isConnected) return`) 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. @@ -410,7 +412,13 @@ export function useConnection( // 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. - if (nativeStartedRef.current) { + // + // 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 */ }); @@ -490,13 +498,13 @@ export function useConnection( // 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(() => { + const performReconnect = useCallback((options?: { preserveNativePeer?: boolean }) => { deliberateCloseRef.current = true; if (autoReconnectTimerRef.current) { clearTimeout(autoReconnectTimerRef.current); autoReconnectTimerRef.current = null; } - clearTransport(); + clearTransport(options); authFlowInProgressRef.current = false; // Reset both liveness clocks so the fresh attempt isn't judged idle. lastInboundActivityRef.current = Date.now(); @@ -641,6 +649,11 @@ export function useConnection( isLoadingRef.current = isLoading; const isReconnectingRef = useRef(isReconnecting); isReconnectingRef.current = isReconnecting; + // Current `requestId`, mirrored so the stable (deps: []) `maybeNegotiate` + // callback can match the background service's live connection without a + // stale closure. + const requestIdRef = useRef(requestId); + requestIdRef.current = requestId; // Attempt a p2p (re)negotiation IFF it is safe and worthwhile. Peers must not // negotiate without knowing they both exist, so this only proceeds when the @@ -673,6 +686,34 @@ export function useConnection( console.log('[ac2] maybeNegotiate: skipped — an auto-reconnect is already pending'); return; } + // If the background service ALREADY holds a live connection for this + // `requestId` (it survived app close / backgrounding), re-attach to it + // immediately — regardless of the presence gate below. A live data channel + // is itself proof both peers are connected, and on a cold relaunch presence + // can't even reach us yet: the service's presence broadcasts only start + // routing to this fresh JS runtime once attach() rebinds the listener, so + // waiting for a presence broadcast here would deadlock the hydration and + // strand the UI on "Connecting…". createNativeAc2Transport takes the attach + // (no-renegotiate) path in this case, preserving the live p2p connection. + try { + const nativeState = getNativeConnectionState(); + if (nativeState.connected && nativeState.requestId === requestIdRef.current) { + console.log( + '[ac2] maybeNegotiate: background service already holds a live connection — attaching (hydrate)', + ); + setPeerOffline(false); + // Re-run setup so `negotiateTransport` -> `createNativeAc2Transport` + // takes the `attach()` (no-renegotiate) branch. Crucially, PRESERVE the + // live native peer: the default reconnect cancels it, which would close + // the very connection we want to hydrate and downgrade this into a full + // renegotiation (the relaunch "reconnect" symptom). + performReconnectRef.current({ preserveNativePeer: true }); + return; + } + } catch { + // Native module unavailable (tests / web / not yet started) — fall through + // to the normal presence-gated negotiation. + } // Both peers must be present (deviceCount >= 2) before we negotiate p2p. if (isPeerOffline(peerPresenceRef.current)) { console.log( @@ -753,15 +794,29 @@ export function useConnection( // 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; returning to the foreground flips it online, replaying any - // buffered requests through the message listeners in arrival order. The - // service itself keeps running regardless (it survives app close). + // 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 (see + // `negotiateTransport`) once fresh handlers are wired. + if (nextState === 'active' && isConnectedRef.current) { + try { + flushNativeQueue(); + } catch { + /* best-effort; the post-negotiation flush also covers this */ + } + } } // Only react to a genuine (background|inactive) -> active transition. @@ -1203,11 +1258,13 @@ export function useConnection( nativeStartedRef.current = true; // We are in the foreground here (setup runs from a user action / an - // active-app resume), so mark the service online. This also sets the - // baseline after a relaunch: the persistent service may have been - // buffering requests while the app was closed, and going online now - // replays them through the message listeners. From here the AppState - // handler keeps this flag in sync with foreground/background. + // active-app resume), so mark the service online. 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 (see `negotiateTransport`). try { setNativeActive(true); } catch { @@ -1235,30 +1292,30 @@ export function useConnection( setPeerPresence(presence); peerPresenceRef.current = presence; if (isPeerOffline(presence)) { - // The peer isn't in the requestId room — but the WebRTC data channel - // is the source of truth for an established p2p connection, and it - // survives signaling-server loss. If a chat is currently live - // (`isConnectedRef`), a presence drop is almost always a signaling - // artifact (most commonly the signaling server restarting and not - // yet re-counting the still-connected peer), NOT a real departure. - // Tearing down here would needlessly restart a healthy p2p - // connection on every signaling blip, so ignore it: a genuinely gone - // peer is caught by the data channel's own detectors (the heartbeat - // watchdog and ICE connectivity monitor), which is the standard - // "drop only when the data channel fails" behavior. + // 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. Trust it: if the + // peer has dropped out of the room, bail out and tear down the p2p + // transport even if the data channel still looks live (a stale data + // channel to a departed peer is worse than reconnecting when they + // return). This holds whether or not a chat is currently live. // - // When NOT connected there is no live p2p connection to trust, so a - // presence drop is authoritative and immediate: proactively tear - // down any half-open transport and surface a clean inline "Peer - // offline" notice instead of an endless "Connecting…". Only progress - // to connecting again once the peer is back (the else branch). - if (isConnectedRef.current) { - console.log( - '[ac2] presence shows peer offline but the p2p data channel is live — ignoring (the data channel is authoritative; a real drop is handled by the heartbeat/ICE monitors)', - ); - } else { - handlePeerOfflineRef.current(); - } + // The ONE case we deliberately do NOT treat as a peer drop is the + // signaling server itself going down while we still have a live data + // channel: that is an infrastructure problem, not a real departure, + // and the p2p connection can happily outlive the signaling server. + // But a server-down scenario produces NO presence broadcast (the + // socket is gone), so it never enters this branch — the data channel + // simply keeps running and a genuine p2p failure is caught by its + // own detectors (the heartbeat watchdog and ICE connectivity + // monitor). So reacting here only to actual presence-offline + // broadcasts is exactly the "trust presence, ignore server + // disconnects" behavior we want. + console.log( + `[ac2] presence shows peer offline (deviceCount=${presence.deviceCount}) — tearing down p2p (the signaling server is connected, so presence is authoritative)`, + ); + handlePeerOfflineRef.current(); } else { // Both peers are present: (re)negotiate the p2p transport. setPeerOffline(false); @@ -1303,9 +1360,31 @@ export function useConnection( active = false; // Release the auth lock before the new run starts so it isn't blocked. authFlowInProgressRef.current = false; + // 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 (see the `attach()` path). Tearing the + // transport + service down here is exactly what dropped the connection on + // swipe-away. Only this run's JS-side presence subscription is detached — + // the JS VM can survive a relaunch, and leaving it subscribed would + // accumulate a dead (guarded, but still registered) listener per + // relaunch. A real teardown (deps changed while the app is active, or an + // explicit disconnect via `reset`) still runs the full teardown below. + if (AppState.currentState !== 'active') { + if (presenceUnsubRef.current) { + try { + presenceUnsubRef.current(); + } catch { + /* noop */ + } + presenceUnsubRef.current = null; + } + return; + } runAbort.abort(); - // The socket is going away for good (session change / unmount / explicit - // rebuild): tear down the p2p transport too, then close the socket. + // The socket is going away for good (session change / explicit rebuild): + // tear down the p2p transport too, then close the socket. clearTransport(); closeSocket(); }; @@ -1473,6 +1552,7 @@ export function useConnection( 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(); @@ -1480,6 +1560,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({ @@ -1582,6 +1663,21 @@ export function useConnection( }, }); 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: the newer run owns all recovery. @@ -1606,6 +1702,38 @@ export function useConnection( // Release the negotiation lock before the next run starts (the `finally` // above is guarded by `active`, now false, so it won't reset it itself). transportInFlightRef.current = false; + // If this cleanup fires because the app is being backgrounded / destroyed + // (swipe-away, screen off) rather than a genuine renegotiation or explicit + // disconnect, PRESERVE the live peer: the background service keeps it alive + // so a relaunch/foreground re-attaches and hydrates. The native + // peer/channels must NOT be cancelled here (doing so is what dropped the + // connection on swipe-away). But DO detach this run's JS-side wiring: the + // JS VM can survive a relaunch (the next "Running main" reuses it), so a + // still-subscribed native message listener from this dead tree would + // swallow the offline-queue replay (its handlers bail on `active === + // false`) and duplicate every event also delivered to the fresh session. + // 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. A real teardown (deps changed while active) still runs below. + if (AppState.currentState !== 'active') { + if (heartbeatMonitorRef.current) { + heartbeatMonitorRef.current.stop(); + heartbeatMonitorRef.current = null; + } + if (peerMonitorDisposeRef.current) { + peerMonitorDisposeRef.current(); + peerMonitorDisposeRef.current = null; + } + if (transportDisposeRef.current) { + try { + transportDisposeRef.current(); + } catch { + /* noop */ + } + transportDisposeRef.current = null; + } + return; + } // 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. diff --git a/lib/ac2/index.ts b/lib/ac2/index.ts index 0e0de14..48c9619 100644 --- a/lib/ac2/index.ts +++ b/lib/ac2/index.ts @@ -58,6 +58,8 @@ export { cancelNativeNegotiation, createNativeAc2Transport, DEFAULT_AC2_QUEUE_CHANNELS, + flushNativeQueue, + getNativeConnectionState, nativeAuthFetch, setNativeActive, startNativeService, @@ -67,6 +69,7 @@ export type { CreateNativeAc2TransportOptions, LiquidAuthNativeApi, NativeAc2TransportSetup, + NativeConnectionStateSnapshot, NativeDataChannelInit, NativeIceServer, NativeLinkErrorEvent, diff --git a/lib/ac2/nativeChannel.ts b/lib/ac2/nativeChannel.ts index 4e6dda0..05e1683 100644 --- a/lib/ac2/nativeChannel.ts +++ b/lib/ac2/nativeChannel.ts @@ -63,11 +63,30 @@ export class NativeDataChannel { onopen: ((ev?: unknown) => void) | null = null; onclose: ((ev?: unknown) => void) | null = null; onerror: ((ev?: unknown) => void) | null = null; - onmessage: ((ev: DataChannelMessageEvent) => 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; @@ -78,6 +97,16 @@ export class NativeDataChannel { 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); @@ -99,6 +128,8 @@ export class NativeDataChannel { 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 { @@ -107,15 +138,76 @@ export class NativeDataChannel { /** 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); + 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 diff --git a/lib/ac2/nativeTransport.ts b/lib/ac2/nativeTransport.ts index f7be067..f2a3f63 100644 --- a/lib/ac2/nativeTransport.ts +++ b/lib/ac2/nativeTransport.ts @@ -54,45 +54,51 @@ export interface NativeNotificationTemplate { } /** - * Per-message-type notification config passed to the native service (mirrors - * the module's `NotificationConfig`). The native service renders these while + * 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 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 to never notify for (control traffic). */ + /** + * 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[]; - /** JSON field in the message used to select a template (default `type`). */ - typeKey?: string; - /** Per-message-type templates, keyed by the message's `type`. */ - templates?: Record; - /** Fallback template used when no `type` matches; omit to suppress. */ - fallback?: NativeNotificationTemplate; + /** 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 per-message-type notifications for the background - * service. Heartbeat/stream control channels never notify; AC2 signing/key - * requests get tailored copy; anything else (chat, unknown) falls back to a - * generic "new message" banner. The type keys are the AC2 message-type URIs - * (`AC2MessageTypes` in `@ac2/ac2-sdk`). + * 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', 'ac2-stream'], - typeKey: 'type', - templates: { - 'ac2/SigningRequest': { - title: 'Signature request', - body: 'A request is waiting for your approval. Tap to review.', - }, - 'ac2/KeyRequest': { - title: 'Key request', - body: 'A request for account access is waiting. Tap to review.', - }, + suppressChannels: ['ac2-heartbeat'], + connected: { + title: 'AC2 Wallet', + body: 'Connected to the signaling service', }, - fallback: { + idle: { title: 'AC2 Wallet', - body: 'You have a new message. Tap to open.', + body: 'Tap to open the app.', + }, + messages: { + title: 'AC2 Wallet', + body: 'You have new messages.', }, }; @@ -106,6 +112,28 @@ export const DEFAULT_AC2_NOTIFICATIONS: NativeNotificationConfig = { */ 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; @@ -126,6 +154,18 @@ 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; +} + /** * 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 @@ -141,10 +181,24 @@ export interface LiquidAuthNativeApi { 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( @@ -195,6 +249,12 @@ export interface CreateNativeAc2TransportOptions { * `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; } @@ -225,6 +285,8 @@ function getDefaultNativeApi(): LiquidAuthNativeApi { start: mod.start, connect: mod.connect, cancel: mod.cancel, + getConnectionState: mod.getConnectionState, + attach: mod.attach, sendToChannel: mod.sendToChannel, disconnect: mod.disconnect, addMessageListener: mod.addMessageListener, @@ -234,6 +296,7 @@ function getDefaultNativeApi(): LiquidAuthNativeApi { addLinkErrorListener: mod.addLinkErrorListener, request: mod.request, setActive: mod.setActive, + flushQueue: mod.flushQueue, }; } @@ -311,10 +374,14 @@ export async function cancelNativeNegotiation( /** * Tell the native background service whether the app is currently online - * (foregrounded, with its JS listeners attached). When set active, any - * messages the service buffered while the app was offline are replayed through - * the `onMessage` event in arrival order. Drive this from the app's - * foreground/background lifecycle so the app owns the signaling delivery state. + * (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, @@ -323,6 +390,33 @@ export function setNativeActive( 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(); +} + /** * Subscribe to server-broadcast presence for the connected `requestId`. Lives * with the persistent service (not a single negotiation), mirroring how the JS @@ -356,6 +450,7 @@ export async function createNativeAc2Transport( dataChannels = DEFAULT_DATA_CHANNELS, notifications = DEFAULT_AC2_NOTIFICATIONS, queueChannels = DEFAULT_AC2_QUEUE_CHANNELS, + heartbeat = DEFAULT_AC2_HEARTBEAT, native = getDefaultNativeApi(), } = opts; @@ -384,8 +479,17 @@ export async function createNativeAc2Transport( // missed. Each subscription is detached by `dispose()` below. const subscriptions: NativeSubscription[] = []; subscriptions.push( - native.addMessageListener((e) => channels.get(e.channel)?.dispatchMessage(e.message)), - native.addStateChangeListener((e) => channels.get(e.channel)?.setState(e.state)), + 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)), ); @@ -423,6 +527,29 @@ export async function createNativeAc2Transport( 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; @@ -430,6 +557,7 @@ export async function createNativeAc2Transport( dataChannels, notifications, queueChannels, + ...(heartbeat ? { heartbeat } : {}), }); if (signal) { 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 index 912c983..d8e50fd 100644 --- 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 @@ -9,14 +9,16 @@ 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.NotificationPresenter +import foundation.algorand.auth.connect.NotificationStatus import foundation.algorand.auth.connect.SignalClient import foundation.algorand.auth.connect.SignalService import kotlinx.coroutines.CoroutineScope @@ -44,6 +46,7 @@ import org.webrtc.PeerConnection */ 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 @@ -160,13 +163,17 @@ class LiquidAuthNativeModule : Module() { ) service.handleMessages( activity, - { label, msg -> sendEvent(ON_MESSAGE, mapOf("channel" to label, "message" to msg)) }, + { label, msg -> + Log.d(TAG, "emit onMessage[$label] -> JS (connect)") + sendEvent(ON_MESSAGE, mapOf("channel" to label, "message" to msg)) + }, { label, state -> sendEvent(ON_STATE_CHANGE, mapOf("channel" to label, "state" to state)) }, createNotificationBuilder(), NOTIFICATION_ID, activity::class.java, - buildNotificationPresenter(options), - parseQueueChannels(options) + buildNotificationStatus(options), + parseQueueChannels(options), + parseHeartbeat(options) ) promise.resolve(null) } catch (e: CancellationException) { @@ -177,6 +184,73 @@ class LiquidAuthNativeModule : Module() { } } + /** + * Snapshot the background service's CURRENT connection so a re-attaching + * app can hydrate instead of assuming a fresh start. Returns + * `{ connected, requestId, iceConnectionState, channels }`. Safe to call + * before the service is bound (returns `connected: false`). + */ + Function("getConnectionState") { + signalService?.getConnectionState() ?: mapOf( + "connected" to false, + "requestId" to null, + "iceConnectionState" to null, + "channels" to emptyMap() + ) + } + + /** + * Re-attach to the ALREADY-live connection without renegotiating: rebind + * the message/state/presence/link-error/connection-state listeners to this + * (fresh) JS runtime and re-emit the current channel + ICE state so the app + * hydrates. Use when [getConnectionState] reports `connected: true` (e.g. + * after a relaunch that reconnected to the still-running service). `options` + * carries the same `notifications`/`queueChannels`/`heartbeat` config as + * [connect]. + */ + AsyncFunction("attach") { options: Map?, promise: Promise -> + val service = signalService + if (service == null) { + promise.reject("E_NOT_STARTED", "Signaling service not started, call start() first", null) + return@AsyncFunction + } + val activity = currentActivity + try { + service.attach( + activity, + { label, msg -> + Log.d(TAG, "emit onMessage[$label] -> JS (attach)") + sendEvent(ON_MESSAGE, mapOf("channel" to label, "message" to msg)) + }, + { label, state -> sendEvent(ON_STATE_CHANGE, mapOf("channel" to label, "state" to state)) }, + createNotificationBuilder(), + NOTIFICATION_ID, + activity::class.java, + buildNotificationStatus(options), + parseQueueChannels(options), + parseHeartbeat(options), + onPresence = { presence -> sendEvent(ON_PRESENCE, jsonToMap(presence)) }, + onLinkError = { error -> sendEvent(ON_LINK_ERROR, jsonToMap(error)) }, + onConnectionStateChange = { state -> + sendEvent(ON_CONNECTION_STATE_CHANGE, mapOf("state" to state)) + }, + onTrack = { track -> + sendEvent( + ON_TRACK, + mapOf( + "id" to track.id(), + "kind" to track.kind(), + "enabled" to track.enabled() + ) + ) + } + ) + promise.resolve(null) + } catch (e: Exception) { + promise.reject("E_ATTACH", e.message, e) + } + } + /** * Abort an in-flight [connect] negotiation. The pending `connect` promise * rejects with `E_ABORTED`. @@ -188,15 +262,27 @@ class LiquidAuthNativeModule : Module() { /** * Set whether the app is currently online (foregrounded, with its JS - * listeners attached). When set active, any messages the background - * service buffered while offline are replayed through the `onMessage` - * event in arrival order. The app owns this signal so it — not the - * library — controls the signaling delivery state. + * listeners attached). The app owns this signal so it — not the library — + * controls the signaling delivery state. Deliberately does NOT replay the + * offline queue (a relaunching app flips active before its listeners are + * rewired); the replay happens when a fresh sink attaches (`connect` / + * `attach`) or when the app calls `flushQueue` once its listeners are + * wired. */ Function("setActive") { active: Boolean -> signalService?.setActive(active) } + /** + * Explicitly replay any messages the background service buffered while the + * app was offline, through the `onMessage` event in arrival order. Call it + * only once the JS message listeners are wired, so the replay can't race + * the listener setup. No-op when nothing is buffered. + */ + Function("flushQueue") { + signalService?.flushQueue() + } + /** * Send a message over the primary (`liquid`) data channel. */ @@ -417,6 +503,24 @@ class LiquidAuthNativeModule : Module() { return channels.ifEmpty { null } } + /** + * Parse the `heartbeat` option: the keep-alive channel + ping/pong tokens the + * background service uses to answer the peer's heartbeat `ping` with a `pong` + * while the app is offline (its JS ping/pong reply is dead), so the peer's + * liveness watchdog does not close the connection. Returns null when omitted + * (keep-alive disabled). The wallet supplies its own channel + tokens (e.g. + * `{ channel: "ac2-heartbeat", ping: "ping", pong: "pong" }`) so the shared + * library never hardcodes them. + */ + private fun parseHeartbeat(options: Map?): HeartbeatConfig? { + @Suppress("UNCHECKED_CAST") + val config = options?.get("heartbeat") as? Map ?: return null + val channel = config["channel"] as? String ?: return null + val ping = config["ping"] as? String ?: "ping" + val pong = config["pong"] as? String ?: "pong" + return HeartbeatConfig(channel, ping, pong) + } + private fun parseIceServers(iceServers: List>?): List { if (iceServers.isNullOrEmpty()) { return listOf( @@ -440,55 +544,46 @@ class LiquidAuthNativeModule : Module() { } /** - * Build a [NotificationPresenter] from the `notifications` template map passed - * to `connect(options)`. The consumer (wallet) owns all per-message-type copy; - * this only provides the generic mechanism (channel suppression + JSON `type` - * lookup), so the notification is rendered natively even when the JS runtime - * is suspended/dead. + * Build a [NotificationStatus] from the `notifications` config passed to + * `connect(options)`/`attach(options)`. The consumer (wallet) owns all copy; + * the shared library only renders it natively, so the single ongoing + * notification updates between its connected / idle / new-messages states + * even when the JS runtime is suspended/dead. * * Expected shape: * ``` * notifications: { * suppressChannels: ["ac2-heartbeat", "ac2-stream"], - * typeKey: "type", // JSON field selecting a template - * templates: { "ac2/SigningRequest": { title, body } }, - * fallback: { title, body } // used when no type matches + * connected: { title, body }, // app foreground / attached + * idle: { title, body }, // app closed, nothing waiting ("tap to open") + * messages: { title, body } // message(s) arrived while closed * } * ``` * Returns null when no config is supplied (legacy raw-text behavior). */ - private fun buildNotificationPresenter(options: Map?): NotificationPresenter? { + private fun buildNotificationStatus(options: Map?): NotificationStatus? { @Suppress("UNCHECKED_CAST") val config = options?.get("notifications") as? Map ?: return null val suppressChannels = (config["suppressChannels"] as? List<*>) ?.filterIsInstance() ?.toSet() ?: emptySet() - val typeKey = config["typeKey"] as? String ?: "type" - @Suppress("UNCHECKED_CAST") - val templates = config["templates"] as? Map ?: emptyMap() + return NotificationStatus( + connected = parseNotificationContent(config["connected"]), + idle = parseNotificationContent(config["idle"]), + messages = parseNotificationContent(config["messages"]), + suppressChannels = suppressChannels + ) + } + + /** Parse a `{ title, body }` template into a [NotificationContent] (or null). */ + private fun parseNotificationContent(value: Any?): NotificationContent? { @Suppress("UNCHECKED_CAST") - val fallback = config["fallback"] as? Map - return NotificationPresenter { label, message -> - if (label in suppressChannels) { - return@NotificationPresenter null - } - val type = try { - val json = JSONObject(message) - if (json.has(typeKey)) json.optString(typeKey) else null - } catch (e: Exception) { - null - } - @Suppress("UNCHECKED_CAST") - val template = (type?.let { templates[it] } as? Map) ?: fallback - if (template == null) { - return@NotificationPresenter null - } - NotificationContent( - template["title"] as? String, - template["body"] as? String ?: message - ) - } + val map = value as? Map ?: return null + return NotificationContent( + map["title"] as? String, + map["body"] as? String + ) } private fun createNotificationChannel() { diff --git a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/PeerApi.kt b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/PeerApi.kt index 9c5e013..5b3c976 100644 --- a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/PeerApi.kt +++ b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/PeerApi.kt @@ -352,9 +352,31 @@ class PeerApi(context: Context) { } fun destroy() { - dataChannels.values.forEach { it.close() } + // Unregister each channel's observer BEFORE closing it. Closing a + // channel drives it to CLOSING/CLOSED, and a still-registered observer + // reports that state through the shared (module-wide) onStateChange + // sink, which is keyed only by channel label. If a previous peer is + // torn down while a NEW negotiation for the same labels (e.g. `ac2-v1`) + // is already live, those stale CLOSED events would be mis-delivered to + // the new channel and close a healthy connection. Detaching the + // observer first makes a destroyed peer go silent. + dataChannels.values.forEach { + try { + it.unregisterObserver() + } catch (e: Exception) { + // No observer was registered (or already detached) — safe to ignore. + } + it.close() + } dataChannels.clear() - dataChannel?.close() + dataChannel?.let { + try { + it.unregisterObserver() + } catch (e: Exception) { + // No observer was registered (or already detached) — safe to ignore. + } + it.close() + } peerConnection?.close() peerConnection?.dispose() peerConnection = null diff --git a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalService.kt b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalService.kt index 03cfc32..0429c2d 100644 --- a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalService.kt +++ b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalService.kt @@ -31,6 +31,11 @@ class SignalService : Service() { var signalClient: SignalClient? = null var peerClient: PeerApi? = null + // The `requestId` the live connection is bound to, so a re-attaching app + // can hydrate which room/peer the background service is connected to. Set + // by [peer] and cleared by [stop]. + private var connectedRequestId: String? = null + // Native WebRTC Components var dataChannel: DataChannel? = null var peerConnection: PeerConnection? = null @@ -66,6 +71,27 @@ class SignalService : Service() { // messages are dropped first. private var maxQueuedMessages: Int = 200 + // Optional heartbeat keep-alive. While the consuming app is offline the JS + // ping/pong reply is dead, so the service itself answers the peer's + // keepalive `ping` with a `pong` on the configured channel, keeping the + // peer's liveness watchdog satisfied so it does not tear the connection + // down while the app is away. The consumer supplies the channel + tokens + // ([HeartbeatConfig]) so the shared library stays label- and + // content-agnostic. `null` disables the behavior (legacy). + private var heartbeat: HeartbeatConfig? = null + + // Persistent-notification state. The single ongoing foreground notification + // text reflects the service state: connected (app foreground), idle ("tap + // to open" — app closed with nothing waiting), or messages ("you have new + // messages" — deliverable messages arrived while closed). The consumer + // supplies the copy ([NotificationStatus]) so the shared library stays + // content-agnostic. Captured on [handleMessages]/[attach] so [setActive] + // and the offline message path can update the SAME notification. + private var notificationBuilder: Builder? = null + private var statusNotificationId: Int = LIQUID_NOTIFICATION_ID + private var notificationActivityClass: Class? = null + private var notificationStatus: NotificationStatus? = null + /** * Handle Service Binding */ @@ -159,11 +185,15 @@ class SignalService : Service() { activityClass: Class ) { startForeground(notificationBuilder.setContentIntent(createPendingIntent(activityClass, 0)), notificationId) - val isInitialized = signalClient != null - if (isInitialized) { - signalClient?.disconnect() + // Preserve an already-running client so the app re-attaching (e.g. after + // a relaunch that reconnected to the still-running foreground service) + // does NOT tear down the live connection the service was keeping alive. + // Only build a client when there is none — first start, or after an + // explicit disconnect()/stop() cleared it. The re-attaching consumer + // rebinds to the live peer via attach() instead of renegotiating. + if (signalClient == null) { + signalClient = SignalClient(url, this@SignalService, httpClient) } - signalClient = SignalClient(url, this@SignalService, httpClient) } /** @@ -172,6 +202,7 @@ class SignalService : Service() { fun stop() { signalClient?.disconnect() signalClient = null + connectedRequestId = null synchronized(this) { messageQueue.clear() messageSink = null @@ -204,6 +235,7 @@ class SignalService : Service() { onLinkError: ((JSONObject) -> Unit)? = null, onConnectionStateChange: ((String) -> Unit)? = null, ) { + connectedRequestId = requestId // Register the socket/peer callbacks before negotiation so the socket // listeners (presence/exception) are attached when it is created. signalClient?.onPresence = onPresence @@ -227,18 +259,26 @@ class SignalService : Service() { * This PendingIntent is used to open the given Activity when a transaction message is received */ fun createPendingIntent(activityClass: Class, requestCode: Int = 0, msg: String? = null): PendingIntent { - val answerIntent = Intent(this@SignalService, activityClass) - answerIntent.setFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) - msg?.let { - answerIntent.putExtra("msg", it) - } - return TaskStackBuilder.create(this@SignalService).run { - addNextIntentWithParentStack(answerIntent) - getPendingIntent( - requestCode, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) + // Bring the app's EXISTING task to the foreground and deliver to the + // already-running activity via onNewIntent, instead of clearing the task + // and launching a fresh instance. The previous TaskStackBuilder approach + // applied an implicit FLAG_ACTIVITY_CLEAR_TASK, which tore down and + // recreated the task — resetting the JS runtime (the app "opened fresh", + // losing state) and, with expo-router, triggering the "linking configured + // in multiple places" error from two concurrent NavigationContainers. + // FLAG_ACTIVITY_NEW_TASK + FLAG_ACTIVITY_SINGLE_TOP with the activity's + // singleTask launchMode resumes the running instance and preserves its + // state so the consumer can hydrate from the live service. + val intent = Intent(this@SignalService, activityClass).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + msg?.let { putExtra("msg", it) } } + return PendingIntent.getActivity( + this@SignalService, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) } /** * Handle Messages and State Changes @@ -253,19 +293,27 @@ class SignalService : Service() { notificationBuilder: Builder, notificationId: Int = LIQUID_NOTIFICATION_ID, activityClass: Class, - presenter: NotificationPresenter? = null, - queueChannels: Set? = null + status: NotificationStatus? = null, + queueChannels: Set? = null, + heartbeat: HeartbeatConfig? = null ) { - var requestCode = 1 - val serviceIntentRequestCode = 0 // Remember where to deliver (and replay) messages, and which channels // to buffer while offline. this.messageSink = onMessage this.queueChannels = queueChannels + this.heartbeat = heartbeat + // Capture the notification config so [setActive] and the offline message + // path can update the same persistent notification. + this.notificationBuilder = notificationBuilder + this.statusNotificationId = notificationId + this.notificationActivityClass = activityClass + this.notificationStatus = status + Log.d(TAG, "handleMessages: sink attached (appActive=$appActiveOverride, queued=${messageQueue.size}, queueChannels=$queueChannels)") // A fresh listener just attached (e.g. after a relaunch that reconnected // to the still-running service). If the app is already marked online, // replay anything buffered while it was gone to this new sink. if (appActiveOverride == true) { + Log.d(TAG, "handleMessages: app already online, replaying ${messageQueue.size} queued message(s)") drainQueue() } // Register observers on every negotiated data channel @@ -273,49 +321,132 @@ class SignalService : Service() { if (isAppActive(activity)) { // Online: flush anything buffered while offline first so the // app's listener sees messages in arrival order, then deliver. + Log.d(TAG, "DataChannel[$label] deliver (online): $msg") drainQueue() onMessage(label, msg) return@handleDataChannels } Log.d(TAG, "DataChannel[$label] Message (offline): $msg") + // Offline keep-alive: the JS ping/pong reply is dead while the app + // is backgrounded, so answer the peer's heartbeat `ping` with a + // `pong` natively. This keeps the peer's liveness watchdog happy so + // it does not close the connection while the app is away. A + // keepalive frame is neither queued nor surfaced as a notification. + heartbeat?.let { hb -> + if (label == hb.channel && msg == hb.ping) { + send(label, hb.pong) + return@handleDataChannels + } + } // Offline: buffer deliverable messages so they reach the app's // listener once it comes back online. if (shouldQueue(label)) { enqueue(label, msg) + } else { + Log.d(TAG, "DataChannel[$label] not queued (channel excluded from queueChannels)") } - // Resolve the notification copy through the (optional) presenter. A - // presenter fully controls the per-message-type content and may - // suppress the notification entirely by returning null (e.g. for - // heartbeat/stream control traffic). With no presenter we keep the - // legacy behavior of showing the raw message text. - val content: NotificationContent? = - if (presenter != null) presenter.present(label, msg) - else NotificationContent(null, msg) - if (content == null) { + // Update the ongoing notification to the "you have new messages" + // state, unless this channel is suppressed (control traffic such as + // the stream channel). The consumer owns the copy; with none we keep + // the legacy behavior of showing the raw message text. + val suppressed = notificationStatus?.suppressChannels?.contains(label) == true + if (suppressed) { return@handleDataChannels } - content.title?.let { notificationBuilder.setContentTitle(it) } - notify( - notificationBuilder - .setContentText(content.text) - .setContentIntent(createPendingIntent(activityClass, requestCode, msg)), - notificationId - ) - requestCode += 1 + showStatus(notificationStatus?.messages ?: NotificationContent(null, msg)) }, { label, state -> if (state == "CLOSED" || state == "CLOSING") { - notify( - notificationBuilder - .setContentText("Tap to open the app.") - .setOnlyAlertOnce(true) - .setContentIntent(createPendingIntent(activityClass, serviceIntentRequestCode,null)) - , notificationId - ) + // The p2p channel dropped; surface the idle "tap to open" state. + showStatus(notificationStatus?.idle ?: NotificationContent(null, "Tap to open the app.")) } onStateChange?.invoke(label, state) }) } + /** + * Snapshot of the current live connection so a re-attaching app can hydrate + * its UI (rather than assuming a fresh start). Reports whether a peer + * connection exists with negotiated channels, its ICE connection state, the + * `requestId` it is bound to, and each negotiated channel's current state + * keyed by label. Read-only: this never mutates the connection. + */ + fun getConnectionState(): Map { + val peer = signalClient?.peerClient + val channels = peer?.dataChannels?.mapValues { it.value.state().toString() } ?: emptyMap() + return mapOf( + "connected" to (peer != null && peer.dataChannels.isNotEmpty()), + "requestId" to connectedRequestId, + "iceConnectionState" to peer?.peerConnection?.iceConnectionState()?.toString(), + "channels" to channels + ) + } + + /** + * Re-attach a freshly (re)started app to the ALREADY-live connection without + * renegotiating. Rebinds the socket/peer callbacks to the new sinks (the old + * ones referenced a now-dead JS runtime), re-registers the data-channel + * observers (via [handleMessages]), and re-emits each channel's current + * state plus the peer's ICE connection state so the consumer hydrates + * immediately — observers only fire on transitions, so a live-but-unchanged + * channel would otherwise never notify the fresh listener. Used when + * [getConnectionState] reports a live peer. + */ + fun attach( + activity: Activity, + onMessage: (label: String, msg: String) -> Unit, + onStateChange: ((label: String, state: String?) -> Unit)? = null, + notificationBuilder: Builder, + notificationId: Int = LIQUID_NOTIFICATION_ID, + activityClass: Class, + status: NotificationStatus? = null, + queueChannels: Set? = null, + heartbeat: HeartbeatConfig? = null, + onPresence: ((JSONObject) -> Unit)? = null, + onLinkError: ((JSONObject) -> Unit)? = null, + onConnectionStateChange: ((String) -> Unit)? = null, + onTrack: ((MediaStreamTrack) -> Unit)? = null + ) { + // Re-attaching means the app is in the foreground and (re)wiring its + // listeners, so it is online and consuming by definition. Mark it active + // up front — BEFORE [handleMessages] captures the fresh sink and decides + // whether to replay the offline queue. This makes the hydrate replay + // self-sufficient rather than depending on the consumer having already + // called [setActive] with the right timing: a late/racing onUnbind from + // the previous binding (torn down on relaunch) can reset + // [appActiveOverride] to null AFTER the app marked itself online, which + // would otherwise cause [handleMessages] to skip drainQueue() and strand + // every message buffered while the app was closed. + synchronized(this) { appActiveOverride = true } + Log.d(TAG, "attach: re-attaching app; marked active, ${messageQueue.size} message(s) buffered") + // Rebind the live socket/peer callbacks to the new (post-relaunch) sinks. + signalClient?.onPresence = onPresence + signalClient?.onLinkError = onLinkError + signalClient?.onConnectionStateChange = onConnectionStateChange + signalClient?.peerClient?.onConnectionStateChange = onConnectionStateChange + signalClient?.peerClient?.onTrack = onTrack + // Re-register the data-channel observers with the fresh message/state + // sinks (and re-arm the offline queue / heartbeat / notification config). + handleMessages( + activity, + onMessage, + onStateChange, + notificationBuilder, + notificationId, + activityClass, + status, + queueChannels, + heartbeat + ) + // Re-emit the current channel + ICE state so the re-attached consumer + // hydrates now (the observers above only fire on future transitions). + signalClient?.peerClient?.dataChannels?.forEach { (label, channel) -> + onStateChange?.invoke(label, channel.state().toString()) + } + signalClient?.peerClient?.peerConnection?.iceConnectionState()?.let { + onConnectionStateChange?.invoke(it.toString()) + } + } + fun updateLastKnownReferer(referer: String?) { lastKnownReferer = referer } @@ -327,17 +458,59 @@ class SignalService : Service() { /** * Set whether the consuming app is currently online (its JS listener is * attached / it is foregrounded). The app owns this signal so it controls - * the signaling delivery state. When set active, any messages buffered - * while offline are replayed to the message sink in arrival order. + * the signaling delivery state. + * + * Deliberately does NOT replay the offline queue: on a relaunch the app + * marks itself active BEFORE its fresh consumer has (re)wired the message + * listeners, so replaying here would deliver the buffered messages to the + * previous (stale) sink and lose them. The queue is drained when a fresh + * sink attaches ([handleMessages] via [attach]/connect) or when the + * consumer explicitly asks for it via [flushQueue] once its listeners are + * wired (e.g. on a plain background -> foreground transition where the + * existing listeners are still live). */ @Synchronized fun setActive(active: Boolean) { + Log.d(TAG, "setActive($active); queue size=${messageQueue.size}") appActiveOverride = active + val status = notificationStatus if (active) { - drainQueue() + // Back in the foreground: restore the connected/ongoing notification. + status?.connected?.let { showStatus(it) } + } else { + // App closed/backgrounded: reflect pending-messages vs. idle in the + // ongoing notification so the user sees "you have new messages" or + // "tap to open". + if (status != null) { + if (messageQueue.isNotEmpty() && status.messages != null) { + showStatus(status.messages) + } else { + status.idle?.let { showStatus(it) } + } + } } } + /** + * Update the single ongoing foreground notification to reflect [content] + * (title + text), keeping it ongoing and tapping through to the app. Moves + * the persistent notification between the connected / idle / new-messages + * states. No-op until [handleMessages]/[attach] has captured the builder. + */ + private fun showStatus(content: NotificationContent) { + val builder = notificationBuilder ?: return + val activityClass = notificationActivityClass ?: return + content.title?.let { builder.setContentTitle(it) } + content.text?.let { builder.setContentText(it) } + notify( + builder + .setOnlyAlertOnce(true) + .setOngoing(true) + .setContentIntent(createPendingIntent(activityClass, 0, null)), + statusNotificationId + ) + } + /** * Whether messages should be delivered live. Uses the app-controlled * [appActiveOverride] when set; otherwise falls back to the activity's @@ -359,15 +532,39 @@ class SignalService : Service() { messageQueue.removeFirst() } messageQueue.addLast(label to msg) + Log.d(TAG, "Enqueued offline message on [$label]; queue size=${messageQueue.size}") + } + + /** + * Explicitly replay (and clear) every buffered offline message to the + * current message sink, in arrival order. Called by the consumer once its + * message listeners are wired and the app is online — the app owns the + * timing so a replay can never race the listener (re)wiring. No-op when + * nothing is buffered or no sink is attached (messages stay buffered). + */ + @Synchronized + fun flushQueue() { + Log.d(TAG, "flushQueue: requested by consumer (queue size=${messageQueue.size})") + drainQueue() } /** Replay (and clear) every buffered message to the current sink, in order. */ @Synchronized private fun drainQueue() { - val sink = messageSink ?: return + val sink = messageSink + if (sink == null) { + Log.d(TAG, "drainQueue: no sink attached; keeping ${messageQueue.size} message(s) buffered") + return + } + if (messageQueue.isEmpty()) { + Log.d(TAG, "drainQueue: nothing to replay") + return + } + Log.d(TAG, "drainQueue: replaying ${messageQueue.size} buffered message(s) to sink") while (messageQueue.isNotEmpty()) { val (label, msg) = messageQueue.first() try { + Log.d(TAG, "drainQueue: delivering buffered [$label]: $msg") sink(label, msg) } catch (e: Exception) { // The sink is stale/dead (e.g. the app process was killed); @@ -398,7 +595,7 @@ class SignalService : Service() { } /** - * Content for a per-message notification produced by a [NotificationPresenter]. + * Title + text for one state of the ongoing foreground notification. */ data class NotificationContent( val title: String?, @@ -406,12 +603,35 @@ data class NotificationContent( ) /** - * Generic seam that decides how (or whether) to present a notification for an - * inbound data-channel message while the app is backgrounded. Returning `null` - * suppresses the notification. Consumers (the RN module / wallet) fill this in - * with their own per-message-type copy, keeping message semantics out of the - * shared signaling library. + * Consumer-supplied copy for the single ongoing foreground-service + * notification, whose text reflects the service state while the app is closed: + * - [connected]: app foregrounded / attached (e.g. "Connected …"). + * - [idle]: app closed with nothing waiting (e.g. "Tap to open the app"). + * - [messages]: deliverable message(s) arrived while the app was closed + * (e.g. "You have new messages"). + * + * [suppressChannels] lists channel labels whose inbound messages should NOT + * flip the notification into the [messages] state (control traffic such as the + * stream channel) — they are still buffered/replayed, just not announced. The + * consumer owns all copy so the shared signaling library stays + * content-agnostic; any field left null leaves that state's text unchanged. */ -fun interface NotificationPresenter { - fun present(label: String, message: String): NotificationContent? -} +data class NotificationStatus( + val connected: NotificationContent? = null, + val idle: NotificationContent? = null, + val messages: NotificationContent? = null, + val suppressChannels: Set = emptySet() +) + +/** + * Optional heartbeat keep-alive configuration. While the consuming app is + * offline the [SignalService] replies to an inbound [ping] frame on [channel] + * with a [pong] on the same channel, so the peer's liveness watchdog stays + * satisfied and keeps the connection open. Supplied by the consumer so the + * shared library never hardcodes channel labels or message tokens. + */ +data class HeartbeatConfig( + val channel: String, + val ping: String, + val pong: String +) diff --git a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/VENDORED.md b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/VENDORED.md index 3d990e6..243a5ac 100644 --- a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/VENDORED.md +++ b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/VENDORED.md @@ -91,3 +91,109 @@ consumer passes the channel labels — keeping `liquid` pure while the app contr the signaling delivery state. Byte-for-byte identical across the three trees (only the two pre-existing `handleMessages` doc-comment lines in `liquid-auth-android` differ). + +## Heartbeat keep-alive (kept in sync, upstream → copy) + +While the app is offline the JS heartbeat ping/pong reply (in the wallet's +`lib/ac2/heartbeat.ts` and its interval sender) is dead, so the peer's +liveness watchdog would stop seeing pongs and close the p2p connection even +though the foreground service is still alive. To fix this `SignalService` +gained an optional `HeartbeatConfig(channel, ping, pong)` (top-level data class) +and a `handleMessages(..., heartbeat)` parameter: in the offline branch, when an +inbound frame on `channel` equals `ping`, the service replies `pong` on the same +channel natively (and neither queues nor notifies for it). With no config the +behavior is disabled (legacy). The RN module builds it from +`connect(options.heartbeat)` and the wallet passes +`{ channel: "ac2-heartbeat", ping: "ping", pong: "pong" }`, so the shared +library never hardcodes channel labels or tokens. Byte-for-byte identical +across the three trees (only the two pre-existing `handleMessages` +doc-comment lines in `liquid-auth-android` differ). + +## Stale channel-close fix (kept in sync, upstream → copy) + +`PeerApi.destroy()` now **unregisters each data channel's observer before +closing it**. Closing a channel drives it to `CLOSING`/`CLOSED`, and a +still-registered observer reports that state through the module-wide +`onStateChange` sink — which is keyed only by channel **label**. When a previous +peer is torn down while a NEW negotiation for the same labels (e.g. `ac2-v1`) is +already live (e.g. on the reconnect/relaunch after killing the app, where +`SignalService.start()` re-inits the client and `SignalClient.disconnect()` +destroys the old peer), those stale `CLOSED` events were mis-delivered to the +new channel's shim and closed a healthy connection — surfacing in the wallet as +`Data channel closed` → `Connection lost (channel)` immediately after the +channels opened. Detaching the observer first makes a destroyed peer go silent, +so only the current negotiation's real state changes reach JS. Byte-for-byte +identical across `liquid-auth-android`, `react-native-liquid-auth`, and the +wallet's vendored copy. +## Preserve live connection + re-attach + resume-on-tap (kept in sync, upstream → copy) + +Three related changes so the background service *stays connected* while the app +re-attaches (rather than restarting) and the connected banner reopens the app in +place: + +- **`start()` preserves the live client.** It no longer `disconnect()`s and + rebuilds `SignalClient` when one already exists — it only builds a client when + there is none (first start / after an explicit `disconnect()`/`stop()`). So the + app calling `start()` again on relaunch/foreground does NOT tear down the live + peer the service was keeping alive. +- **`getConnectionState()` + `attach(...)`.** `getConnectionState()` returns a + read-only snapshot (`connected`, `requestId`, `iceConnectionState`, per-channel + `channels`) so a re-attaching app can hydrate instead of assuming a fresh + start. `attach(...)` rebinds the socket/peer callbacks to the fresh JS runtime, + re-registers the data-channel observers (via `handleMessages`) and re-emits the + current channel + ICE state (observers only fire on transitions, so a + live-but-unchanged channel is re-announced explicitly). `peer()` records the + bound `requestId`; `stop()` clears it. +- **`createPendingIntent` resumes the task.** It now builds a + `FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_SINGLE_TOP` intent (via + `PendingIntent.getActivity`) instead of `TaskStackBuilder`, whose implicit + `FLAG_ACTIVITY_CLEAR_TASK` cleared the task and relaunched a fresh activity — + which reset the JS runtime (the app "opened fresh", losing state) and, with + expo-router, produced the "linking configured in multiple places" error from + two concurrent NavigationContainers. With the activity's `singleTask` + launchMode, tapping the banner now resumes the running instance and preserves + its state. + +Byte-for-byte identical across `liquid-auth-android`, `react-native-liquid-auth`, +and the wallet's vendored copy (only the two pre-existing `createPendingIntent`/ +`handleMessages` doc-comment lines in `liquid-auth-android` differ). + +## Notification status model & hydrate message-replay buffering (2026-07-23) + +Applied two fixes for robust connection hydrate and stateful notifications: + +- **Notification status:** the `NotificationPresenter` was replaced by a stateful `NotificationStatus` (connected/idle/messages). The single ongoing notification now reflects the service state natively while the app is backgrounded, updating to "Tap to open" when closed idle and "You have new messages" when messages arrived while closed. +- **RTC shim buffering:** the `NativeDataChannel` (TS shim) now buffers inbound messages until a consumer (`onmessage` or a `message` listener) is attached. This ensures that the background service's offline-queue replay during `attach()` is not dropped if the SDK client hasn't wired the channel handlers yet. +- **RTC shim deferred flush:** the RTC shim `NativeDataChannel` now flushes its buffered offline-replay on a MICROTASK (deferred) rather than synchronously when `onmessage`/a `message` listener is attached, and buffers live frames while a flush is pending to preserve arrival order — this fixes replayed messages being dropped because the AC2 SDK's `rtcDataChannelTransport` assigns `onmessage` before registering its real inbound handlers. Applied byte-identically across the wallet's `lib/ac2` (runtime source of truth), the package `src`, and the vendored module `src` (D7). +- **`attach()` marks the app active (2026-07-23):** `SignalService.attach(...)` now sets `appActiveOverride = true` up front (before `handleMessages` captures the fresh sink) so the offline-queue replay on hydrate always runs. Previously the replay depended on the consumer's `setActive(true)` timing, and a late/racing `onUnbind` from the previous binding (torn down on relaunch) could reset `appActiveOverride` to `null` after the app went online, causing `handleMessages` to skip `drainQueue()` and strand every message buffered while the app was closed. Ported byte-identically to the package copy and upstream `liquid-auth-android` (D7; upstream keeps only its pre-existing KDoc differences). + +Byte-for-byte identical across the upstream `liquid-auth-android` (SignalService), the `react-native-liquid-auth` package, and the wallet's vendored copy (D7). + +## Consumer-driven offline-queue replay (`flushQueue`, 2026-07-23) + +`setActive(true)` no longer auto-replays the offline queue, and the service +gained a public `flushQueue()` (exposed as `Function("flushQueue")` on the RN +module) so the CONSUMER decides when the replay fires. + +Why: the JS VM can survive an app relaunch (the next "Running main" reuses the +same process/runtime), and the previous session's channel shims + native +message listeners are intentionally preserved across a swipe-away (so the live +peer isn't torn down). On relaunch the app calls `setActive(true)` during +socket setup — BEFORE the fresh transport wires its listeners — so the +auto-replay fired into the previous session's stale handlers, which silently +drop everything (their run is marked inactive). The buffered messages were +consumed and lost; the fresh session saw nothing. + +Now the replay only happens when a fresh sink attaches (`handleMessages` via +`connect`/`attach`, unchanged) or when the consumer explicitly calls +`flushQueue()` once its listeners are wired. The wallet calls it (a) after a +transport negotiation completes (all channel consumers wired) and (b) on a +background → foreground transition while a live transport exists (same-runtime +handlers still valid). The wallet also detaches the dead tree's native +listeners/monitors in its preserve-path cleanups so stale sessions can't +swallow or duplicate future replays. + +Ported byte-identically across `liquid-auth-android`, the +`react-native-liquid-auth` package, and the wallet's vendored copy (upstream +keeps only its pre-existing KDoc differences; the wallet's vendored copy +additionally carries temporary debug logging). diff --git a/modules/react-native-liquid-auth/src/LiquidAuthNative.types.ts b/modules/react-native-liquid-auth/src/LiquidAuthNative.types.ts index 3d35fd5..658fe47 100644 --- a/modules/react-native-liquid-auth/src/LiquidAuthNative.types.ts +++ b/modules/react-native-liquid-auth/src/LiquidAuthNative.types.ts @@ -31,7 +31,7 @@ export interface DataChannelInit { /** * A single notification template the native background service uses to render - * a per-message-type notification while the app is backgrounded. + * the ongoing notification while the app is backgrounded. */ export interface NotificationTemplate { /** Notification title. When omitted, the ongoing notification's title is kept. */ @@ -41,27 +41,46 @@ export interface NotificationTemplate { } /** - * Configuration for the per-message notifications the native background - * service shows while the app is backgrounded (or its JS runtime is - * suspended/killed). The consumer owns all copy; the native service renders - * from this map, so notifications work even when the JS runtime is not - * running. Content is keyed by the message's `type` (see {@link typeKey}). + * Copy for the single ongoing foreground-service notification, whose text + * reflects the service state while the app is backgrounded (or its JS runtime + * is suspended/killed): connected (app foreground), idle (app closed with + * nothing waiting — "tap to open"), or messages (message[s] arrived while + * closed — "you have new messages"). The consumer owns all copy so the shared + * library stays content-agnostic; the native service renders it, so it works + * even when the JS runtime is not running. */ export interface NotificationConfig { /** - * Channel labels to never notify for (e.g. `ac2-heartbeat` / `ac2-stream` - * control traffic). + * Channel labels whose inbound messages do NOT flip the notification into + * the `messages` state (control traffic such as `ac2-heartbeat`/`ac2-stream`). + * They are still buffered/replayed, just not announced. */ suppressChannels?: string[]; - /** JSON field in the message used to select a template (default `type`). */ - typeKey?: string; - /** Per-message-type templates, keyed by the value of {@link typeKey}. */ - templates?: Record; - /** - * Fallback template used when the message's type matches no entry in - * {@link templates}. Omit to suppress unmatched messages entirely. - */ - fallback?: NotificationTemplate; + /** Ongoing notification while the app is foreground/connected. */ + connected?: NotificationTemplate; + /** Ongoing notification while the app is closed with no pending messages. */ + idle?: NotificationTemplate; + /** Ongoing notification while the app is closed with pending messages. */ + messages?: NotificationTemplate; +} + +/** + * Heartbeat keep-alive configuration. While the app is offline (its JS runtime + * is suspended / it has been backgrounded or closed) the background service + * itself answers the peer's keepalive `ping` on {@link channel} with a `pong`, + * so the peer's liveness watchdog stays satisfied and does not tear the + * connection down while the app is away. The JS ping/pong reply only runs while + * the app is foregrounded, so this native reply covers the backgrounded case. + * The consumer supplies the channel + tokens so the shared library never + * hardcodes them. + */ +export interface HeartbeatConfig { + /** The data-channel label the keepalive ping/pong is exchanged on. */ + channel: string; + /** The inbound token that triggers a reply (default `ping`). */ + ping?: string; + /** The token sent back in reply (default `pong`). */ + pong?: string; } /** @@ -76,9 +95,9 @@ export interface LiquidAuthConnectOptions { */ dataChannels?: Record; /** - * Per-message-type notification content shown natively while the app is - * backgrounded. When omitted, the native service falls back to showing the - * raw message text for every channel. + * Notification content: the ongoing notification reflects the connected / + * idle ("tap to open") / new-messages states. When omitted, the native + * service falls back to showing the raw message text for every channel. */ notifications?: NotificationConfig; /** @@ -90,6 +109,13 @@ export interface LiquidAuthConnectOptions { * `ac2-stream`) and skip pure control traffic (e.g. `ac2-heartbeat`). */ queueChannels?: string[]; + /** + * Heartbeat keep-alive: while the app is offline the background service + * answers the peer's keepalive `ping` on the given channel with a `pong`, + * so the connection survives being backgrounded (the JS ping/pong reply is + * dead then). When omitted, no native keep-alive is performed. + */ + heartbeat?: HeartbeatConfig; } /** @@ -176,6 +202,29 @@ export interface LiquidAuthResponse { body: string; } +/** + * A snapshot of the background service's CURRENT connection, returned by + * `getConnectionState`, so a re-attaching app can hydrate its UI (instead of + * assuming a fresh start / showing "Connecting…") when it reconnects to a + * still-running service. + */ +export interface LiquidAuthConnectionState { + /** Whether a peer connection with negotiated data channels currently exists. */ + connected: boolean; + /** The `requestId` the live connection is bound to, or `null` when none. */ + requestId: string | null; + /** + * The peer's ICE connection state (`CONNECTED`, `DISCONNECTED`, `FAILED`, + * ...), or `null` when there is no peer connection. + */ + iceConnectionState: string | null; + /** + * Each negotiated channel's current state (`OPEN`, `CLOSING`, `CLOSED`, ...), + * keyed by channel label. + */ + channels: Record; +} + /** * Events emitted by the native module. */ diff --git a/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.ts b/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.ts index 80afab8..c1e361c 100644 --- a/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.ts +++ b/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.ts @@ -2,6 +2,7 @@ import { NativeModule, requireNativeModule } from 'expo'; import { IceServer, + LiquidAuthConnectionState, LiquidAuthConnectOptions, LiquidAuthMessage, LiquidAuthNativeModuleEvents, @@ -41,6 +42,23 @@ declare class LiquidAuthNativeModule extends NativeModule; + /** + * Snapshot the background service's CURRENT connection so a re-attaching app + * can hydrate instead of assuming a fresh start. Safe to call before the + * service is bound (returns `connected: false`). + */ + getConnectionState(): LiquidAuthConnectionState; + + /** + * Re-attach to the ALREADY-live connection without renegotiating: rebind the + * event listeners to this (fresh) JS runtime and re-emit the current channel + * + ICE state so the app hydrates. Use when {@link getConnectionState} + * reports `connected: true` (e.g. after a relaunch that reconnected to the + * still-running service). `options` carries the same + * `notifications`/`queueChannels`/`heartbeat` config as {@link connect}. + */ + attach(options?: LiquidAuthConnectOptions): Promise; + /** * Abort an in-flight {@link connect} negotiation. The pending `connect` * promise rejects with an `E_ABORTED` error. @@ -49,12 +67,21 @@ declare class LiquidAuthNativeModule extends NativeModule throw new Error(UNSUPPORTED); } + getConnectionState(): LiquidAuthConnectionState { + return { connected: false, requestId: null, iceConnectionState: null, channels: {} }; + } + + async attach(_options?: LiquidAuthConnectOptions): Promise { + throw new Error(UNSUPPORTED); + } + async cancel(): Promise { throw new Error(UNSUPPORTED); } @@ -41,6 +50,10 @@ class LiquidAuthNativeModule extends NativeModule throw new Error(UNSUPPORTED); } + flushQueue(): void { + throw new Error(UNSUPPORTED); + } + send(_message: string): void { throw new Error(UNSUPPORTED); } diff --git a/modules/react-native-liquid-auth/src/index.ts b/modules/react-native-liquid-auth/src/index.ts index 9296bc4..e11a763 100644 --- a/modules/react-native-liquid-auth/src/index.ts +++ b/modules/react-native-liquid-auth/src/index.ts @@ -1,5 +1,6 @@ import type { IceServer, + LiquidAuthConnectionState, LiquidAuthConnectionStateEvent, LiquidAuthConnectOptions, LiquidAuthLinkErrorEvent, @@ -54,11 +55,15 @@ export function start(url: string): Promise { * * Pass `options.dataChannels` to open multiple named data channels (e.g. * `ac2-v1`, `ac2-stream`) when acting as the offerer (`type: 'answer'`). - * Pass `options.notifications` to customize (or suppress) the per-message-type - * notifications the background service shows while the app is backgrounded. + * Pass `options.notifications` to customize (or suppress) the ongoing + * notification, which reflects the connected / idle ("tap to open") / + * new-messages states. * Pass `options.queueChannels` to choose which channels the service buffers * while the app is offline (replayed via `onMessage` once online; see * {@link setActive}). + * Pass `options.heartbeat` to have the background service answer the peer's + * keepalive `ping` with a `pong` natively while the app is offline, so the + * connection survives being backgrounded. */ export function connect( requestId: string, @@ -69,6 +74,28 @@ export function connect( return LiquidAuthNativeModule.connect(requestId, type, iceServers, options); } +/** + * Snapshot the background service's CURRENT connection so a re-attaching app + * can hydrate its UI (instead of assuming a fresh start) when it reconnects to + * a still-running service. Safe to call before {@link start} (returns + * `connected: false`). + */ +export function getConnectionState(): LiquidAuthConnectionState { + return LiquidAuthNativeModule.getConnectionState(); +} + +/** + * Re-attach to the ALREADY-live connection without renegotiating: rebind the + * event listeners to this (fresh) JS runtime and re-emit the current channel + + * ICE state so the app hydrates. Use when {@link getConnectionState} reports + * `connected: true` (e.g. after a relaunch that reconnected to the + * still-running background service). `options` carries the same + * `notifications`/`queueChannels`/`heartbeat` config as {@link connect}. + */ +export function attach(options?: LiquidAuthConnectOptions): Promise { + return LiquidAuthNativeModule.attach(options); +} + /** * Abort an in-flight {@link connect} negotiation. The pending `connect` * promise rejects with an `E_ABORTED` error. @@ -79,15 +106,27 @@ export function cancel(): Promise { /** * Set whether the app is currently online (foregrounded, with its JS listeners - * attached). When set active, any messages the background service buffered - * while the app was offline are replayed through the `onMessage` event in - * arrival order. Drive this from the app's foreground/background lifecycle so - * the app — not the library — controls the signaling delivery state. + * attached). Drive this from the app's foreground/background lifecycle so the + * app — not the library — controls the signaling delivery state. Deliberately + * does NOT replay the offline queue (a relaunching app flips active before its + * listeners are rewired); replay happens when a fresh sink attaches + * ({@link connect} / {@link attach}) or via an explicit {@link flushQueue}. */ export function setActive(active: boolean): void { return LiquidAuthNativeModule.setActive(active); } +/** + * Explicitly replay any messages the background service buffered while the app + * was offline, through the `onMessage` event in arrival order. Call it only + * once the JS message listeners are wired (e.g. right after a foreground + * transition with a live transport, or after a negotiation completes), so the + * replay can't race the listener setup. No-op when nothing is buffered. + */ +export function flushQueue(): void { + return LiquidAuthNativeModule.flushQueue(); +} + /** * Send a message over the primary (`liquid`) data channel. */ diff --git a/modules/react-native-liquid-auth/src/nativeChannel.ts b/modules/react-native-liquid-auth/src/nativeChannel.ts index 4e6dda0..ef59876 100644 --- a/modules/react-native-liquid-auth/src/nativeChannel.ts +++ b/modules/react-native-liquid-auth/src/nativeChannel.ts @@ -63,11 +63,30 @@ export class NativeDataChannel { onopen: ((ev?: unknown) => void) | null = null; onclose: ((ev?: unknown) => void) | null = null; onerror: ((ev?: unknown) => void) | null = null; - onmessage: ((ev: DataChannelMessageEvent) => 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; @@ -78,6 +97,16 @@ export class NativeDataChannel { 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); @@ -99,6 +128,8 @@ export class NativeDataChannel { 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 { @@ -107,15 +138,65 @@ export class NativeDataChannel { /** 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); + if (this._hasMessageConsumer()) this._scheduleFlush(); + return; + } + this._deliver(message); + } + + /** Deliver a single message to the attached consumer(s). */ + private _deliver(message: string): void { const event: DataChannelMessageEvent = { data: message }; try { - this.onmessage?.(event); + 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); + 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 From 5ed29802ae1e7f51ad8445fcd52b20462b385a11 Mon Sep 17 00:00:00 2001 From: Michael J Feher Date: Fri, 24 Jul 2026 13:00:45 -0400 Subject: [PATCH 9/9] wip: harden connection lifecylcle with state machine for connectivity and idle session handler --- .../components/chat/chat-reconnect.test.tsx | 5 +- __tests__/lib/ac2/connectionMachine.test.ts | 637 ++++++ __tests__/lib/ac2/idleSession.test.ts | 122 ++ __tests__/lib/ac2/nativeTransport.test.ts | 68 +- components/chat/ChatScreen.tsx | 11 +- hooks/useConnection.ts | 1786 ++++++++--------- lib/ac2/connectionMachine.ts | 627 ++++++ lib/ac2/idleSession.ts | 82 + lib/ac2/index.ts | 5 + lib/ac2/nativeTransport.ts | 57 +- modules/react-native-liquid-auth/VENDORED.md | 4 + .../algorand/liquid/LiquidAuthNativeModule.kt | 21 +- .../algorand/auth/connect/SignalClient.kt | 88 +- .../algorand/auth/connect/SignalService.kt | 24 +- .../ios/LiquidAuthNativeModule.swift | 65 +- .../ios/LiquidAuthSDK/SignalClient.swift | 175 +- .../ios/LiquidAuthSDK/SignalService.swift | 151 +- .../src/LiquidAuthNative.types.ts | 18 + .../src/LiquidAuthNativeModule.web.ts | 8 +- modules/react-native-liquid-auth/src/index.ts | 15 + 20 files changed, 2875 insertions(+), 1094 deletions(-) create mode 100644 __tests__/lib/ac2/connectionMachine.test.ts create mode 100644 __tests__/lib/ac2/idleSession.test.ts create mode 100644 lib/ac2/connectionMachine.ts create mode 100644 lib/ac2/idleSession.ts diff --git a/__tests__/components/chat/chat-reconnect.test.tsx b/__tests__/components/chat/chat-reconnect.test.tsx index c40decb..f410338 100644 --- a/__tests__/components/chat/chat-reconnect.test.tsx +++ b/__tests__/components/chat/chat-reconnect.test.tsx @@ -63,7 +63,6 @@ function baseConnection() { peerOffline: false, isSocketConnected: true, reconnectAttempt: 0, - maxReconnectAttempts: 3, send: jest.fn(), sendAc2: jest.fn(), lastHeartbeat: Date.now(), @@ -104,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(); 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/nativeTransport.test.ts b/__tests__/lib/ac2/nativeTransport.test.ts index a3bf874..8e20581 100644 --- a/__tests__/lib/ac2/nativeTransport.test.ts +++ b/__tests__/lib/ac2/nativeTransport.test.ts @@ -1,10 +1,13 @@ 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. */ @@ -51,7 +54,7 @@ function createFakeNative(): FakeNative { return { remove: () => set.delete(l) }; }; const emit = (set: Set<(e: any) => void>, e: any) => { - for (const l of [...set]) l(e); + for (const l of set) l(e); }; const api: LiquidAuthNativeApi = { @@ -380,6 +383,69 @@ describe('flushNativeQueue', () => { }); }); +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(); diff --git a/components/chat/ChatScreen.tsx b/components/chat/ChatScreen.tsx index db815a9..4f7e76e 100644 --- a/components/chat/ChatScreen.tsx +++ b/components/chat/ChatScreen.tsx @@ -51,7 +51,6 @@ function ChatScreen({ origin, requestId, allowPasskeyCreation = false }: ChatScr peerOffline, isSocketConnected, reconnectAttempt, - maxReconnectAttempts, send, sendAc2, lastHeartbeat, @@ -330,14 +329,20 @@ function ChatScreen({ origin, requestId, allowPasskeyCreation = false }: ChatScr // 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 ? ( diff --git a/hooks/useConnection.ts b/hooks/useConnection.ts index 09ac322..6ce67fa 100644 --- a/hooks/useConnection.ts +++ b/hooks/useConnection.ts @@ -8,12 +8,14 @@ import type { } from '@/lib/ac2'; import { addNativePresenceListener, + addNativeSignalingStateListener, attachHeartbeatChannel, cancelNativeNegotiation, createAc2Client, createHeartbeatMonitor, createNativeAc2Transport, DEFAULT_THID, + evaluateIdleSession, flushNativeQueue, generateThid, getNativeConnectionState, @@ -21,6 +23,7 @@ import { isPeerRejectedError, isPeerUnreachableError, isRegistrationBlockingNotice, + isSnapshotChannelOpen, monitorPeerConnection, nativeAuthFetch, selectConnectionNoticeForRequest, @@ -30,6 +33,18 @@ import { 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'; @@ -57,10 +72,6 @@ import { useStore } from '@tanstack/react-store'; import { useCallback, useEffect, useRef, useState } from 'react'; 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 @@ -83,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; @@ -110,7 +116,7 @@ interface UseConnectionResult { */ peerPresence: PresenceResult | null; /** - * True when a (re)connect gave up because the peer isn't present in the + * 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. */ @@ -126,12 +132,14 @@ interface UseConnectionResult { 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. */ @@ -170,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, @@ -178,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); @@ -187,25 +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 to re-trigger the p2p transport negotiation effect on demand - // (manual/auto reconnect, presence-driven renegotiation). Does NOT rebuild - // the persistent socket. - const [reconnectNonce, setReconnectNonce] = useState(0); - // Bumped to rebuild the persistent signaling socket after it was fully torn - // down (an explicit disconnect via `reset`). Transient socket.io drops - // auto-reconnect without a rebuild, so this is only used for the "reconnect - // after an explicit disconnect" path. - const [socketNonce, setSocketNonce] = useState(0); + // Mirrors `userStoppedRef` for rendering (initial-loading derivation below). + const [userStopped, setUserStopped] = useState(false); const dataChannelRef = useRef(null); const streamChannelRef = useRef(null); @@ -216,8 +271,8 @@ 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); // True once the native foreground signaling service has been started (the // analog of the persistent `SignalClient` socket). Kept alive across p2p chat @@ -231,21 +286,21 @@ export function useConnection( // 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. @@ -260,42 +315,27 @@ export function useConnection( 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). Handled outside the SignalClient, on the socket, via the - // dedicated `presence` websocket event. Used to detect whether there is - // anyone available to (re)connect to. + // connected). Subscribed on the persistent native service; each broadcast is + // mapped to a `PEER_PRESENT`/`PEER_ABSENT` machine event. const [peerPresence, setPeerPresence] = useState(null); - // Live mirror of `peerPresence` so the failure funnel can read the latest - // snapshot synchronously (without re-subscribing on every presence update) - // when deciding whether a connection failure means the peer is offline. const peerPresenceRef = useRef(null); peerPresenceRef.current = peerPresence; - // True when we've given up (re)connecting because the peer isn't in the - // requestId room. Surfaced inline in the chat window (a clean banner over the - // composer) rather than as a disruptive pop-up, so the user knows to check - // their remote device. Cleared on a fresh (re)connect and on a successful - // connect. - const [peerOffline, setPeerOffline] = useState(false); // Whether the signaling socket itself is connected to the Liquid Auth - // service. Owned by the persistent socket effect and kept alive across p2p - // chat drops, so presence checks and renegotiation keep working. Surfaced as - // "Service unavailable" in the chat UI when false. + // 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); - const isSocketConnectedRef = useRef(false); - isSocketConnectedRef.current = isSocketConnected; - // Disposer for the socket-level `presence` subscription (lives with the - // socket, not the transport). + // 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 (socket effect), so the - // per-negotiation transport's presence disposer is a no-op; this ref is kept - // only for symmetry with the socket teardown 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); - // True once the persistent socket is established AND connected, so p2p - // negotiation may be attempted (subject to the both-peers-present gate). - const socketReadyRef = useRef(false); - // True while a p2p transport negotiation is in flight, so presence/reconnect - // triggers never stack a second concurrent negotiation on the shared socket. - const transportInFlightRef = useRef(false); // Threads the agent advertised on connect (`conversations` control frame). const [remoteThreads, setRemoteThreads] = useState< { thid: string; title?: string; updatedAt?: number }[] @@ -336,14 +376,19 @@ export function useConnection( 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((options?: { preserveNativePeer?: boolean }) => { // Stop the liveness watchdog and detach the connectivity monitor before @@ -395,7 +440,7 @@ export function useConnection( // 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 the socket effect (see `closeSocket`). + // by `closeSocket`. // // Detach this negotiation's native listeners (message/state/ICE) so reusing // the service for the next attempt doesn't accumulate duplicate handlers. @@ -425,10 +470,10 @@ export function useConnection( } }, []); - // Fully tear down the persistent signaling socket (and its presence + // 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 - // socket survives p2p reconnects. + // service survives p2p reconnects. const closeSocket = useCallback(() => { if (presenceUnsubRef.current) { try { @@ -438,6 +483,14 @@ export function useConnection( } presenceUnsubRef.current = null; } + if (signalingUnsubRef.current) { + try { + signalingUnsubRef.current(); + } catch { + /* noop */ + } + signalingUnsubRef.current = null; + } if (transportPresenceUnsubRef.current) { try { transportPresenceUnsubRef.current(); @@ -446,7 +499,6 @@ export function useConnection( } transportPresenceUnsubRef.current = null; } - socketReadyRef.current = false; setIsSocketConnected(false); if (nativeStartedRef.current) { nativeStartedRef.current = false; @@ -464,321 +516,57 @@ export function useConnection( 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 (connect / failure) stay in place, - // ready to re-enable if the native module later surfaces peer stats. + // 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(); - // An explicit user disconnect also drops the persistent signaling socket: + 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); - setPeerOffline(false); updateSessionStatus(requestId, origin, 'closed'); - }, [requestId, origin, clearTransport, closeSocket]); - - // 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((options?: { preserveNativePeer?: boolean }) => { - deliberateCloseRef.current = true; - if (autoReconnectTimerRef.current) { - clearTimeout(autoReconnectTimerRef.current); - autoReconnectTimerRef.current = null; - } - clearTransport(options); - 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); - // A fresh attempt is starting — clear any "peer offline" notice. - setPeerOffline(false); - 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); - if (!nativeStartedRef.current) { - // The persistent native service was fully torn down (an explicit - // disconnect): rebuild it. The socket effect re-authenticates, restarts - // the service, and drives presence-gated p2p negotiation once both peers - // are present again. - setError(null); - setPeerOffline(false); - setIsConnected(false); - setIsLoading(true); - setSocketNonce((n) => n + 1); - return; - } - performReconnect(); - }, [performReconnect]); - 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(); - // A room refusal (two-peer lockdown: the session already has its two - // devices) is TERMINAL — retrying can't free a slot, so short-circuit the - // auto-reconnect budget and surface an actionable "session full" message - // at once instead of hammering the server with rejected attempts. - if (isPeerRejectedError(error)) { - setIsReconnecting(false); - setIsLoading(false); - setPeerOffline(false); - if (error) setError(error); - Alert.alert( - 'Session Full', - error?.message || - 'This session already has the maximum number of devices connected. Ask the other device to disconnect and try again.', - [{ text: 'OK' }], - ); - return; - } - // 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); - // Distinguish "the peer simply isn't there" from a generic failure so - // the user gets an actionable message instead of a cryptic timeout. - // The peer is deemed offline when the signaling server reports nobody - // but us in the requestId room (presence) or when the negotiation timed - // out waiting for the peer's answer-description. - const peerIsOffline = - isPeerOffline(peerPresenceRef.current) || isPeerUnreachableError(error); - if (peerIsOffline) { - // Surface this inline in the chat window (see ChatScreen) rather than - // as a pop-up: tell the user the chat can't connect and that they - // should check their remote device. - if (error) setError(error); - setPeerOffline(true); - } else 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; - // Current `requestId`, mirrored so the stable (deps: []) `maybeNegotiate` - // callback can match the background service's live connection without a - // stale closure. - const requestIdRef = useRef(requestId); - requestIdRef.current = requestId; - - // Attempt a p2p (re)negotiation IFF it is safe and worthwhile. Peers must not - // negotiate without knowing they both exist, so this only proceeds when the - // persistent socket is connected, we aren't already connected/negotiating, - // and the signaling server reports the peer present in the requestId room. - // When the peer is absent it simply waits — the next `presence` broadcast (or - // a manual Reconnect) re-invokes this once both parties are back in the room. - const maybeNegotiate = useCallback(() => { - // Log why a negotiation attempt is (or isn't) started. When the app is - // stuck on "Connecting…" after the agent restarts, this pinpoints whether - // the wallet even TRIED to negotiate — and if not, exactly which gate held - // it back (socket not ready, already connecting, peer not present, etc.). - if (userStoppedRef.current) { - console.log('[ac2] maybeNegotiate: skipped — user stopped the session'); - return; - } - if (!socketReadyRef.current) { - console.log('[ac2] maybeNegotiate: skipped — signaling socket not ready'); - return; - } - if (isConnectedRef.current || transportInFlightRef.current) { - console.log( - `[ac2] maybeNegotiate: skipped — ${ - isConnectedRef.current ? 'already connected' : 'a negotiation is already in flight' - }`, - ); - return; - } - if (autoReconnectTimerRef.current) { - console.log('[ac2] maybeNegotiate: skipped — an auto-reconnect is already pending'); + setUserStopped(false); + setError(null); + if (machineRef.current.phase === 'stopped') { + dispatchRef.current({ type: 'START' }); return; } - // If the background service ALREADY holds a live connection for this - // `requestId` (it survived app close / backgrounding), re-attach to it - // immediately — regardless of the presence gate below. A live data channel - // is itself proof both peers are connected, and on a cold relaunch presence - // can't even reach us yet: the service's presence broadcasts only start - // routing to this fresh JS runtime once attach() rebinds the listener, so - // waiting for a presence broadcast here would deadlock the hydration and - // strand the UI on "Connecting…". createNativeAc2Transport takes the attach - // (no-renegotiate) path in this case, preserving the live p2p connection. - try { - const nativeState = getNativeConnectionState(); - if (nativeState.connected && nativeState.requestId === requestIdRef.current) { - console.log( - '[ac2] maybeNegotiate: background service already holds a live connection — attaching (hydrate)', - ); - setPeerOffline(false); - // Re-run setup so `negotiateTransport` -> `createNativeAc2Transport` - // takes the `attach()` (no-renegotiate) branch. Crucially, PRESERVE the - // live native peer: the default reconnect cancels it, which would close - // the very connection we want to hydrate and downgrade this into a full - // renegotiation (the relaunch "reconnect" symptom). - performReconnectRef.current({ preserveNativePeer: true }); - return; - } - } catch { - // Native module unavailable (tests / web / not yet started) — fall through - // to the normal presence-gated negotiation. + 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' }); } - // Both peers must be present (deviceCount >= 2) before we negotiate p2p. - if (isPeerOffline(peerPresenceRef.current)) { - console.log( - `[ac2] maybeNegotiate: waiting — peer not present (deviceCount=${ - peerPresenceRef.current?.deviceCount ?? 'unknown' - }); will retry on the next presence broadcast`, - ); - setIsLoading(false); - setPeerOffline(true); - return; - } - console.log( - `[ac2] maybeNegotiate: peer present (deviceCount=${ - peerPresenceRef.current?.deviceCount ?? 'unknown' - }) — starting p2p (re)negotiation`, - ); - performReconnectRef.current(); }, []); - const maybeNegotiateRef = useRef(maybeNegotiate); - maybeNegotiateRef.current = maybeNegotiate; - - // The signaling server reports the peer has left the requestId room (presence - // deviceCount dropped to just us). Presence is authoritative and immediate, so - // proactively tear down the p2p transport and surface a clean inline "Peer - // offline" notice right away, instead of waiting out the heartbeat/ICE - // watchdog — the heartbeat only keeps a LIVE connection alive while BOTH peers - // are online. We do NOT schedule a reconnect here: with the peer gone there is - // nothing to connect to. The next presence broadcast showing both peers back - // in the room drives renegotiation via `maybeNegotiate`. - const handlePeerOffline = useCallback(() => { - // Respect an explicit user disconnect — nothing to keep present for. - if (userStoppedRef.current) return; - console.log( - `[ac2] handlePeerOffline: peer left the room (deviceCount=${ - peerPresenceRef.current?.deviceCount ?? 'unknown' - }) — tearing down p2p, keeping socket; awaiting peer to return`, - ); - // Cancel any pending automatic reconnect: retrying is pointless while the - // peer is absent and would otherwise flip the UI back into "Connecting…". - if (autoReconnectTimerRef.current) { - clearTimeout(autoReconnectTimerRef.current); - autoReconnectTimerRef.current = null; - } - reconnectAttemptRef.current = 0; - setReconnectAttempt(0); - setIsReconnecting(false); - // Tear down ONLY the p2p peer/data-channels (the persistent socket and its - // presence subscription stay alive so we keep receiving broadcasts). Flag - // the teardown as deliberate so the channel `onClose` doesn't re-enter the - // failure/auto-reconnect path. - deliberateCloseRef.current = true; - clearTransport(); - setIsConnected(false); - setIsLoading(false); - setPeerOffline(true); - }, [clearTransport]); - const handlePeerOfflineRef = useRef(handlePeerOffline); - handlePeerOfflineRef.current = handlePeerOffline; + const reconnectRef = useRef(reconnect); + reconnectRef.current = reconnect; // 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; @@ -789,6 +577,9 @@ 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 @@ -808,9 +599,9 @@ export function useConnection( // 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 (see - // `negotiateTransport`) once fresh handlers are wired. - if (nextState === 'active' && isConnectedRef.current) { + // the queue buffered: the next negotiation replays it once fresh + // handlers are wired. + if (nextState === 'active' && machineRef.current.phase === 'connected') { try { flushNativeQueue(); } catch { @@ -825,33 +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 p2p transport negotiation is already running, we're in the - // loading/connecting state, an auto-reconnect sequence is running, or a - // retry timer is already pending. NOTE: we no longer treat a live - // `clientRef` (the persistent socket) as "busy" — it is expected to stay - // connected across chat drops, and a resume only re-negotiates the peer. - if ( - authFlowInProgressRef.current || - transportInFlightRef.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); @@ -877,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({ @@ -957,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({ @@ -978,70 +762,99 @@ 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(); + 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 bounded auto-reconnect scheduler instead of - // hanging. + // 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 @@ -1075,363 +888,307 @@ export function useConnection( }); }; - 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; - // The persistent native service is already started for this session — the - // socket effect only starts it once (it survives p2p chat drops). - if (nativeStartedRef.current) { - return; - } - // Never resurrect a session the user explicitly disconnected. - if (userStoppedRef.current) { - 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'); - } - - const walletAddress = encodeAddress(foundKey.publicKey); - console.log('Found key for attestation:', foundKey.id, foundKey.type); + 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 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 walletAddress = encodeAddress(foundKey.publicKey); + console.log('Found key for attestation:', foundKey.id, foundKey.type); - 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); - } - } + 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); - // 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) { + 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'); } - } else { - const authResult = await authenticateLiquidAuth({ - origin, - requestId, - foundKey, - walletAddress, - currentKeys, - initialSessionData, - initialSessionAddress, - existingSessionPasskeyCredentialId: existingSession?.passkeyCredentialId, - allowPasskeyCreation, - key, - passkey, - setAddress, - addressRef, - authFlowInProgressRef, - fetchWithTimeout, - isActive: () => active, - }); - if (authResult.superseded || !active) return; + } catch (error) { + console.warn('Unable to parse existing auth session response:', error); } - 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`); + // 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, + foundKey, + walletAddress, + currentKeys, + initialSessionData, + initialSessionAddress, + existingSessionPasskeyCredentialId: existingSession?.passkeyCredentialId, + allowPasskeyCreation, + key, + passkey, + setAddress, + addressRef, + authFlowInProgressRef, + fetchWithTimeout, + isActive: () => active(), + }); + if (authResult.superseded || !active()) return; + } + console.log(`[ac2] auth phase done in ${Date.now() - setupStartedAt}ms`); - if (!active) return; + // Final validation of the session before connecting + const finalSessionCheck = await fetchWithTimeout(`${origin}/auth/session`); - if (finalSessionCheck.ok) { - const sessionData = await finalSessionCheck.json(); + if (!active()) return; - if (!active) return; + if (finalSessionCheck.ok) { + const sessionData = await finalSessionCheck.json(); - 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)'); - } + if (!active()) return; - // 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 old - // in-process socket.io client did). 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; - - // We are in the foreground here (setup runs from a user action / an - // active-app resume), so mark the service online. 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 (see `negotiateTransport`). - try { - setNativeActive(true); - } catch { - /* native module may not implement setActive on every platform yet */ + 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'); } - - // Presence lives with the persistent service (outside a single p2p - // negotiation) so it keeps working across chat drops and drives - // presence-gated renegotiation: 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: unlike the old socket path there is no active - // presence *query*, so the first negotiation decision is made once the - // first broadcast arrives rather than being seeded up-front. - const presenceSub = addNativePresenceListener((e) => { - if (!active) return; - 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; - if (isPeerOffline(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. Trust it: if the - // peer has dropped out of the room, bail out and tear down the p2p - // transport even if the data channel still looks live (a stale data - // channel to a departed peer is worse than reconnecting when they - // return). This holds whether or not a chat is currently live. - // - // The ONE case we deliberately do NOT treat as a peer drop is the - // signaling server itself going down while we still have a live data - // channel: that is an infrastructure problem, not a real departure, - // and the p2p connection can happily outlive the signaling server. - // But a server-down scenario produces NO presence broadcast (the - // socket is gone), so it never enters this branch — the data channel - // simply keeps running and a genuine p2p failure is caught by its - // own detectors (the heartbeat watchdog and ICE connectivity - // monitor). So reacting here only to actual presence-offline - // broadcasts is exactly the "trust presence, ignore server - // disconnects" behavior we want. - console.log( - `[ac2] presence shows peer offline (deviceCount=${presence.deviceCount}) — tearing down p2p (the signaling server is connected, so presence is authoritative)`, - ); - handlePeerOfflineRef.current(); - } else { - // Both peers are present: (re)negotiate the p2p transport. - setPeerOffline(false); - maybeNegotiateRef.current(); - } - }); - presenceUnsubRef.current = () => presenceSub.remove(); - - // The native service is up; treat that as "socket connected" for the - // chat surface. (The native module does not expose signaling-socket - // connect/disconnect events, so this stays true until the service is - // explicitly stopped; a transient native reconnect is transparent and - // re-drives negotiation via the presence broadcast above.) - setIsSocketConnected(true); - socketReadyRef.current = true; - console.log(`[ac2] socket phase done in ${Date.now() - setupStartedAt}ms`); - - // Attempt the first p2p negotiation (gated on both peers being present; - // if presence is not yet known it waits for the first broadcast). - maybeNegotiateRef.current(); - } catch (err: any) { - // A superseded run (cleanup 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 signaling socket:', err); - updateSessionStatus(requestId, origin, 'failed'); - setIsLoading(false); - setIsSocketConnected(false); - socketReadyRef.current = false; - // Surface the auth/network failure. Peer-presence gating (the peer - // simply not being online) is handled by the presence path above. - setError(err); - } finally { - // Only release the auth lock if this run is still the active one. - if (active) authFlowInProgressRef.current = false; + } else { + console.log('Session validation failed (ignored for debugging)'); } - } - - setupConnection(); - return () => { - active = false; - // Release the auth lock before the new run starts so it isn't blocked. - authFlowInProgressRef.current = false; - // 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 (see the `attach()` path). Tearing the - // transport + service down here is exactly what dropped the connection on - // swipe-away. Only this run's JS-side presence subscription is detached — - // the JS VM can survive a relaunch, and leaving it subscribed would - // accumulate a dead (guarded, but still registered) listener per - // relaunch. A real teardown (deps changed while the app is active, or an - // explicit disconnect via `reset`) still runs the full teardown below. - if (AppState.currentState !== 'active') { - if (presenceUnsubRef.current) { - try { - presenceUnsubRef.current(); - } catch { - /* noop */ - } - presenceUnsubRef.current = null; - } - return; + // 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 */ } - runAbort.abort(); - // The socket is going away for good (session change / explicit rebuild): - // tear down the p2p transport too, then close the socket. - clearTransport(); - closeSocket(); - }; - }, [origin, requestId, accounts.length > 0, keys.length > 0, socketNonce]); - // Negotiate (and re-negotiate) the p2p transport over the PERSISTENT native - // service. Keyed on the reconnect nonce so each manual/auto/presence-driven - // attempt runs as its own superseded-safe run. Reuses the started native - // service and NEVER stops it on teardown — only the peer/data-channels are - // torn down, so the app stays connected to the service between chats. - useEffect(() => { - // Nothing to negotiate until the persistent native service is started. - if (!nativeStartedRef.current || !socketReadyRef.current) return; - if (isConnectedRef.current) return; - - let active = true; - const runAbort = new AbortController(); - const setupStartedAt = Date.now(); - - async function negotiateTransport() { - if (userStoppedRef.current) return; - if (transportInFlightRef.current) return; - if (!nativeStartedRef.current) return; - // Peers must both be present (deviceCount >= 2) before negotiating p2p. - if (isPeerOffline(peerPresenceRef.current)) { + // 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] negotiateTransport: aborted — peer not present (deviceCount=${ - peerPresenceRef.current?.deviceCount ?? 'unknown' - })`, + `[ac2] presence for ${presence.requestId}: ${presence.deviceCount} device(s), online=${presence.online}`, ); - setIsLoading(false); - setPeerOffline(true); - return; - } + 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(); + // 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] negotiateTransport: opening p2p transport (nonce=${reconnectNonce}, deviceCount=${ - peerPresenceRef.current?.deviceCount ?? 'unknown' + `[ac2] service ready in ${Date.now() - setupStartedAt}ms (signaling ${ + signalingConnected ? 'connected' : 'connecting…' })`, ); - transportInFlightRef.current = true; - setIsLoading(true); + + 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; + } + + 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; + + 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. @@ -1464,21 +1221,22 @@ export function useConnection( }, }); - // Presence is subscribed on the persistent native service (socket - // effect), so it is intentionally NOT re-subscribed per negotiation. - // `createNativeAc2Transport` drives the native background service - // (`start` -> `connect('answer', { dataChannels })` -> wait for `ac2-v1` - // open) and routes native events into `RTCDataChannel`-shaped shims, so - // the wiring below is unchanged from the old in-process path aside from - // the shim casts at the `RTCDataChannel`-typed helper boundaries. + // 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, 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) => { @@ -1488,7 +1246,7 @@ export function useConnection( channel as unknown as RTCDataChannel, { onInbound: () => { - if (!active) return; + if (!isCurrent()) return; wasBackgroundedRef.current = false; lastInboundActivityRef.current = Date.now(); heartbeatMonitorRef.current?.noteInbound(); @@ -1501,7 +1259,7 @@ export function useConnection( if (channel.label === 'ac2-stream') { 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); @@ -1513,9 +1271,9 @@ export function useConnection( }); const { datachannel } = transport; - // Track this negotiation's listener disposer so `clearTransport` / the - // effect cleanup can detach the native message/state/ICE listeners. - // Replace any prior disposer first so a superseded run cannot leak one. + // 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(); @@ -1524,17 +1282,17 @@ export function useConnection( } } transportDisposeRef.current = transport.dispose; - // The native presence listener is persistent (socket effect); the + // 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 socket teardown path. + // symmetry with the service teardown path. transportPresenceUnsubRef.current = transport.disposePresence; - if (!active) { + 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 socket here. + // `PeerConnectionObserver`. NEVER touch the persistent service here. return; } @@ -1579,87 +1337,77 @@ 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); - // We've actually reached the peer — clear any "peer offline" - // notice so the live chat is shown. - setPeerOffline(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; @@ -1679,42 +1427,168 @@ export function useConnection( /* 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: the newer run owns all recovery. - if (!active || err?.name === 'AbortError') return; + // 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 peer (via `clearTransport`, socket preserved) - // and hands off to the bounded auto-reconnect scheduler, surfacing the - // terminal error + manual fallback only once the retry budget is spent. - failConnectionRef.current('setup', () => active, err); - } finally { - // Only release the negotiation lock if this run is still the active one. - if (active) transportInFlightRef.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; } } + }; - negotiateTransport(); + // 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; + } + + dispatch({ type: 'START' }); return () => { - active = false; - // Release the negotiation lock before the next run starts (the `finally` - // above is guarded by `active`, now false, so it won't reset it itself). - transportInFlightRef.current = false; // If this cleanup fires because the app is being backgrounded / destroyed - // (swipe-away, screen off) rather than a genuine renegotiation or explicit - // disconnect, PRESERVE the live peer: the background service keeps it alive - // so a relaunch/foreground re-attaches and hydrates. The native - // peer/channels must NOT be cancelled here (doing so is what dropped the - // connection on swipe-away). But DO detach this run's JS-side wiring: the - // JS VM can survive a relaunch (the next "Running main" reuses it), so a - // still-subscribed native message listener from this dead tree would - // swallow the offline-queue replay (its handlers bail on `active === - // false`) and duplicate every event also delivered to the fresh session. - // 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. A real teardown (deps changed while active) still runs below. + // (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(); @@ -1732,79 +1606,52 @@ export function useConnection( } transportDisposeRef.current = null; } - return; - } - // 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 */ - } - ac2ClientRef.current = null; - } - if (dataChannelRef.current) { - dataChannelRef.current.close(); - dataChannelRef.current = null; - } - if (streamChannelRef.current) { - try { - streamChannelRef.current.close(); - } catch { - /* noop */ - } - streamChannelRef.current = null; - } - if (heartbeatChannelRef.current) { - try { - heartbeatChannelRef.current.close(); - } catch { - /* noop */ + if (presenceUnsubRef.current) { + try { + presenceUnsubRef.current(); + } catch { + /* noop */ + } + presenceUnsubRef.current = null; } - heartbeatChannelRef.current = null; - } - // Detach this negotiation's native listeners (message/state/ICE) so - // reusing the service for the next attempt doesn't accumulate duplicate - // handlers. Own-disposer, set once the transport was established. - if (transportDisposeRef.current) { - try { - transportDisposeRef.current(); - } catch { - /* noop */ + if (signalingUnsubRef.current) { + try { + signalingUnsubRef.current(); + } catch { + /* noop */ + } + signalingUnsubRef.current = null; } - transportDisposeRef.current = null; - } - // Only cancel the native peer once this run owned an established - // transport. For a superseded run, `runAbort.abort()` above already - // cancelled the in-flight native negotiation (`createNativeAc2Transport` - // races the abort signal and calls `cancel()` itself), so cancelling - // again is unnecessary. NEVER stop the service here — it is owned by the - // socket effect and must stay alive so the app remains connected between - // chats. - if (hadEstablishedTransport && nativeStartedRef.current) { - cancelNativeNegotiation().catch(() => { - /* best-effort */ - }); + 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, 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, @@ -1821,10 +1668,9 @@ export function useConnection( error, isError: !!error, isLoading, - isConnected, - isReconnecting, - reconnectAttempt, - maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS, + isConnected: ui.isConnected, + isReconnecting: ui.isReconnecting, + reconnectAttempt: ui.reconnectAttempt, lastHeartbeat, reset, reconnect, 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 48c9619..f7cdd0c 100644 --- a/lib/ac2/index.ts +++ b/lib/ac2/index.ts @@ -18,6 +18,8 @@ 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, @@ -55,11 +57,13 @@ export type { DataChannelMessageEvent, DataChannelReadyState } from './nativeCha export { AC2_CONTROL_CHANNEL, addNativePresenceListener, + addNativeSignalingStateListener, cancelNativeNegotiation, createNativeAc2Transport, DEFAULT_AC2_QUEUE_CHANNELS, flushNativeQueue, getNativeConnectionState, + isSnapshotChannelOpen, nativeAuthFetch, setNativeActive, startNativeService, @@ -74,6 +78,7 @@ export type { NativeIceServer, NativeLinkErrorEvent, NativePresenceEvent, + NativeSignalingStateEvent, NativeSubscription, } from './nativeTransport'; diff --git a/lib/ac2/nativeTransport.ts b/lib/ac2/nativeTransport.ts index f2a3f63..0a018d9 100644 --- a/lib/ac2/nativeTransport.ts +++ b/lib/ac2/nativeTransport.ts @@ -141,6 +141,16 @@ export interface NativePresenceEvent { 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; @@ -164,6 +174,12 @@ export interface NativeConnectionStateSnapshot { 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; } /** @@ -210,6 +226,11 @@ export interface LiquidAuthNativeApi { 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, @@ -294,6 +315,7 @@ function getDefaultNativeApi(): LiquidAuthNativeApi { addConnectionStateListener: mod.addConnectionStateListener, addPresenceListener: mod.addPresenceListener, addLinkErrorListener: mod.addLinkErrorListener, + addSignalingStateListener: mod.addSignalingStateListener, request: mod.request, setActive: mod.setActive, flushQueue: mod.flushQueue, @@ -399,9 +421,7 @@ export function setNativeActive( * 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 { +export function flushNativeQueue(native: LiquidAuthNativeApi = getDefaultNativeApi()): void { native.flushQueue?.(); } @@ -417,6 +437,19 @@ export function getNativeConnectionState( 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 @@ -429,6 +462,24 @@ export function addNativePresenceListener( 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 diff --git a/modules/react-native-liquid-auth/VENDORED.md b/modules/react-native-liquid-auth/VENDORED.md index 1bcd8a3..4082971 100644 --- a/modules/react-native-liquid-auth/VENDORED.md +++ b/modules/react-native-liquid-auth/VENDORED.md @@ -11,6 +11,10 @@ sibling repo. - **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) 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 index d8e50fd..9470de3 100644 --- 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 @@ -56,6 +56,7 @@ class LiquidAuthNativeModule : Module() { 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 @@ -78,7 +79,7 @@ class LiquidAuthNativeModule : Module() { override fun definition() = ModuleDefinition { Name("LiquidAuthNative") - Events(ON_MESSAGE, ON_STATE_CHANGE, ON_TRACK, ON_PRESENCE, ON_LINK_ERROR, ON_CONNECTION_STATE_CHANGE) + 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. @@ -116,7 +117,14 @@ class LiquidAuthNativeModule : Module() { httpClient, createNotificationBuilder(), NOTIFICATION_ID, - currentActivity::class.java + 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) { @@ -159,6 +167,9 @@ class LiquidAuthNativeModule : Module() { onLinkError = { error -> sendEvent(ON_LINK_ERROR, jsonToMap(error)) }, onConnectionStateChange = { state -> sendEvent(ON_CONNECTION_STATE_CHANGE, mapOf("state" to state)) + }, + onSignalingState = { state -> + sendEvent(ON_SIGNALING_STATE_CHANGE, mapOf("state" to state)) } ) service.handleMessages( @@ -195,7 +206,8 @@ class LiquidAuthNativeModule : Module() { "connected" to false, "requestId" to null, "iceConnectionState" to null, - "channels" to emptyMap() + "channels" to emptyMap(), + "signalingConnected" to false ) } @@ -243,6 +255,9 @@ class LiquidAuthNativeModule : Module() { "enabled" to track.enabled() ) ) + }, + onSignalingState = { state -> + sendEvent(ON_SIGNALING_STATE_CHANGE, mapOf("state" to state)) } ) promise.resolve(null) diff --git a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalClient.kt b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalClient.kt index d293053..81524db 100644 --- a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalClient.kt +++ b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalClient.kt @@ -81,6 +81,14 @@ class SignalClient( */ var onConnectionStateChange: ((String) -> Unit)? = null + /** + * Signaling-socket connectivity changes (`"connected"` / `"disconnected"`), + * including socket.io auto-reconnects. Lets consumers surface a + * "signaling offline" state that is independent of the p2p connection — + * the data channels deliberately survive signaling disruptions. + */ + var onSignalingState: ((String) -> Unit)? = null + // In-flight negotiation bookkeeping, so [cancel] can abort a pending [peer]. private var peerJob: Job? = null private var peerContinuation: Continuation? = null @@ -109,15 +117,41 @@ class SignalClient( } /** - * Abort an in-flight [peer] negotiation: cancel the negotiation coroutine, - * fail the pending continuation with a [CancellationException] so the caller - * unblocks promptly, and tear the socket + peer down. + * Abort an in-flight (or established) [peer] negotiation: cancel the + * negotiation coroutine, fail the pending continuation with a + * [CancellationException] so the caller unblocks promptly, and destroy the + * peer connection. + * + * Deliberately does NOT touch the signaling socket. Cancelling used to run + * a full [disconnect], which closed the socket while [SignalService.start] + * kept this client instance alive — so no further `presence` broadcasts + * could ever arrive, and a consumer waiting for the peer to come back + * online (presence-gated renegotiation) was left permanently deaf. The + * socket must outlive the peer: it is the persistent presence/rendezvous + * plane; only [disconnect] (an explicit stop) tears it down. */ fun cancel() { peerJob?.cancel() peerJob = null failPeer(CancellationException("Peer negotiation cancelled")) - disconnect() + // Drop this negotiation's socket listeners (candidates + the one-shot + // description waiters) so a stray late frame can't hit a destroyed + // peer, and the next negotiation on the SAME socket starts clean. + detachNegotiationListeners() + peerClient?.destroy() + peerClient = null + } + + /** + * Remove the per-negotiation socket listeners ([peer] re-registers them on + * each run). The persistent listeners (`presence`, `exception`, socket + * connectivity) registered in [ensureSocket] are left untouched. + */ + private fun detachNegotiationListeners() { + socket?.off("offer-candidate") + socket?.off("answer-candidate") + socket?.off("offer-description") + socket?.off("answer-description") } /** @@ -167,12 +201,16 @@ class SignalClient( dataChannels: Map?, tracks: List? ): DataChannel? { - createSocket() + ensureSocket() return suspendCoroutine { continuation -> peerContinuation = continuation peerResumed = false peerJob = scope.launch { val clientType = if (type == "offer") "answer" else "offer" + // The socket is persistent across negotiations, so clear any + // listeners a previous (cancelled/failed) negotiation left + // behind before re-registering this run's own. + detachNegotiationListeners() peerClient = PeerApi(context) peerClient?.onConnectionStateChange = onConnectionStateChange // Note: the peer continuation is resumed at most once via @@ -353,11 +391,28 @@ class SignalClient( } } - private fun createSocket() { - // Handle existing connections - if (socket !== null) { - socket?.close() - socket?.disconnect() + /** + * Whether the signaling socket is currently connected. `false` before the + * first [ensureSocket] and while socket.io is (re)connecting. + */ + fun isSignalingConnected(): Boolean { + return socket?.connected() == true + } + + /** + * Create the signaling socket if none exists yet, or (re)connect the + * existing one. The socket is PERSISTENT: it is reused across peer + * negotiations (and across [cancel]) so `presence` broadcasts keep flowing + * between chats — it is only torn down by an explicit [disconnect]. + */ + fun ensureSocket() { + val existing = socket + if (existing !== null) { + // Reuse the persistent socket; revive it if it was dropped. + if (!existing.connected()) { + existing.connect() + } + return } // Configure Socket Options to use the same client @@ -376,12 +431,25 @@ class SignalClient( socket?.on("exception") { args -> (args.getOrNull(0) as? JSONObject)?.let { onLinkError?.invoke(it) } } + // Surface socket connectivity (fires on every connect/auto-reconnect + // and disconnect) so consumers can show a "signaling offline" state + // without tying it to the p2p connection. + socket?.on(Socket.EVENT_CONNECT) { + Log.d(TAG, "Signaling socket connected") + onSignalingState?.invoke("connected") + } + socket?.on(Socket.EVENT_DISCONNECT) { + Log.d(TAG, "Signaling socket disconnected") + onSignalingState?.invoke("disconnected") + } socket?.connect() } fun disconnect() { socket?.close() socket?.disconnect() + socket = null peerClient?.destroy() + peerClient = null } } diff --git a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalService.kt b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalService.kt index 0429c2d..18cd073 100644 --- a/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalService.kt +++ b/modules/react-native-liquid-auth/android/src/main/java/foundation/algorand/auth/connect/SignalService.kt @@ -182,7 +182,9 @@ class SignalService : Service() { httpClient: OkHttpClient, notificationBuilder: Builder, notificationId: Int, - activityClass: Class + activityClass: Class, + onPresence: ((JSONObject) -> Unit)? = null, + onSignalingState: ((String) -> Unit)? = null ) { startForeground(notificationBuilder.setContentIntent(createPendingIntent(activityClass, 0)), notificationId) // Preserve an already-running client so the app re-attaching (e.g. after @@ -194,6 +196,14 @@ class SignalService : Service() { if (signalClient == null) { signalClient = SignalClient(url, this@SignalService, httpClient) } + // (Re)bind the persistent-socket callbacks before the socket comes up so + // the very first presence broadcast / connectivity transition is seen. + onPresence?.let { signalClient?.onPresence = it } + onSignalingState?.let { signalClient?.onSignalingState = it } + // Bring the persistent signaling socket up NOW (not lazily on the first + // peer negotiation) so presence and signaling connectivity flow to the + // consumer before — and between — p2p negotiations. + signalClient?.ensureSocket() } /** @@ -234,6 +244,7 @@ class SignalService : Service() { onPresence: ((JSONObject) -> Unit)? = null, onLinkError: ((JSONObject) -> Unit)? = null, onConnectionStateChange: ((String) -> Unit)? = null, + onSignalingState: ((String) -> Unit)? = null, ) { connectedRequestId = requestId // Register the socket/peer callbacks before negotiation so the socket @@ -241,6 +252,7 @@ class SignalService : Service() { signalClient?.onPresence = onPresence signalClient?.onLinkError = onLinkError signalClient?.onConnectionStateChange = onConnectionStateChange + onSignalingState?.let { signalClient?.onSignalingState = it } dataChannel = signalClient?.peer(requestId, type, iceServers, dataChannels, tracks) peerClient = signalClient?.peerClient peerClient?.onTrack = onTrack @@ -377,7 +389,11 @@ class SignalService : Service() { "connected" to (peer != null && peer.dataChannels.isNotEmpty()), "requestId" to connectedRequestId, "iceConnectionState" to peer?.peerConnection?.iceConnectionState()?.toString(), - "channels" to channels + "channels" to channels, + // Whether the persistent signaling socket is currently connected, + // independent of the p2p state above (data channels deliberately + // survive signaling disruptions). + "signalingConnected" to (signalClient?.isSignalingConnected() == true) ) } @@ -404,7 +420,8 @@ class SignalService : Service() { onPresence: ((JSONObject) -> Unit)? = null, onLinkError: ((JSONObject) -> Unit)? = null, onConnectionStateChange: ((String) -> Unit)? = null, - onTrack: ((MediaStreamTrack) -> Unit)? = null + onTrack: ((MediaStreamTrack) -> Unit)? = null, + onSignalingState: ((String) -> Unit)? = null ) { // Re-attaching means the app is in the foreground and (re)wiring its // listeners, so it is online and consuming by definition. Mark it active @@ -422,6 +439,7 @@ class SignalService : Service() { signalClient?.onPresence = onPresence signalClient?.onLinkError = onLinkError signalClient?.onConnectionStateChange = onConnectionStateChange + onSignalingState?.let { signalClient?.onSignalingState = it } signalClient?.peerClient?.onConnectionStateChange = onConnectionStateChange signalClient?.peerClient?.onTrack = onTrack // Re-register the data-channel observers with the fresh message/state diff --git a/modules/react-native-liquid-auth/ios/LiquidAuthNativeModule.swift b/modules/react-native-liquid-auth/ios/LiquidAuthNativeModule.swift index c7d860d..e61d455 100644 --- a/modules/react-native-liquid-auth/ios/LiquidAuthNativeModule.swift +++ b/modules/react-native-liquid-auth/ios/LiquidAuthNativeModule.swift @@ -33,7 +33,8 @@ public class LiquidAuthNativeModule: Module { "onTrack", "onPresence", "onLinkError", - "onConnectionStateChange" + "onConnectionStateChange", + "onSignalingStateChange" ) /** @@ -64,7 +65,16 @@ public class LiquidAuthNativeModule: Module { */ AsyncFunction("start") { (url: String, promise: Promise) in self.signalUrl = url - SignalService.shared.start(url: url, httpClient: URLSession.shared) + SignalService.shared.start( + url: url, + httpClient: URLSession.shared, + onPresence: { [weak self] presence in + self?.sendEvent("onPresence", presence) + }, + onSignalingState: { [weak self] state in + self?.sendEvent("onSignalingStateChange", ["state": state]) + } + ) promise.resolve(nil) } @@ -129,6 +139,9 @@ public class LiquidAuthNativeModule: Module { }, onConnectionStateChange: { [weak self] state in self?.sendEvent("onConnectionStateChange", ["state": state]) + }, + onSignalingState: { [weak self] state in + self?.sendEvent("onSignalingStateChange", ["state": state]) } ) } @@ -146,6 +159,54 @@ public class LiquidAuthNativeModule: Module { promise.resolve(nil) } + /** + * Snapshot the background service's CURRENT connection so a re-attaching + * app can hydrate instead of assuming a fresh start. Returns + * `{ connected, requestId, iceConnectionState, channels, signalingConnected }`. + * Safe to call before the service is bound (returns `connected: false`). + */ + Function("getConnectionState") { () -> [String: Any?] in + return SignalService.shared.getConnectionState() + } + + /** + * Re-attach to the ALREADY-live connection without renegotiating: rebind + * the message/state/presence/link-error/connection-state listeners to this + * (fresh) JS runtime and re-emit the current channel + ICE state so the app + * hydrates. Use when [getConnectionState] reports `connected: true` (e.g. + * after a relaunch that reconnected to the still-running service). + */ + AsyncFunction("attach") { (options: [String: Any]?, promise: Promise) in + SignalService.shared.attach( + onMessage: { [weak self] channel, message in + self?.sendEvent("onMessage", ["channel": channel, "message": message]) + }, + onStateChange: { [weak self] channel, state in + var payload: [String: Any] = ["channel": channel] + if let state { payload["state"] = state } + self?.sendEvent("onStateChange", payload) + }, + onPresence: { [weak self] presence in + self?.sendEvent("onPresence", presence) + }, + onLinkError: { [weak self] error in + guard let self else { return } + var payload: [String: Any] = ["event": "link-error"] + if let reason = error.rawReason { payload["reason"] = reason } + if let requestId = error.requestId { payload["requestId"] = requestId } + if let message = error.message { payload["message"] = message } + self.sendEvent("onLinkError", payload) + }, + onConnectionStateChange: { [weak self] state in + self?.sendEvent("onConnectionStateChange", ["state": state]) + }, + onSignalingState: { [weak self] state in + self?.sendEvent("onSignalingStateChange", ["state": state]) + } + ) + promise.resolve(nil) + } + /** * Send a message over the primary (`liquid`) data channel. */ diff --git a/modules/react-native-liquid-auth/ios/LiquidAuthSDK/SignalClient.swift b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/SignalClient.swift index ef78cc5..9cf92cb 100644 --- a/modules/react-native-liquid-auth/ios/LiquidAuthSDK/SignalClient.swift +++ b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/SignalClient.swift @@ -29,8 +29,13 @@ public class SignalClient { private var candidatesBuffer: [RTCIceCandidate] = [] private var eventQueue: [(String, QueuedEventData)] = [] private var dataChannelDelegates: [RTCDataChannel: DataChannelDelegate] = [:] - private var onLinkError: ((LinkError) -> Void)? + var onLinkError: ((LinkError) -> Void)? var onSocketConnected: (() -> Void)? + /// Signaling-socket connectivity changes (`"connected"` / `"disconnected"`), + /// including socket.io auto-reconnects. Lets consumers surface a + /// "signaling offline" state that is independent of the p2p connection — + /// the data channels deliberately survive signaling disruptions. + var onSignalingState: ((String) -> Void)? /// Server-broadcast `presence` updates for the current `requestId` room /// (`{ requestId, deviceCount, online }`). Set before ``connectToPeer`` so /// the socket listener is registered when the socket is created. Mirrors @@ -51,6 +56,22 @@ public class SignalClient { setupSocketListeners() } + /// Whether the signaling socket is currently connected. + func isSignalingConnected() -> Bool { + return socket.status == .connected + } + + /// Create the signaling socket if none exists yet, or (re)connect the + /// existing one. The socket is PERSISTENT: it is reused across peer + /// negotiations (and across [cancel]) so `presence` broadcasts keep flowing + /// between chats — it is only torn down by an explicit [disconnectSocket]. + func ensureSocket() { + if socket.status != .connected && socket.status != .connecting { + Logger.debug("SignalClient: Socket is not connected. Attempting to connect...") + socket.connect() + } + } + // swiftlint:disable:next function_body_length public func connectToPeer( requestId: String, @@ -62,15 +83,36 @@ public class SignalClient { onStateChange: @escaping (String, String?) -> Void, onLinkError: ((LinkError) -> Void)? = nil ) -> RTCDataChannel? { + ensureSocket() + // Clean up any existing peer connection peerClient?.close() peerClient = nil + // The socket is persistent across negotiations, so clear any + // listeners a previous (cancelled/failed) negotiation left + // behind before re-registering this run's own. + detachNegotiationListeners() + self.onLinkError = onLinkError installLinkErrorListeners(requestId: requestId) Logger.debug("SignalClient: Attempting to connect to peer with requestId: \(requestId), type: \(type.rawValue)") + // Listen for Remote ICE Candidates + socket.on("candidate") { [weak self] data, _ in + guard let self, let eventData = data.first as? [String: Any] else { return } + self.handleIceCandidate(eventData) + } + socket.on("offer-candidate") { [weak self] data, _ in + guard let self, let eventData = data.first as? [String: Any] else { return } + self.handleIceCandidate(eventData) + } + socket.on("answer-candidate") { [weak self] data, _ in + guard let self, let eventData = data.first as? [String: Any] else { return } + self.handleIceCandidate(eventData) + } + peerClient = PeerApi( iceServers: iceServers, poolSize: 10, @@ -148,6 +190,16 @@ public class SignalClient { Logger.info("Answer (initiator): sending link request") send(event: "link", data: ["requestId": requestId]) + // Listen for the answer-description event (only for initiator) + socket.on("answer-description") { [weak self] data, _ in + guard let self else { return } + if let eventData = data.first as? [String: Any] { + self.handleAnswerDescription(eventData) + } else if let sdp = data.first as? String { + self.handleAnswerDescription(sdp) + } + } + guard let peerClient, peerClient.peerConnection != nil else { Logger.error("PeerClient or its peerConnection is nil!") return nil @@ -193,40 +245,9 @@ public class SignalClient { send(event: "link", data: ["requestId": requestId]) // Listen for the offer-description event (only for responder) - socket.off("offer-description") socket.on("offer-description") { [weak self] data, _ in - guard let self, let eventData = data.first as? [String: Any], - let sdp = eventData["sdp"] as? String, - let type = sdpType(from: eventData["type"] as? String) else { return } - Logger.info("Offer (responder): Received SDP type: \(type) : \(sdp)") - let sessionDescription = RTCSessionDescription(type: type, sdp: sdp) - - peerClient?.setRemoteDescription(sessionDescription, completion: { error in - if let error { - Logger.error("Failed to set remote description: \(error)") - } else { - Logger.info("Offer (responder): Remote description set successfully.") - - self.peerClient?.createAnswer { answer in - guard let answer else { - Logger.error("Failed to create answer: Answer is nil") - return - } - Logger.info("Offer (responder): Setting local description") - self.peerClient?.setLocalDescription(answer) { error in - if let error { - Logger.error("Failed to set local description: \(error)") - } else { - Logger.info("Offer (responder): Sending answer description") - self - .send(event: "answer-description", - sdp: answer - .sdp) // ["type": stringFromSdpType(answer.type), "sdp": answer.sdp]) - } - } - } - } - }) + guard let self, let eventData = data.first as? [String: Any] else { return } + self.handleOfferDescription(eventData) } return nil } @@ -236,32 +257,53 @@ public class SignalClient { // MARK: - Connect to the Socket.IO Server func connectSocket() { - if socket.status != .connected { - Logger.debug("Socket is not connected. Attempting to connect...") - socket.connect() - } else { - Logger.debug("Socket is already connected.") - } + ensureSocket() } func disconnectSocket() { socket.disconnect() + peerClient?.close() + peerClient = nil handleDisconnect() } - /// Abort an in-flight negotiation: tear the peer connection down and - /// disconnect the socket so the caller unblocks promptly. Mirrors - /// `SignalClient.cancel()` in the Android SDK. + /// Abort an in-flight negotiation: cancel the negotiation, and destroy the + /// peer connection. + /// + /// Deliberately does NOT touch the signaling socket. Cancelling used to run + /// a full [disconnectSocket], which closed the socket while the service + /// kept this client instance alive — so no further `presence` broadcasts + /// could ever arrive, and a consumer waiting for the peer to come back + /// online (presence-gated renegotiation) was left permanently deaf. The + /// socket must outlive the peer: it is the persistent presence/rendezvous + /// plane; only [disconnectSocket] (an explicit stop) tears it down. func cancel() { peerClient?.close() peerClient = nil - socket.disconnect() + // Drop this negotiation's socket listeners (candidates + the one-shot + // description waiters) so a stray late frame can't hit a destroyed + // peer, and the next negotiation on the SAME socket starts clean. + detachNegotiationListeners() + candidatesBuffer.removeAll() + } + + /// Remove the per-negotiation socket listeners (``connectToPeer`` re-registers + /// them on each run). The persistent listeners (`presence`, `exception`, socket + /// connectivity) registered in ``setupSocketListeners`` are left untouched. + private func detachNegotiationListeners() { + socket.off("offer-candidate") + socket.off("answer-candidate") + socket.off("offer-description") + socket.off("answer-description") + socket.off("candidate") + socket.off("link-error") + socket.off("exception") } private func handleDisconnect() { Logger.debug("Handling Socket.IO disconnection...") - peerClient?.close() - peerClient = nil + // peerClient?.close() + // peerClient = nil } // MARK: - Link Error Handling @@ -322,54 +364,17 @@ public class SignalClient { private func setupSocketListeners() { socket.on(clientEvent: .connect) { _, _ in Logger.debug("Socket.IO connected") + self.onSignalingState?("connected") self.onSocketConnected?() self.processEventQueue() } socket.on(clientEvent: .disconnect) { _, _ in Logger.debug("Socket.IO disconnected") + self.onSignalingState?("disconnected") self.handleDisconnect() } - if service?.currentPeerType == .offer { - socket.on("offer-description") { [weak self] data, _ in - guard let self, let eventData = data.first as? [String: Any] else { return } - Logger.debug("Received SDP offer: \(eventData)") - handleOfferDescription(eventData) - } - } - - socket.on("answer-description") { [weak self] data, _ in - guard let self else { return } - // Try to handle as dictionary first, then as string - if let eventData = data.first as? [String: Any] { - Logger.debug("Received SDP answer as dictionary: \(eventData)") - handleAnswerDescription(eventData) - } else if let sdp = data.first as? String { - Logger.debug("Received SDP answer as string: \(sdp)") - handleAnswerDescription(sdp) - } else { - Logger.error("Received SDP answer in unknown format: \(data)") - } - } - - socket.on("candidate") { [weak self] data, _ in - guard let self, let eventData = data.first as? [String: Any] else { return } - Logger.debug("Received ICE candidate: \(eventData)") - handleIceCandidate(eventData) - } - - socket.on("offer-candidate") { [weak self] data, _ in - guard let self, let eventData = data.first as? [String: Any] else { return } - Logger.debug("Received offer ICE candidate: \(eventData)") - handleIceCandidate(eventData) - } - socket.on("answer-candidate") { [weak self] data, _ in - guard let self, let eventData = data.first as? [String: Any] else { return } - Logger.debug("Received answer ICE candidate: \(eventData)") - handleIceCandidate(eventData) - } - socket.on("presence") { [weak self] data, _ in guard let self, let eventData = data.first as? [String: Any] else { return } Logger.debug("Received presence: \(eventData)") diff --git a/modules/react-native-liquid-auth/ios/LiquidAuthSDK/SignalService.swift b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/SignalService.swift index 88e0123..a900920 100644 --- a/modules/react-native-liquid-auth/ios/LiquidAuthSDK/SignalService.swift +++ b/modules/react-native-liquid-auth/ios/LiquidAuthSDK/SignalService.swift @@ -43,6 +43,11 @@ public class SignalService { private var lastKnownReferer: String? private var isDeepLink: Bool = true + // The `requestId` the live connection is bound to, so a re-attaching app + // can hydrate which room/peer the background service is connected to. Set + // by ``connectToPeer`` and cleared by ``stop``. + private var connectedRequestId: String? + var currentPeerType: LiquidAuthPeerType? // .offer or .answer /// Guards the one-shot `onConnected` callback so it fires exactly once per @@ -58,10 +63,33 @@ public class SignalService { /// - Parameters: /// - url: The signaling server URL /// - httpClient: URLSession for HTTP communications - public func start(url: String, httpClient _: URLSession) { - // Initialize the SignalClient - signalClient = SignalClient(url: url, service: self) - signalClient?.connectSocket() + /// - onPresence: Callback for server-broadcast `presence` updates + /// - onSignalingState: Callback for signaling-socket connectivity changes + public func start( + url: String, + httpClient _: URLSession, + onPresence: (([String: Any]) -> Void)? = nil, + onSignalingState: ((String) -> Void)? = nil + ) { + // Preserve an already-running client so the app re-attaching (e.g. after + // a relaunch that reconnected to the still-running service) does NOT + // tear down the live connection the service was keeping alive. + if signalClient == nil { + signalClient = SignalClient(url: url, service: self) + } + // (Re)bind the persistent-socket callbacks before the socket comes up so + // the very first presence broadcast / connectivity transition is seen. + if let onPresence = onPresence { + signalClient?.onPresence = onPresence + } + if let onSignalingState = onSignalingState { + signalClient?.onSignalingState = onSignalingState + } + // Bring the persistent signaling socket up NOW (not lazily on the first + // peer negotiation) so presence and signaling connectivity flow to the + // consumer before — and between — p2p negotiations. + signalClient?.ensureSocket() + delegate?.signalService( self, didReceiveStatusUpdate: "Signal Service", @@ -75,6 +103,7 @@ public class SignalService { signalClient = nil peerClient = nil dataChannel = nil + connectedRequestId = nil namedDataChannels.removeAll() peerConnection = nil delegate?.signalService(self, didReceiveStatusUpdate: "Signal Service", message: "Service stopped.") @@ -90,6 +119,59 @@ public class SignalService { ) } + /** + * Re-attach a freshly (re)started app to the ALREADY-live connection without + * renegotiating. Rebinds the socket/peer callbacks to the new sinks (the old + * ones referenced a now-dead JS runtime), and re-emits each channel's current + * state plus the peer's ICE connection state so the consumer hydrates + * immediately — observers only fire on transitions, so a live-but-unchanged + * channel would otherwise never notify the fresh listener. Used when + * ``getConnectionState()`` reports a live peer. + */ + public func attach( + onMessage: @escaping (String, String) -> Void, + onStateChange: @escaping (String, String?) -> Void, + onPresence: (([String: Any]) -> Void)? = nil, + onLinkError: ((LinkError) -> Void)? = nil, + onConnectionStateChange: ((String) -> Void)? = nil, + onSignalingState: ((String) -> Void)? = nil + ) { + // Rebind the live socket/peer callbacks to the new sinks. + if let onPresence = onPresence { + signalClient?.onPresence = onPresence + } + if let onLinkError = onLinkError { + signalClient?.onLinkError = onLinkError + } + if let onSignalingState = onSignalingState { + signalClient?.onSignalingState = onSignalingState + } + if let onConnectionStateChange = onConnectionStateChange { + signalClient?.onConnectionStateChange = onConnectionStateChange + peerClient?.onConnectionStateChange = onConnectionStateChange + } + + // Re-register the data-channel observers with the fresh message/state + // sinks. + for (label, channel) in namedDataChannels { + let delegate = DataChannelDelegate( + signalService: self, + onMessage: { message in onMessage(label, message) }, + onStateChange: { state in onStateChange(label, state) } + ) + channel.delegate = delegate + dataChannelDelegates[channel] = delegate + + // Re-emit the current channel state so the re-attached consumer + // hydrates now (the observers only fire on future transitions). + onStateChange(label, channel.readyState.stateDescription) + } + + if let iceState = peerClient?.peerConnection?.iceConnectionState { + onConnectionStateChange?(iceState.stateDescription) + } + } + /// Abort an in-flight ``connectToPeer`` negotiation without fully stopping /// the service. Mirrors `SignalService.cancel()` in the Android SDK. public func cancel() { @@ -130,23 +212,28 @@ public class SignalService { onLinkError: ((LinkError) -> Void)? = nil, onConnected: (() -> Void)? = nil, onPresence: (([String: Any]) -> Void)? = nil, - onConnectionStateChange: ((String) -> Void)? = nil + onConnectionStateChange: ((String) -> Void)? = nil, + onSignalingState: ((String) -> Void)? = nil ) { currentPeerType = type didFireConnected = false + connectedRequestId = requestId - signalClient?.disconnectSocket() - signalClient = nil namedDataChannels.removeAll() Logger.debug("Attempting to connect to peer with requestId: \(requestId), type: \(type.rawValue)") - // Ensure the socket is connected - signalClient = SignalClient(url: origin, service: self) + // Ensure the SignalClient exists and is pointing to the right origin + if signalClient == nil { + signalClient = SignalClient(url: origin, service: self) + } // Register socket/peer callbacks before connecting so the socket // listeners (presence) are attached when the socket is created. signalClient?.onPresence = onPresence signalClient?.onConnectionStateChange = onConnectionStateChange + if let onSignalingState = onSignalingState { + signalClient?.onSignalingState = onSignalingState + } // Wait for socket connection before starting signaling signalClient?.onSocketConnected = { [weak self] in @@ -197,7 +284,12 @@ public class SignalService { ) } - signalClient?.connectSocket() + if signalClient?.isSignalingConnected() == true { + signalClient?.onSocketConnected?() + } else { + signalClient?.connectSocket() + } + Logger.debug("ICE servers: \(iceServers)") Logger.debug("Waiting for socket to connect before signaling.") } @@ -261,4 +353,43 @@ public class SignalService { } messageQueue.removeAll() } + + /** + * Snapshot of the current live connection so a re-attaching app can hydrate + * its UI (rather than assuming a fresh start). Reports whether a peer + * connection exists with negotiated channels, its ICE connection state, the + * `requestId` it is bound to, and each negotiated channel's current state + * keyed by label. Read-only: this never mutates the connection. + */ + public func getConnectionState() -> [String: Any?] { + let peer = signalClient?.peerClient + let channels = namedDataChannels.mapValues { $0.readyState.stateDescription } + return [ + "connected": peer != nil && !namedDataChannels.isEmpty, + "requestId": connectedRequestId, + "iceConnectionState": peer?.peerConnection?.iceConnectionState.stateDescription, + "channels": channels, + // Whether the persistent signaling socket is currently connected, + // independent of the p2p state above (data channels deliberately + // survive signaling disruptions). + "signalingConnected": signalClient?.isSignalingConnected() ?? false, + ] + } +} + +// MARK: - RTCDataChannelState description + +extension RTCDataChannelState { + /// Uppercase state name matching the Android SDK's + /// `DataChannel.State.toString()` (`CONNECTING`/`OPEN`/`CLOSING`/`CLOSED`), + /// so the `onStateChange` payload is identical across platforms. + var stateDescription: String { + switch self { + case .connecting: return "CONNECTING" + case .open: return "OPEN" + case .closing: return "CLOSING" + case .closed: return "CLOSED" + @unknown default: return "UNKNOWN" + } + } } diff --git a/modules/react-native-liquid-auth/src/LiquidAuthNative.types.ts b/modules/react-native-liquid-auth/src/LiquidAuthNative.types.ts index 658fe47..755d2ee 100644 --- a/modules/react-native-liquid-auth/src/LiquidAuthNative.types.ts +++ b/modules/react-native-liquid-auth/src/LiquidAuthNative.types.ts @@ -189,6 +189,16 @@ export interface LiquidAuthConnectionStateEvent { state: string; } +/** + * Payload emitted when the persistent signaling socket's connectivity changes + * (including socket.io auto-reconnects). Independent of the p2p connection — + * the data channels deliberately survive signaling disruptions — so consumers + * can surface a dedicated "signaling server offline" state. + */ +export interface LiquidAuthSignalingStateEvent { + state: 'connected' | 'disconnected'; +} + /** * The result of an authenticated {@link request} performed through the native * module's shared cookie-jar HTTP client (the same client that backs the @@ -223,6 +233,13 @@ export interface LiquidAuthConnectionState { * keyed by channel label. */ channels: Record; + /** + * Whether the persistent signaling socket is currently connected, + * independent of the p2p state above. Lets a (re)attaching app seed its + * "signaling offline" indicator before the first `onSignalingStateChange` + * event arrives. + */ + signalingConnected: boolean; } /** @@ -235,4 +252,5 @@ export type LiquidAuthNativeModuleEvents = { onPresence: (event: LiquidAuthPresenceEvent) => void; onLinkError: (event: LiquidAuthLinkErrorEvent) => void; onConnectionStateChange: (event: LiquidAuthConnectionStateEvent) => void; + onSignalingStateChange: (event: LiquidAuthSignalingStateEvent) => void; }; diff --git a/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.web.ts b/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.web.ts index 0a836d0..aba8708 100644 --- a/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.web.ts +++ b/modules/react-native-liquid-auth/src/LiquidAuthNativeModule.web.ts @@ -35,7 +35,13 @@ class LiquidAuthNativeModule extends NativeModule } getConnectionState(): LiquidAuthConnectionState { - return { connected: false, requestId: null, iceConnectionState: null, channels: {} }; + return { + connected: false, + requestId: null, + iceConnectionState: null, + channels: {}, + signalingConnected: false, + }; } async attach(_options?: LiquidAuthConnectOptions): Promise { diff --git a/modules/react-native-liquid-auth/src/index.ts b/modules/react-native-liquid-auth/src/index.ts index e11a763..d849982 100644 --- a/modules/react-native-liquid-auth/src/index.ts +++ b/modules/react-native-liquid-auth/src/index.ts @@ -9,6 +9,7 @@ import type { LiquidAuthPeerType, LiquidAuthPresenceEvent, LiquidAuthResponse, + LiquidAuthSignalingStateEvent, LiquidAuthStateChangeEvent, LiquidAuthTrackEvent, } from './LiquidAuthNative.types'; @@ -222,3 +223,17 @@ export function addConnectionStateListener( ): EventSubscription { return LiquidAuthNativeModule.addListener('onConnectionStateChange', listener); } + +/** + * Subscribe to signaling-socket connectivity changes (`connected` / + * `disconnected`), including socket.io auto-reconnects. Independent of the + * p2p connection — the data channels deliberately survive signaling + * disruptions — so the app can surface a dedicated "signaling server offline" + * state. Seed the initial value from {@link getConnectionState}'s + * `signalingConnected` when subscribing after {@link start}. + */ +export function addSignalingStateListener( + listener: (event: LiquidAuthSignalingStateEvent) => void +): EventSubscription { + return LiquidAuthNativeModule.addListener('onSignalingStateChange', listener); +}