diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx
index c4c0969..5406583 100644
--- a/apps/mobile/app/_layout.tsx
+++ b/apps/mobile/app/_layout.tsx
@@ -1,33 +1,32 @@
+import { crypto } from '@chat-app/shared';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ErrorBoundary } from '../components/ErrorBoundary';
+import { AuthProvider } from '../lib/authContext';
+import { createLibsodiumBackend } from '../lib/cryptoBackend';
-// Force-resolve @chat-app/shared at boot so Metro packaging issues
-// surface during the very first dev-client load instead of mid-Phase-1.
-// Reference the export so tree-shaking can't drop it.
-import { sharedSmoke } from '../lib/sharedSmoke';
-void sharedSmoke;
+// Register the crypto backend exactly once, before any code that calls
+// crypto.getCryptoBackend(). Doing it at module load (not inside the
+// component) avoids a race where a child component renders before the
+// effect fires.
+crypto.setCryptoBackend(createLibsodiumBackend());
-// Root layout for every Expo Router screen. Provider stack order:
-// GestureHandlerRootView — required by gesture-driven libs (BottomSheet,
-// swipeable rows, drawer nav). Must be the
-// outermost so gesture state is global.
-// SafeAreaProvider — feeds notch / status-bar insets to children.
-// ErrorBoundary — last line of defence for render errors.
-// Stack — Expo Router's screen registry.
export default function RootLayout() {
return (
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/apps/mobile/lib/authContext.tsx b/apps/mobile/lib/authContext.tsx
new file mode 100644
index 0000000..243d19e
--- /dev/null
+++ b/apps/mobile/lib/authContext.tsx
@@ -0,0 +1,124 @@
+import type { Session, User } from '@supabase/supabase-js';
+import { auth, crypto } from '@chat-app/shared';
+import type { DeviceRecord } from '@chat-app/shared/auth';
+import { createContext, useCallback, useContext, useEffect, useState } from 'react';
+import { Platform } from 'react-native';
+
+import { secretStore } from './secretStore';
+import { supabase } from './supabase';
+
+// Locally-stored secrets keyed by stable names. Mirrors the desktop
+// convention so the migration tests (later) can compare snapshots.
+const KEY_DEVICE_ID = 'device.id';
+const KEY_DEVICE_PRIVKEY = 'device.privateKey';
+
+interface AuthContextValue {
+ session: Session | null;
+ user: User | null;
+ device: DeviceRecord | null;
+ ownPrivateKey: Uint8Array | null;
+ loading: boolean;
+ signOut: () => Promise;
+}
+
+const Ctx = createContext(null);
+
+export function useAuth(): AuthContextValue {
+ const v = useContext(Ctx);
+ if (!v) throw new Error('useAuth() called outside ');
+ return v;
+}
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const [session, setSession] = useState(null);
+ const [device, setDevice] = useState(null);
+ const [ownPrivateKey, setOwnPrivateKey] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ // Resolve or create the device record for this install given an active
+ // session. Stores the private key in expo-secure-store on first run.
+ const ensureDevice = useCallback(async (currentSession: Session): Promise => {
+ const userId = currentSession.user.id;
+ const savedDeviceId = await secretStore.getSecret(KEY_DEVICE_ID);
+ const savedPrivKey = await secretStore.getSecret(KEY_DEVICE_PRIVKEY);
+
+ if (savedDeviceId && savedPrivKey) {
+ const devices = await auth.listOwnDevices(supabase);
+ const deviceIdStr = new TextDecoder().decode(savedDeviceId);
+ const match = devices.find((d) => d.id === deviceIdStr);
+ if (match) {
+ setDevice(match);
+ setOwnPrivateKey(savedPrivKey);
+ return;
+ }
+ // Stored id no longer matches any device on the server (revoked,
+ // wiped). Fall through to register a fresh one.
+ }
+
+ const backend = crypto.getCryptoBackend();
+ const kp = backend.generateKeyPair();
+ const platform = Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'linux';
+ const record = await auth.registerDevice(supabase, {
+ name: 'Netralax Mobile (' + Platform.OS + ')',
+ platform,
+ publicKey: kp.publicKey,
+ });
+ await secretStore.setSecret(KEY_DEVICE_ID, new TextEncoder().encode(record.id));
+ await secretStore.setSecret(KEY_DEVICE_PRIVKEY, kp.privateKey);
+ setDevice(record);
+ setOwnPrivateKey(kp.privateKey);
+ void userId;
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ const { data } = await supabase.auth.getSession();
+ if (cancelled) return;
+ setSession(data.session);
+ if (data.session) {
+ try {
+ await ensureDevice(data.session);
+ } catch (err) {
+ console.warn('[auth] ensureDevice failed', err);
+ }
+ }
+ setLoading(false);
+ })();
+
+ const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => {
+ setSession(nextSession);
+ if (!nextSession) {
+ setDevice(null);
+ setOwnPrivateKey(null);
+ } else {
+ void ensureDevice(nextSession).catch((err) =>
+ console.warn('[auth] ensureDevice (state change) failed', err),
+ );
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ sub.subscription.unsubscribe();
+ };
+ }, [ensureDevice]);
+
+ const signOut = useCallback(async (): Promise => {
+ await supabase.auth.signOut();
+ await secretStore.removeSecret(KEY_DEVICE_ID);
+ await secretStore.removeSecret(KEY_DEVICE_PRIVKEY);
+ setDevice(null);
+ setOwnPrivateKey(null);
+ }, []);
+
+ const value: AuthContextValue = {
+ session,
+ user: session?.user ?? null,
+ device,
+ ownPrivateKey,
+ loading,
+ signOut,
+ };
+ return {children};
+}
diff --git a/apps/mobile/lib/sharedSmoke.ts b/apps/mobile/lib/sharedSmoke.ts
deleted file mode 100644
index c736362..0000000
--- a/apps/mobile/lib/sharedSmoke.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-// Runtime canary import from @chat-app/shared. Phase 0's only goal here
-// is to prove that Metro can resolve the workspace package and that
-// nothing in shared/crypto pulls in an RN-incompatible module path.
-//
-// `import type` would be erased by the TypeScript compiler before
-// reaching Metro, so we deliberately import a runtime symbol —
-// `crypto.setCryptoBackend` — and stash it in an exported reference.
-// We never call it here; Phase 1's real adapter does that after
-// constructing a CryptoBackend wrapping react-native-libsodium.
-//
-// This file is deleted in Phase 1 once the real adapter lands.
-import { crypto } from '@chat-app/shared';
-
-export const sharedSmoke = { register: crypto.setCryptoBackend };