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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .oxfmtignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules/
modules/
ios/Pods/
ios/build/
android/build/
Expand Down
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@
"rules": {}
}
],
"ignorePatterns": ["dist/", "node_modules/", ".expo/", "android/", "ios/"]
"ignorePatterns": ["dist/", "node_modules/", ".expo/", "android/", "ios/", "modules/"]
}
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,63 @@ To further integrate with identity primitives, the following extensions are sugg
> ```bash
> pnpm android
> ```

## Liquid Auth native module (vendored)

The wallet's connection layer drives Liquid Auth signaling/WebRTC through a
native **background service** exposed by the `react-native-liquid-auth` package
(see `hooks/useConnection.ts` and `lib/ac2/nativeTransport.ts`).

That package is **unpublished**, so instead of a workspace/`file:`/`link:`
dependency it is **vendored as an [Expo local module](https://docs.expo.dev/modules/get-started/)**
at `modules/react-native-liquid-auth/`. This keeps the wallet a self-contained,
buildable app — no external workspace or sibling checkout required.

- **Runtime resolution:** a Metro resolver alias (`resolver.extraNodeModules` in
`metro.config.js`) maps the bare specifier `react-native-liquid-auth` to
`modules/react-native-liquid-auth/src`, so every `require('react-native-liquid-auth')`
resolves unchanged and Metro bundles the module's TypeScript directly.
- **Native autolinking:** Expo autolinking discovers the module's `android/` and
`ios/` from `modules/` during prebuild — no `package.json` dependency entry.
- **Isolation:** the vendored tree is excluded from the wallet's `tsc`, `oxlint`,
`oxfmt`, and scoped in Jest, so it is never scanned as app source.
- **Provenance:** `modules/react-native-liquid-auth/VENDORED.md` records the
upstream repo/commit and the one-way (upstream → copy) sync direction.

### Background-service notifications

The native background service shows an ongoing "connected" notification plus a
per-message notification while the app is backgrounded (rendered natively so it
works even when the JS runtime is suspended/killed).

- **Runtime permission:** on Android 13+ (`POST_NOTIFICATIONS`) the app must
request the permission at runtime. The wallet depends on `expo-notifications`
and requests it via `lib/notifications.ts` (`ensureNotificationPermission`)
just before starting the service — non-fatal if denied. Adding this native
dependency requires an `expo prebuild` + rebuild to link it.
- **Custom content:** the per-message copy is defined in
`DEFAULT_AC2_NOTIFICATIONS` (`lib/ac2/nativeTransport.ts`) and passed to the
native service via `connect(options.notifications)`. Heartbeat/stream control
channels are suppressed; `ac2/SigningRequest` / `ac2/KeyRequest` get tailored
copy; anything else falls back to a generic banner.

### Remaining on-device (Mac) steps

Native linking and a device/simulator run require a macOS host and are **not**
exercised in CI/Linux:

1. `expo prebuild` — generate the native `android/` / `ios/` projects.
2. `pod install` (iOS) — **watch for a pod overlap:** the module's podspec pulls
`WebRTC-lib`, which can clash with the wallet's `react-native-webrtc`. This is
resolved when the in-process WebRTC path is retired.
3. Gradle autolink (Android) — picks up the module from `modules/` automatically.
4. Run on device/simulator and confirm `startNativeService` /
`createNativeAc2Transport` reach the native `SignalService`.

### Upgrade path

When `react-native-liquid-auth` is published (e.g.
`@algorandfoundation/react-native-liquid-auth`), migrating is mechanical: delete
`modules/react-native-liquid-auth/`, drop the Metro alias and the
`tsconfig`/`oxlint`/`oxfmt`/Jest isolation entries, and add a normal scoped
dependency.
68 changes: 64 additions & 4 deletions __tests__/components/chat/chat-reconnect.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => {} }),
Expand Down Expand Up @@ -60,8 +60,9 @@ function baseConnection() {
isError: false,
isLoading: false,
isReconnecting: false,
peerOffline: false,
isSocketConnected: true,
reconnectAttempt: 0,
maxReconnectAttempts: 3,
send: jest.fn(),
sendAc2: jest.fn(),
lastHeartbeat: Date.now(),
Expand All @@ -76,6 +77,9 @@ function baseConnection() {
openConversation: jest.fn(),
closeConversation: jest.fn(),
remoteThreads: [],
connectionNotice: null,
dismissConnectionNotice: jest.fn(),
isRegistered: true,
};
}

Expand All @@ -99,18 +103,44 @@ 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();

expect(screen.getByLabelText('Reconnect')).toBeTruthy();
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();
Expand All @@ -119,4 +149,34 @@ describe('ChatScreen reconnect footer', () => {
expect(screen.queryByLabelText('Reconnect')).toBeNull();
expect(view.UNSAFE_getByType(KeyboardAvoidingView).props.keyboardVerticalOffset).toBe(162);
});

it('disables the composer when connected but the wallet is not registered', () => {
// A foreign wallet locked out, or no identity granted yet: block new
// messages until registration completes.
mockConnectionState = { ...baseConnection(), isConnected: true, isRegistered: false };
renderChat();

const input = screen.getByPlaceholderText("Not registered — can't send messages");
expect(input).toBeTruthy();
expect(input.props.editable).toBe(false);
expect(screen.queryByPlaceholderText('Message')).toBeNull();
});

it('prompts to delete the connection when connected but not registered', () => {
// The connection is dead (re-pairing generates a new requestId), so the app
// offers to delete this thread.
const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
mockConnectionState = { ...baseConnection(), isConnected: true, isRegistered: false };
renderChat();

expect(alertSpy).toHaveBeenCalledWith(
'Connection not registered',
expect.stringContaining('delete'),
expect.arrayContaining([
expect.objectContaining({ text: 'Keep' }),
expect.objectContaining({ text: 'Delete', style: 'destructive' }),
]),
);
alertSpy.mockRestore();
});
});
Loading