north-star / commit
encryption is working
.vscode/settings.json | 4 + mobile/App.tsx | 100 +++++++- mobile/package-lock.json | 37 ++- mobile/package.json | 6 +- mobile/src/core/crypto.ts | 92 ++++++++ mobile/src/features/friends/sync.ts | 82 ++++++- mobile/src/screens/onboarding_screen.tsx | 257 ++++++++++++++++++++ mobile/src/screens/room_screen.tsx | 30 ++- mobile/src/screens/room_session_screen.tsx | 365 +++++++++++++++++++++++++++++ 9 files changed, 948 insertions(+), 25 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 2a60ba5..e9d5b25 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,6 +17,10 @@ "/^npx tsc --noEmit && npm run build 2>&1 \\| tail -20$/": { "approve": true, "matchCommandLine": true + }, + "/^cd /Users/pavel/code/north-star && npm run mobile:typecheck 2>&1$/": { + "approve": true, + "matchCommandLine": true } }, "cSpell.words": [ diff --git a/mobile/App.tsx b/mobile/App.tsx index 8c4602a..72e6795 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -1,12 +1,68 @@ +import { useEffect, useState } from 'react'; import { StatusBar } from 'expo-status-bar'; -import { SafeAreaView, StyleSheet } from 'react-native'; -import { RoomScreen } from './src/screens/room_screen'; +import { SafeAreaView, StyleSheet, Text, View } from 'react-native'; +import type { SymmetricKey } from './src/core/crypto'; +import { loadProfile, saveProfile } from './src/platform/profile'; +import { OnboardingScreen } from './src/screens/onboarding_screen'; +import { RoomSessionScreen } from './src/screens/room_session_screen'; + +const createDefaultDisplayName = () => `mobile-${Math.floor(1000 + Math.random() * 9000)}`; export default function App() { + const [displayName, setDisplayName] = useState(createDefaultDisplayName()); + const [displayColor, setDisplayColor] = useState('#3366cc'); + const [activeRoom, setActiveRoom] = useState<{ roomHash: string; roomKey: SymmetricKey | null } | null>(null); + + useEffect(() => { + loadProfile().then((saved) => { + if (saved) { + setDisplayName(saved.name); + setDisplayColor(saved.color); + } + }).catch(() => { /* ignore storage errors on load */ }); + }, []); + + useEffect(() => { + const id = setTimeout(() => { + void saveProfile(displayName, displayColor); + }, 1000); + return () => clearTimeout(id); + }, [displayName, displayColor]); + + const isInSession = !!activeRoom; + return ( <SafeAreaView style={styles.root}> <StatusBar style="dark" /> - <RoomScreen /> + <View style={styles.progressWrap}> + <View style={styles.progressHeaderRow}> + <View style={[styles.stepPill, !isInSession && styles.stepPillActive]}> + <Text style={[styles.stepPillText, !isInSession && styles.stepPillTextActive]}>1. Setup</Text> + </View> + <View style={[styles.stepConnector, isInSession && styles.stepConnectorActive]} /> + <View style={[styles.stepPill, isInSession && styles.stepPillActive]}> + <Text style={[styles.stepPillText, isInSession && styles.stepPillTextActive]}>2. Live Room</Text> + </View> + </View> + </View> + {!activeRoom && ( + <OnboardingScreen + displayName={displayName} + displayColor={displayColor} + onChangeDisplayName={setDisplayName} + onChangeDisplayColor={setDisplayColor} + onStartRoom={setActiveRoom} + /> + )} + {!!activeRoom && ( + <RoomSessionScreen + roomHash={activeRoom.roomHash} + roomKey={activeRoom.roomKey} + displayName={displayName} + displayColor={displayColor} + onLeaveRoom={() => setActiveRoom(null)} + /> + )} </SafeAreaView> ); } @@ -15,5 +71,43 @@ const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#f5f1ea' + }, + progressWrap: { + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 8, + backgroundColor: '#efe8d9', + borderBottomWidth: 1, + borderBottomColor: '#ddd2bc' + }, + progressHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8 + }, + stepPill: { + borderRadius: 999, + backgroundColor: '#d9cfbb', + paddingHorizontal: 12, + paddingVertical: 6 + }, + stepPillActive: { + backgroundColor: '#7a4f2a' + }, + stepPillText: { + color: '#5f5648', + fontSize: 12, + fontWeight: '700' + }, + stepPillTextActive: { + color: '#f6ead9' + }, + stepConnector: { + height: 2, + flex: 1, + backgroundColor: '#cdbfa6' + }, + stepConnectorActive: { + backgroundColor: '#7a4f2a' } }); diff --git a/mobile/package-lock.json b/mobile/package-lock.json index e652472..63711b7 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -8,16 +8,20 @@ "name": "mobile", "version": "1.0.0", "dependencies": { + "@noble/ciphers": "^2.1.1", "@react-native-async-storage/async-storage": "2.2.0", + "base-64": "^1.0.0", "expo": "~54.0.0", "expo-location": "~19.0.8", "expo-sensors": "~15.0.8", "expo-status-bar": "~3.0.9", "firebase": "^12.0.0", "react": "19.1.0", - "react-native": "0.81.5" + "react-native": "0.81.5", + "tweetnacl": "^1.0.3" }, "devDependencies": { + "@types/base-64": "^1.0.2", "@types/react": "~19.1.10", "typescript": "~5.9.2" } @@ -2700,6 +2704,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -3095,6 +3111,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/base-64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/base-64/-/base-64-1.0.2.tgz", + "integrity": "sha512-uPgKMmM9fmn7I+Zi6YBqctOye4SlJsHKcisjHIMWpb2YKZRc36GpKyNuQ03JcT+oNXg1m7Uv4wU94EVltn8/cw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3551,6 +3574,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -8090,6 +8119,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", diff --git a/mobile/package.json b/mobile/package.json index 9fc990f..e4e3cb7 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -10,16 +10,20 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@noble/ciphers": "^2.1.1", "@react-native-async-storage/async-storage": "2.2.0", + "base-64": "^1.0.0", "expo": "~54.0.0", "expo-location": "~19.0.8", "expo-sensors": "~15.0.8", "expo-status-bar": "~3.0.9", "firebase": "^12.0.0", "react": "19.1.0", - "react-native": "0.81.5" + "react-native": "0.81.5", + "tweetnacl": "^1.0.3" }, "devDependencies": { + "@types/base-64": "^1.0.2", "@types/react": "~19.1.10", "typescript": "~5.9.2" }, diff --git a/mobile/src/core/crypto.ts b/mobile/src/core/crypto.ts new file mode 100644 index 0000000..3792a0d --- /dev/null +++ b/mobile/src/core/crypto.ts @@ -0,0 +1,92 @@ +import { gcm } from '@noble/ciphers/aes.js'; +import { utf8ToBytes, bytesToUtf8 } from '@noble/ciphers/utils.js'; +import { encode as base64Encode, decode as base64Decode } from 'base-64'; + +const KEY_BYTES = 32; +const IV_BYTES = 12; + +const bytesToBase64 = (bytes: Uint8Array): string => { + let binary = ''; + for (let i = 0; i < bytes.length; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + return base64Encode(binary); +}; + +const base64ToBytes = (base64: string): Uint8Array => { + const binary = base64Decode(base64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + out[i] = binary.charCodeAt(i); + } + return out; +}; + +const randomBytes = (length: number): Uint8Array => { + const out = new Uint8Array(length); + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + crypto.getRandomValues(out); + return out; + } + for (let i = 0; i < out.length; i += 1) { + out[i] = Math.floor(Math.random() * 256); + } + return out; +}; + +/** + * Represents a symmetric encryption key. + * Stores raw AES-256 key bytes shared between web and mobile. + */ +export interface SymmetricKey { + _type: 'SymmetricKey'; + keyBytes: Uint8Array; +} + +/** + * Generate a new 256-bit symmetric key. + */ +export const generateRoomKey = (): SymmetricKey => { + return { _type: 'SymmetricKey', keyBytes: randomBytes(KEY_BYTES) }; +}; + +/** + * Export a key to base64url format for room hashes. + */ +export const exportRoomKey = async (key: SymmetricKey): Promise<string> => { + const b64 = bytesToBase64(key.keyBytes); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +}; + +/** + * Import a base64url-encoded key from a room hash. + */ +export const importRoomKey = async (base64url: string): Promise<SymmetricKey> => { + const b64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); + const keyBytes = base64ToBytes(padded); + return { _type: 'SymmetricKey', keyBytes }; +}; + +/** + * Encrypts JSON using AES-256-GCM and returns `ivBase64:ciphertextBase64`. + * This matches the web app format for cross-platform interoperability. + */ +export const encryptJson = async (key: SymmetricKey, value: unknown): Promise<string> => { + const iv = randomBytes(IV_BYTES); + const plaintext = utf8ToBytes(JSON.stringify(value)); + const ciphertext = gcm(key.keyBytes, iv).encrypt(plaintext); + return `${bytesToBase64(iv)}:${bytesToBase64(ciphertext)}`; +}; + +/** + * Decrypts JSON from `ivBase64:ciphertextBase64` produced by `encryptJson`. + */ +export const decryptJson = async (key: SymmetricKey, encryptedValue: string): Promise<unknown> => { + const [ivB64, ctB64] = encryptedValue.split(':'); + if (!ivB64 || !ctB64) throw new Error('Invalid encrypted payload format'); + const iv = base64ToBytes(ivB64); + const ciphertext = base64ToBytes(ctB64); + const plaintext = gcm(key.keyBytes, iv).decrypt(ciphertext); + return JSON.parse(bytesToUtf8(plaintext)); +}; diff --git a/mobile/src/features/friends/sync.ts b/mobile/src/features/friends/sync.ts index 84ed18d..d92459a 100644 --- a/mobile/src/features/friends/sync.ts +++ b/mobile/src/features/friends/sync.ts @@ -1,4 +1,5 @@ import { onValue, ref, serverTimestamp, set, type Database } from 'firebase/database'; +import { decryptJson, encryptJson, type SymmetricKey } from '../../core/crypto'; export type MobileFriendRecord = { id: string; @@ -11,13 +12,19 @@ export type MobileFriendRecord = { export type LatLon = { lat: number; lon: number }; +type MobileFriendDbRecord = MobileFriendRecord & { + encryptedState?: string; + encryptedPosition?: string; +}; + export const upsertSelfFriend = async ({ db, roomId, uid, name, color, - position + position, + roomKey }: { db: Database; roomId: string; @@ -25,14 +32,27 @@ export const upsertSelfFriend = async ({ name: string; color: string; position: LatLon | null; + roomKey: SymmetricKey | null; }) => { const friendRef = ref(db, `rooms/${roomId}/friends/${uid}`); - await set(friendRef, { + const friendData: MobileFriendRecord = { id: uid, name, color, - position, - timestamp: Date.now(), + position: position ?? undefined, + timestamp: Date.now() + }; + + if (roomKey) { + await set(friendRef, { + encryptedState: await encryptJson(roomKey, friendData), + lastSeen: serverTimestamp() + }); + return; + } + + await set(friendRef, { + ...friendData, lastSeen: serverTimestamp() }); }; @@ -40,11 +60,13 @@ export const upsertSelfFriend = async ({ export const subscribeToFriends = ({ db, roomId, + roomKey, onUpdate, onError }: { db: Database; roomId: string; + roomKey: SymmetricKey | null; onUpdate: (friends: MobileFriendRecord[]) => void; onError: (error: Error) => void; }) => { @@ -52,15 +74,49 @@ export const subscribeToFriends = ({ return onValue( friendsRef, - (snapshot) => { - const value = snapshot.val() as Record<string, MobileFriendRecord> | null; - const nextFriends = value ? Object.values(value) : []; - nextFriends.sort((a, b) => { - const aName = (a.name || '').toLowerCase(); - const bName = (b.name || '').toLowerCase(); - return aName.localeCompare(bName); - }); - onUpdate(nextFriends); + async (snapshot) => { + try { + const value = snapshot.val() as Record<string, MobileFriendDbRecord> | null; + const raw = value ? Object.values(value) : []; + + const resolved = await Promise.all(raw.map(async (friend): Promise<MobileFriendRecord | null> => { + if (friend.encryptedState) { + if (!roomKey) return null; + try { + const decrypted = await decryptJson(roomKey, friend.encryptedState); + return { + ...(decrypted as MobileFriendRecord), + lastSeen: friend.lastSeen + }; + } catch { + return null; + } + } + + if (friend.encryptedPosition) { + if (!roomKey) return null; + try { + const position = await decryptJson(roomKey, friend.encryptedPosition) as { lat: number; lon: number }; + const { encryptedPosition: _encryptedPosition, ...rest } = friend; + return { ...rest, position }; + } catch { + return null; + } + } + + return friend; + })); + + const nextFriends = resolved.filter((item): item is MobileFriendRecord => item !== null); + nextFriends.sort((a, b) => { + const aName = (a.name || '').toLowerCase(); + const bName = (b.name || '').toLowerCase(); + return aName.localeCompare(bName); + }); + onUpdate(nextFriends); + } catch (err) { + onError(err instanceof Error ? err : new Error('Unknown error reading friends')); + } }, (error) => { onError(error as Error); diff --git a/mobile/src/screens/onboarding_screen.tsx b/mobile/src/screens/onboarding_screen.tsx new file mode 100644 index 0000000..2087e99 --- /dev/null +++ b/mobile/src/screens/onboarding_screen.tsx @@ -0,0 +1,257 @@ +import { useState } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { generateRoomKey, exportRoomKey, importRoomKey, type SymmetricKey } from '../core/crypto'; +import { buildRoomHash, parseRoomFromHash } from '../core/room'; +import { PUBLIC_SETTINGS } from '../public_settings'; + +type OnboardingScreenProps = { + displayName: string; + displayColor: string; + onChangeDisplayName: (name: string) => void; + onChangeDisplayColor: (color: string) => void; + onStartRoom: (params: { roomHash: string; roomKey: SymmetricKey | null }) => void; +}; + +const randomRoomId = () => { + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let out = ''; + for (let i = 0; i < PUBLIC_SETTINGS.room.idLength; i += 1) { + out += alphabet[Math.floor(Math.random() * alphabet.length)]; + } + return out; +}; + +export function OnboardingScreen({ + displayName, + displayColor, + onChangeDisplayName, + onChangeDisplayColor, + onStartRoom +}: OnboardingScreenProps) { + const [roomHashInput, setRoomHashInput] = useState(''); + const [joinError, setJoinError] = useState<string | null>(null); + + const createRoom = async () => { + setJoinError(null); + const roomId = randomRoomId(); + const key = generateRoomKey(); + const keyB64 = await exportRoomKey(key); + const hash = buildRoomHash(roomId, keyB64); + + const webAppUrl = `http://localhost:5173/#${hash}`; + console.log(`Room created. Join from web: ${webAppUrl}`); + + onStartRoom({ roomHash: hash, roomKey: key }); + }; + + const joinRoom = async () => { + setJoinError(null); + const normalized = roomHashInput.trim().replace(/^#/, ''); + const parsed = parseRoomFromHash(normalized); + if (!parsed) { + setJoinError('Invalid room hash.'); + return; + } + + const hash = buildRoomHash(parsed.roomId, parsed.roomKeyBase64); + const roomKey = parsed.roomKeyBase64 ? await importRoomKey(parsed.roomKeyBase64) : null; + onStartRoom({ roomHash: hash, roomKey }); + }; + + return ( + <ScrollView contentContainerStyle={styles.container}> + <View style={styles.heroCard}> + <View style={styles.heroOrbA} /> + <View style={styles.heroOrbB} /> + <Text style={styles.heroEyebrow}>NORTH STAR</Text> + <Text style={styles.title}>Friend Compass</Text> + <Text style={styles.subtitle}>Set your profile, then create or join a room to start live direction sync.</Text> + </View> + + <View style={styles.card}> + <View style={styles.profileHeaderRow}> + <Text style={styles.label}>Step 1: Your profile</Text> + <View style={[styles.colorPreview, { backgroundColor: displayColor || '#3366cc' }]} /> + </View> + <TextInput + value={displayName} + onChangeText={onChangeDisplayName} + autoCapitalize="none" + autoCorrect={false} + placeholder="display name" + placeholderTextColor="#7a7469" + style={styles.input} + /> + <TextInput + value={displayColor} + onChangeText={onChangeDisplayColor} + autoCapitalize="none" + autoCorrect={false} + placeholder="#3366cc" + placeholderTextColor="#7a7469" + style={styles.input} + /> + <Text style={styles.helperText}>Pick a name and color that others will see in the room.</Text> + </View> + + <View style={styles.createCard}> + <Text style={styles.label}>Step 2A: Create room</Text> + <Text style={styles.helperText}>Creates a fresh encrypted room hash for sharing.</Text> + <Pressable onPress={() => void createRoom()} style={({ pressed }) => [styles.buttonPrimary, pressed && styles.buttonPressed]}> + <Text style={styles.buttonText}>Create encrypted room</Text> + </Pressable> + </View> + + <View style={styles.joinCard}> + <Text style={styles.label}>Step 2B: Join existing room</Text> + <Text style={styles.helperText}>Paste an invite hash from web or another phone.</Text> + <TextInput + value={roomHashInput} + onChangeText={setRoomHashInput} + autoCapitalize="none" + autoCorrect={false} + placeholder="example: ab12cd34" + placeholderTextColor="#7a7469" + style={styles.input} + /> + <Pressable onPress={() => void joinRoom()} style={({ pressed }) => [styles.buttonSecondary, pressed && styles.buttonPressed]}> + <Text style={styles.buttonText}>Join room</Text> + </Pressable> + {!!joinError && <Text style={styles.errorText}>{joinError}</Text>} + </View> + + <Text style={styles.footerNote}>Tip: after creating a room, the app logs a web URL in the console for quick laptop join.</Text> + </ScrollView> + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 20, + paddingVertical: 28, + gap: 14 + }, + heroCard: { + backgroundColor: '#ddd0b9', + borderRadius: 18, + padding: 18, + gap: 8, + overflow: 'hidden', + borderWidth: 1, + borderColor: '#c9b89d' + }, + heroOrbA: { + position: 'absolute', + width: 130, + height: 130, + borderRadius: 65, + backgroundColor: '#c8b08a', + right: -30, + top: -35, + opacity: 0.55 + }, + heroOrbB: { + position: 'absolute', + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: '#b38a56', + right: 20, + bottom: -45, + opacity: 0.22 + }, + heroEyebrow: { + letterSpacing: 1.6, + fontSize: 11, + fontWeight: '800', + color: '#6d5330' + }, + title: { + fontSize: 30, + fontWeight: '800', + color: '#2e2a23' + }, + subtitle: { + fontSize: 15, + color: '#5b554c' + }, + card: { + backgroundColor: '#ece6da', + borderRadius: 14, + padding: 16, + gap: 10 + }, + createCard: { + backgroundColor: '#e4dfd1', + borderRadius: 14, + padding: 16, + gap: 10, + borderWidth: 1, + borderColor: '#cac0ac' + }, + joinCard: { + backgroundColor: '#e5d8c4', + borderRadius: 14, + padding: 16, + gap: 10, + borderWidth: 1, + borderColor: '#ccb798' + }, + profileHeaderRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center' + }, + colorPreview: { + width: 18, + height: 18, + borderRadius: 999, + borderWidth: 1, + borderColor: '#8f8778' + }, + label: { + fontSize: 14, + fontWeight: '600', + color: '#2e2a23' + }, + helperText: { + fontSize: 12, + color: '#6a6359' + }, + input: { + backgroundColor: '#f6f1e7', + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + color: '#2e2a23' + }, + buttonPrimary: { + backgroundColor: '#7a4f2a', + borderRadius: 10, + alignItems: 'center', + paddingVertical: 12 + }, + buttonSecondary: { + backgroundColor: '#5a6f3d', + borderRadius: 10, + alignItems: 'center', + paddingVertical: 12 + }, + buttonPressed: { + opacity: 0.85 + }, + buttonText: { + color: '#f7eee2', + fontSize: 15, + fontWeight: '700' + }, + errorText: { + fontSize: 14, + color: '#8b2f2f' + }, + footerNote: { + fontSize: 12, + color: '#6f695f', + paddingHorizontal: 4 + } +}); diff --git a/mobile/src/screens/room_screen.tsx b/mobile/src/screens/room_screen.tsx index b960ace..a2b9021 100644 --- a/mobile/src/screens/room_screen.tsx +++ b/mobile/src/screens/room_screen.tsx @@ -10,6 +10,7 @@ import { } from 'react-native'; import { formatDistance, getBearingDeg, getDistanceMeters, getRelativeBearingDeg } from '../core/bearing'; import { buildRoomHash, parseRoomFromHash } from '../core/room'; +import { generateRoomKey, exportRoomKey, importRoomKey, type SymmetricKey } from '../core/crypto'; import { watchHeading } from '../platform/heading'; import { loadProfile, saveProfile } from '../platform/profile'; import { subscribeToFriends, upsertSelfFriend, type MobileFriendRecord } from '../features/friends/sync'; @@ -51,6 +52,7 @@ export function RoomScreen() { const [position, setPosition] = useState<LatLon | null>(null); const [locationStatus, setLocationStatus] = useState<'idle' | 'denied' | 'watching'>('idle'); const [headingDeg, setHeadingDeg] = useState(0); + const [roomKey, setRoomKey] = useState<SymmetricKey | null>(null); const parsedRoom = useMemo(() => { if (!activeRoomHash) return null; @@ -75,24 +77,36 @@ export function RoomScreen() { return () => clearTimeout(id); }, [displayName, displayColor]); - const createRoom = () => { + const createRoom = async () => { const roomId = randomRoomId(); - const hash = buildRoomHash(roomId); + const key = generateRoomKey(); + setRoomKey(key); + + const keyB64 = await exportRoomKey(key); + const hash = buildRoomHash(roomId, keyB64); setActiveRoomHash(hash); setRoomHashInput(''); // Log the URL for the web app to join this room const webAppUrl = `http://localhost:5173/#${hash}`; - console.log(`📱 Room created! Join on web app:\n${webAppUrl}`); + console.log(`📱 Encrypted room created! Join on web app:\n${webAppUrl}`); console.log(`Replace localhost:5173 with your web app URL if needed.`); }; - const joinFromInput = () => { + const joinFromInput = async () => { const normalized = roomHashInput.trim().replace(/^#/, ''); const parsed = parseRoomFromHash(normalized); if (!parsed) return; setActiveRoomHash(buildRoomHash(parsed.roomId, parsed.roomKeyBase64)); setRoomHashInput(''); + + // Import encryption key if present + if (parsed.roomKeyBase64) { + const key = await importRoomKey(parsed.roomKeyBase64); + setRoomKey(key); + } else { + setRoomKey(null); + } }; const connectFirebase = async () => { @@ -161,7 +175,8 @@ export function RoomScreen() { uid: firebaseSession.uid, name: displayName.trim() || createDefaultDisplayName(), color: normalizeColor(displayColor), - position: currentPosition + position: currentPosition, + roomKey }); } catch (error) { const nextError = error instanceof Error ? error.message : 'Unknown room sync write error'; @@ -179,6 +194,7 @@ export function RoomScreen() { const unsubscribe = subscribeToFriends({ db: firebaseSession.db, roomId, + roomKey, onUpdate: (nextFriends) => { if (!isDisposed) setFriends(nextFriends); }, @@ -192,7 +208,7 @@ export function RoomScreen() { clearInterval(intervalId); unsubscribe(); }; - }, [displayColor, displayName, firebaseSession, isRoomSyncEnabled, parsedRoom, position]); + }, [displayColor, displayName, firebaseSession, isRoomSyncEnabled, parsedRoom, position, roomKey]); return ( <ScrollView contentContainerStyle={styles.container}> @@ -229,7 +245,7 @@ export function RoomScreen() { <> <Text style={styles.value}>Hash: {activeRoomHash}</Text> <Text style={styles.value}>Room id: {parsedRoom?.roomId ?? 'invalid'}</Text> - <Text style={styles.value}>Encrypted key: {parsedRoom?.roomKeyBase64 ? 'present' : 'none'}</Text> + <Text style={styles.value}>Encryption: {roomKey ? '🔐 enabled' : 'none'}</Text> </> )} </View> diff --git a/mobile/src/screens/room_session_screen.tsx b/mobile/src/screens/room_session_screen.tsx new file mode 100644 index 0000000..be5e840 --- /dev/null +++ b/mobile/src/screens/room_session_screen.tsx @@ -0,0 +1,365 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Text, + View +} from 'react-native'; +import { formatDistance, getBearingDeg, getDistanceMeters, getRelativeBearingDeg } from '../core/bearing'; +import { parseRoomFromHash } from '../core/room'; +import type { SymmetricKey } from '../core/crypto'; +import { watchHeading } from '../platform/heading'; +import { subscribeToFriends, upsertSelfFriend, type MobileFriendRecord } from '../features/friends/sync'; +import { initFirebaseSession, type FirebaseSession } from '../platform/firebase'; +import { requestLocationPermission, watchPosition, type LatLon } from '../platform/geolocation'; + +const MOBILE_SYNC_INTERVAL_MS = 10000; + +type RoomSessionScreenProps = { + roomHash: string; + roomKey: SymmetricKey | null; + displayName: string; + displayColor: string; + onLeaveRoom: () => void; +}; + +const createDefaultDisplayName = () => `mobile-${Math.floor(1000 + Math.random() * 9000)}`; + +const normalizeColor = (value: string) => { + const candidate = value.trim().toLowerCase(); + if (!/^#[0-9a-f]{6}$/.test(candidate)) return '#3366cc'; + return candidate; +}; + +export function RoomSessionScreen({ + roomHash, + roomKey, + displayName, + displayColor, + onLeaveRoom +}: RoomSessionScreenProps) { + const [firebaseSession, setFirebaseSession] = useState<FirebaseSession | null>(null); + const [isConnectingFirebase, setIsConnectingFirebase] = useState(false); + const [firebaseError, setFirebaseError] = useState<string | null>(null); + + const [isRoomSyncEnabled, setIsRoomSyncEnabled] = useState(false); + const [friends, setFriends] = useState<MobileFriendRecord[]>([]); + const [roomSyncError, setRoomSyncError] = useState<string | null>(null); + const [position, setPosition] = useState<LatLon | null>(null); + const [locationStatus, setLocationStatus] = useState<'idle' | 'denied' | 'watching'>('idle'); + const [headingDeg, setHeadingDeg] = useState(0); + + const parsedRoom = useMemo(() => parseRoomFromHash(roomHash), [roomHash]); + + const connectFirebase = async () => { + setIsConnectingFirebase(true); + setFirebaseError(null); + try { + const session = await initFirebaseSession(); + setFirebaseSession(session); + } catch (error) { + const nextError = error instanceof Error ? error.message : 'Unknown Firebase error'; + setFirebaseError(nextError); + } finally { + setIsConnectingFirebase(false); + } + }; + + useEffect(() => { + if (!isRoomSyncEnabled) return; + + let stopWatching: (() => void) | null = null; + + const start = async () => { + const granted = await requestLocationPermission(); + if (!granted) { + setLocationStatus('denied'); + return; + } + setLocationStatus('watching'); + stopWatching = watchPosition( + (nextPosition) => setPosition(nextPosition), + (message) => setRoomSyncError(message) + ); + }; + + void start(); + + return () => { + stopWatching?.(); + setLocationStatus('idle'); + }; + }, [isRoomSyncEnabled]); + + useEffect(() => { + if (!isRoomSyncEnabled) return; + const stop = watchHeading( + (deg) => setHeadingDeg(deg), + (msg) => setRoomSyncError(msg) + ); + return stop; + }, [isRoomSyncEnabled]); + + useEffect(() => { + if (!isRoomSyncEnabled || !firebaseSession || !parsedRoom) { + setFriends([]); + return; + } + + let isDisposed = false; + const roomId = parsedRoom.roomId; + + const writeSelf = async (currentPosition: LatLon) => { + try { + await upsertSelfFriend({ + db: firebaseSession.db, + roomId, + uid: firebaseSession.uid, + name: displayName.trim() || createDefaultDisplayName(), + color: normalizeColor(displayColor), + position: currentPosition, + roomKey + }); + } catch (error) { + const nextError = error instanceof Error ? error.message : 'Unknown room sync write error'; + if (!isDisposed) setRoomSyncError(nextError); + } + }; + + setRoomSyncError(null); + if (position) void writeSelf(position); + + const intervalId = setInterval(() => { + if (position) void writeSelf(position); + }, MOBILE_SYNC_INTERVAL_MS); + + const unsubscribe = subscribeToFriends({ + db: firebaseSession.db, + roomId, + roomKey, + onUpdate: (nextFriends) => { + if (!isDisposed) setFriends(nextFriends); + }, + onError: (error) => { + if (!isDisposed) setRoomSyncError(error.message); + } + }); + + return () => { + isDisposed = true; + clearInterval(intervalId); + unsubscribe(); + }; + }, [displayColor, displayName, firebaseSession, isRoomSyncEnabled, parsedRoom, position, roomKey]); + + return ( + <ScrollView contentContainerStyle={styles.container}> + <View style={styles.heroCard}> + <View style={styles.heroOrb} /> + <View style={styles.headerRow}> + <View> + <Text style={styles.title}>Room Session</Text> + <Text style={styles.subtitle}>Room id: {parsedRoom?.roomId ?? 'invalid'}</Text> + </View> + <Pressable onPress={onLeaveRoom} style={({ pressed }) => [styles.secondaryButton, pressed && styles.buttonPressed]}> + <Text style={styles.secondaryButtonText}>Leave</Text> + </Pressable> + </View> + </View> + + <View style={styles.resultCard}> + <Text style={styles.label}>Live room details</Text> + <View> + <Text style={styles.value}>Hash: {roomHash}</Text> + <Text style={styles.value}>Encryption: {roomKey ? 'enabled' : 'none'}</Text> + </View> + </View> + + <View style={styles.card}> + <Text style={styles.label}>Firebase session</Text> + <Pressable + onPress={connectFirebase} + disabled={isConnectingFirebase} + style={({ pressed }) => [styles.button, (pressed || isConnectingFirebase) && styles.buttonPressed]} + > + <Text style={styles.buttonText}> + {isConnectingFirebase ? 'Connecting...' : firebaseSession ? 'Reconnect Firebase' : 'Connect Firebase'} + </Text> + </Pressable> + {isConnectingFirebase && <ActivityIndicator color="#7a4f2a" />} + {!!firebaseSession && <Text style={styles.value}>Signed in uid: {firebaseSession.uid}</Text>} + {!!firebaseError && <Text style={styles.errorText}>Firebase error: {firebaseError}</Text>} + </View> + + <View style={styles.card}> + <Text style={styles.label}>Live sync</Text> + <Pressable + onPress={() => setIsRoomSyncEnabled((prev) => !prev)} + disabled={!firebaseSession || !parsedRoom} + style={({ pressed }) => [ + styles.button, + (!firebaseSession || !parsedRoom || pressed) && styles.buttonPressed + ]} + > + <Text style={styles.buttonText}> + {isRoomSyncEnabled ? 'Stop room sync' : 'Start room sync'} + </Text> + </Pressable> + {!firebaseSession && <Text style={styles.valueMuted}>Connect Firebase first.</Text>} + {!!firebaseSession && !parsedRoom && <Text style={styles.valueMuted}>Invalid room hash.</Text>} + {locationStatus === 'denied' && <Text style={styles.errorText}>Location permission denied.</Text>} + {locationStatus === 'watching' && ( + position + ? <Text style={styles.value}>Location: {position.lat.toFixed(5)}, {position.lon.toFixed(5)}</Text> + : <Text style={styles.valueMuted}>Waiting for GPS fix...</Text> + )} + <Text style={styles.value}>Participants in room: {friends.length}</Text> + {friends.map((friend) => { + const bearingDeg = position && friend.position + ? getBearingDeg(position, friend.position) + : null; + const arrowDeg = bearingDeg !== null + ? getRelativeBearingDeg(bearingDeg, headingDeg) + : null; + const dist = position && friend.position + ? formatDistance(getDistanceMeters(position, friend.position)) + : null; + return ( + <View key={friend.id} style={styles.friendRow}> + <View style={[styles.friendDot, { backgroundColor: friend.color ?? '#888888' }]} /> + <Text style={[styles.value, styles.friendName]}>{friend.name || friend.id}</Text> + {dist !== null && <Text style={styles.friendDist}>{dist}</Text>} + {arrowDeg !== null && ( + <Text style={[styles.friendArrow, { transform: [{ rotate: `${arrowDeg}deg` }] }]}>↑</Text> + )} + </View> + ); + })} + {!!roomSyncError && <Text style={styles.errorText}>Room sync error: {roomSyncError}</Text>} + </View> + </ScrollView> + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 20, + paddingVertical: 28, + gap: 14 + }, + heroCard: { + backgroundColor: '#d6ccb7', + borderRadius: 16, + padding: 14, + overflow: 'hidden', + borderWidth: 1, + borderColor: '#c7b79d' + }, + heroOrb: { + position: 'absolute', + width: 120, + height: 120, + borderRadius: 60, + backgroundColor: '#b38a56', + right: -28, + top: -34, + opacity: 0.25 + }, + headerRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center' + }, + title: { + fontSize: 28, + fontWeight: '700', + color: '#2e2a23' + }, + subtitle: { + fontSize: 15, + color: '#5b554c' + }, + card: { + backgroundColor: '#ece6da', + borderRadius: 14, + padding: 16, + gap: 10, + borderWidth: 1, + borderColor: '#d7ccb9' + }, + resultCard: { + backgroundColor: '#dbe3df', + borderRadius: 14, + padding: 16, + gap: 6, + borderWidth: 1, + borderColor: '#c2d1cb' + }, + label: { + fontSize: 14, + fontWeight: '600', + color: '#2e2a23' + }, + button: { + backgroundColor: '#7a4f2a', + borderRadius: 10, + alignItems: 'center', + paddingVertical: 12 + }, + secondaryButton: { + borderWidth: 1, + borderColor: '#7a4f2a', + borderRadius: 10, + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 12 + }, + secondaryButtonText: { + color: '#7a4f2a', + fontSize: 14, + fontWeight: '700' + }, + buttonPressed: { + opacity: 0.85 + }, + buttonText: { + color: '#f7eee2', + fontSize: 15, + fontWeight: '700' + }, + value: { + fontSize: 14, + color: '#2e2a23' + }, + valueMuted: { + fontSize: 14, + color: '#5b554c' + }, + errorText: { + fontSize: 14, + color: '#8b2f2f' + }, + friendRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8 + }, + friendDot: { + width: 12, + height: 12, + borderRadius: 6 + }, + friendName: { + flex: 1 + }, + friendDist: { + fontSize: 13, + color: '#7a7469' + }, + friendArrow: { + fontSize: 24, + color: '#2e2a23' + } +});