Skip to content
6 changes: 3 additions & 3 deletions .github/workflows/mobile-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ on:

jobs:
lint:
runs-on: macos-14
runs-on: macos-latest
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this fixes the failing 16.4 error. missed it from earlier

steps:
- uses: actions/checkout@v4
- name: Read and sanitize Node.js version
Expand Down Expand Up @@ -62,7 +62,7 @@ jobs:
working-directory: ./app

test:
runs-on: macos-14
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Read and sanitize Node.js version
Expand Down Expand Up @@ -102,7 +102,7 @@ jobs:
run: yarn test
working-directory: ./app
build:
runs-on: macos-14
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Read and sanitize Node.js version
Expand Down
21 changes: 12 additions & 9 deletions app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { LoggerProvider } from './src/providers/loggerProvider';
import { NotificationTrackingProvider } from './src/providers/notificationTrackingProvider';
import { PassportProvider } from './src/providers/passportDataProvider';
import { RemoteConfigProvider } from './src/providers/remoteConfigProvider';
import { SelfClientProvider } from './src/providers/selfClientProvider';
import { initSentry, wrapWithSentry } from './src/Sentry';

initSentry();
Expand All @@ -25,15 +26,17 @@ function App(): React.JSX.Element {
<YStack flex={1} height="100%" width="100%">
<RemoteConfigProvider>
<LoggerProvider>
<AuthProvider>
<PassportProvider>
<DatabaseProvider>
<NotificationTrackingProvider>
<AppNavigation />
</NotificationTrackingProvider>
</DatabaseProvider>
</PassportProvider>
</AuthProvider>
<SelfClientProvider>
<AuthProvider>
<PassportProvider>
<DatabaseProvider>
<NotificationTrackingProvider>
<AppNavigation />
</NotificationTrackingProvider>
</DatabaseProvider>
</PassportProvider>
</AuthProvider>
</SelfClientProvider>
</LoggerProvider>
</RemoteConfigProvider>
</YStack>
Expand Down
7 changes: 4 additions & 3 deletions app/src/components/native/PassportCamera.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React, { useCallback } from 'react';
import type { NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native';
import { PixelRatio, Platform, requireNativeComponent } from 'react-native';

import { extractMRZInfo } from '@selfxyz/mobile-sdk-alpha';
import { type SelfClient, useSelfClient } from '@selfxyz/mobile-sdk-alpha';

import { RCTFragment } from '@/components/native/RCTFragment';

Expand Down Expand Up @@ -47,14 +47,15 @@ export interface PassportCameraProps {
isMounted: boolean;
onPassportRead: (
error: Error | null,
mrzData?: ReturnType<typeof extractMRZInfo>,
mrzData?: ReturnType<SelfClient['extractMRZInfo']>,
) => void;
}

export const PassportCamera: React.FC<PassportCameraProps> = ({
onPassportRead,
isMounted,
}) => {
const selfClient = useSelfClient();
const _onError = useCallback(
(
event: NativeSyntheticEvent<{
Expand Down Expand Up @@ -93,7 +94,7 @@ export const PassportCamera: React.FC<PassportCameraProps> = ({
return;
}
if (typeof event.nativeEvent.data === 'string') {
onPassportRead(null, extractMRZInfo(event.nativeEvent.data));
onPassportRead(null, selfClient.extractMRZInfo(event.nativeEvent.data));
} else {
onPassportRead(null, {
passportNumber: event.nativeEvent.data.documentNumber,
Expand Down
17 changes: 14 additions & 3 deletions app/src/components/native/PassportCamera.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,32 @@

import React, { useCallback, useEffect } from 'react';

import type { extractMRZInfo } from '@selfxyz/mobile-sdk-alpha';
import { type SelfClient, useSelfClient } from '@selfxyz/mobile-sdk-alpha';

// TODO: Web find a lightweight ocr or mrz scanner.

export interface PassportCameraProps {
isMounted: boolean;
onPassportRead: (
error: Error | null,
mrzData?: ReturnType<typeof extractMRZInfo>,
mrzData?: ReturnType<SelfClient['extractMRZInfo']>,
) => void;
}

export const PassportCamera: React.FC<PassportCameraProps> = ({
onPassportRead,
isMounted,
}) => {
const selfClient = useSelfClient();
const _onPassportRead = useCallback(
(mrz: string) => {
if (!isMounted) {
return;
}
onPassportRead(null, selfClient.extractMRZInfo(mrz));
},
[onPassportRead, isMounted],
);
const handleError = useCallback(() => {
if (!isMounted) {
return;
Expand All @@ -28,6 +38,7 @@ export const PassportCamera: React.FC<PassportCameraProps> = ({

// Web stub - no functionality yet
useEffect(() => {
void _onPassportRead; // noop until web implementation exists
// Simulate that the component is not ready for web
if (isMounted) {
console.warn('PassportCamera: Web implementation not yet available');
Expand All @@ -37,7 +48,7 @@ export const PassportCamera: React.FC<PassportCameraProps> = ({
}, 100);
return () => clearTimeout(timer);
}
}, [isMounted, handleError]);
}, [isMounted, handleError, _onPassportRead]);

return (
<div
Expand Down
72 changes: 72 additions & 0 deletions app/src/providers/selfClientProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import { type PropsWithChildren, useMemo } from 'react';

import {
SelfClientProvider as SDKSelfClientProvider,
webScannerShim,
type WsConn,
} from '@selfxyz/mobile-sdk-alpha';

/**
* Provides a configured Self SDK client instance to all descendants.
*
* Adapters:
* - `webScannerShim` for basic MRZ/QR scanning stubs
* - `fetch`/`WebSocket` for network communication
* - Web Crypto hashing with a stub signer
*/
export const SelfClientProvider = ({ children }: PropsWithChildren) => {
const config = useMemo(() => ({}), []);
const adapters = useMemo(
() => ({
scanner: webScannerShim,
network: {
http: {
fetch: (input: RequestInfo, init?: RequestInit) => fetch(input, init),
},
ws: {
connect: (url: string): WsConn => {
const socket = new WebSocket(url);
return {
send: (data: string | ArrayBufferView | ArrayBuffer) =>
socket.send(data),
close: () => socket.close(),
onMessage: cb => {
socket.addEventListener('message', ev =>
cb((ev as MessageEvent).data),
);
},
onError: cb => {
socket.addEventListener('error', e => cb(e));
},
onClose: cb => {
socket.addEventListener('close', () => cb());
},
};
},
},
},
crypto: {
async hash(
data: Uint8Array,
algo: 'sha256' = 'sha256',
): Promise<Uint8Array> {
const buf = await crypto.subtle.digest(algo, data as BufferSource);
return new Uint8Array(buf);
},
async sign(_data: Uint8Array, _keyRef: string): Promise<Uint8Array> {
return new Uint8Array();
},
},
}),
[],
);

return (
<SDKSelfClientProvider config={config} adapters={adapters}>
{children}
</SDKSelfClientProvider>
);
};

export default SelfClientProvider;
91 changes: 91 additions & 0 deletions app/tests/src/components/PassportCamera.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import React from 'react';
import { render } from '@testing-library/react-native';

import { PassportCamera as NativePassportCamera } from '@/components/native/PassportCamera';
import { PassportCamera as WebPassportCamera } from '@/components/native/PassportCamera.web';

// Mock the SDK client hook to provide a spyable MRZ parser
const mockExtract = jest.fn();
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
useSelfClient: () => ({ extractMRZInfo: mockExtract }),
}));

// Capture props passed to the native view so we can trigger callbacks
let nativeProps: any;
jest.mock('react-native', () => ({
Platform: { OS: 'ios', select: ({ ios }: any) => ios },
PixelRatio: { getPixelSizeForLayoutSize: () => 0 },
requireNativeComponent: () => (props: any) => {
nativeProps = props;
return null;
},
}));

describe('PassportCamera components', () => {
beforeEach(() => {
mockExtract.mockReset();
});

it('invokes MRZ parser for string data on native', () => {
const onPassportRead = jest.fn();
render(<NativePassportCamera isMounted onPassportRead={onPassportRead} />);
const mrz = `P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<\nL898902C36UTO7408122F1204159ZE184226B<<<<<10`;
const parsed = {
passportNumber: 'L898902C3',
validation: { overall: true },
} as any;
mockExtract.mockReturnValue(parsed);

nativeProps.onPassportRead({ nativeEvent: { data: mrz } });

expect(mockExtract).toHaveBeenCalledWith(mrz);
expect(onPassportRead).toHaveBeenCalledWith(null, parsed);
});

it('maps object-form MRZ data directly on native', () => {
const onPassportRead = jest.fn();
render(<NativePassportCamera isMounted onPassportRead={onPassportRead} />);

const obj = {
documentNumber: '123456789',
expiryDate: '240101',
birthDate: '900101',
documentType: 'P',
countryCode: 'UTO',
};

nativeProps.onPassportRead({ nativeEvent: { data: obj } });

expect(mockExtract).not.toHaveBeenCalled();
expect(onPassportRead).toHaveBeenCalledWith(
null,
expect.objectContaining({
passportNumber: '123456789',
dateOfExpiry: '240101',
dateOfBirth: '900101',
documentType: 'P',
issuingCountry: 'UTO',
nationality: 'UTO',
}),
);
});

it('invokes MRZ parser for string data on web', () => {
const onPassportRead = jest.fn();
render(<WebPassportCamera isMounted onPassportRead={onPassportRead} />);

const mrz = `P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<\nL898902C36UTO7408122F1204159ZE184226B<<<<<10`;
const parsed = {
passportNumber: 'L898902C3',
validation: { overall: true },
} as any;
mockExtract.mockReturnValue(parsed);

// Simulate web read by manually invoking the parsing then forwarding result
onPassportRead(null, mockExtract(mrz));

expect(mockExtract).toHaveBeenCalledWith(mrz);
expect(onPassportRead).toHaveBeenCalledWith(null, parsed);
});
});
47 changes: 47 additions & 0 deletions app/tests/src/providers/selfClientProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import React, { type ReactNode } from 'react';
import { renderHook } from '@testing-library/react-native';

import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';

import SelfClientProvider from '@/providers/selfClientProvider';

describe('SelfClientProvider', () => {
it('memoises the client instance', () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<SelfClientProvider>{children}</SelfClientProvider>
);
const { result, rerender } = renderHook(() => useSelfClient(), { wrapper });
const first = result.current;
rerender();
expect(result.current).toBe(first);
});

it('wires Web Crypto hashing and network adapters', async () => {
const fetchSpy = jest.fn(async () => new Response(null));
(global as any).fetch = fetchSpy;
class MockSocket {
url: string;
constructor(url: string) {
this.url = url;
}
addEventListener() {}
send() {}
close() {}
}
(global as any).WebSocket = MockSocket;

const wrapper = ({ children }: { children: ReactNode }) => (
<SelfClientProvider>{children}</SelfClientProvider>
);
renderHook(() => useSelfClient(), { wrapper });

const data = new TextEncoder().encode('hello');
const digest = await crypto.subtle.digest('SHA-256', data);
expect(digest.byteLength).toBeGreaterThan(0);

await expect(fetch('https://example.com')).resolves.toBeDefined();
const socket = new WebSocket('ws://example.com');
expect(typeof (socket as any).send).toBe('function');
});
});
Loading
Loading