diff --git a/examples/demo-app/README.md b/examples/demo-app/README.md index d2faba089..994caedc4 100644 --- a/examples/demo-app/README.md +++ b/examples/demo-app/README.md @@ -67,3 +67,21 @@ emulator have no working Bluetooth stack. Grant Bluetooth and (on Android) nearby-devices/location permissions when prompted, or discovery silently returns nothing. + +The address under your name in the header is this device's identity. It is +the same on every launch, so contacts and encrypted sessions survive a restart. + +### Wi-Fi Direct (Android) + +Wi-Fi Direct is on by default on Android and works with Bluetooth off. On +Android 10 and later the SDK forms the group itself (`autoAccept: true` in +`src/constants.ts`): start the app on both phones and they find each other +within about a minute, with no dialog to accept. On older phones, pair them +once in the system settings (**Wi-Fi > Wi-Fi Direct**, or **Wi-Fi > Advanced > +Wi-Fi Direct** on some phones). Allow **Nearby devices** (Android 13+) or +**Location** (Android 12 and lower) when asked, or the transport stays off. + +If a connection request stays pending, check the date and time on both phones. +Phones that have not been online often have the wrong date, and two devices +whose clocks are far enough apart cannot set up encryption. The app shows a +"Check date and time" alert when it sees this. diff --git a/examples/demo-app/src/constants.ts b/examples/demo-app/src/constants.ts index cc77b4fc8..1098de0d7 100644 --- a/examples/demo-app/src/constants.ts +++ b/examples/demo-app/src/constants.ts @@ -11,6 +11,15 @@ export const QUICK_MESSAGES = [ {text: 'Emergency - need help!', emoji: '🚨', priority: 'critical' as const}, ]; +import {Platform} from 'react-native'; + +// One identity per install. The SDK keeps a profile's keys in the platform +// keystore, so the same profile is the same address on every launch. The demo +// used to mint a new profile at each start, which made every launch a new +// person to its peers (contacts and sessions gone) and left another keystore +// namespace behind each time. +export const PROFILE = 'offline-demo'; + export const PRESENCE_BROADCAST_INTERVAL_MS = 15 * 1000; export const TYPING_INDICATOR_TIMEOUT_MS = 10 * 1000; @@ -25,6 +34,10 @@ export const PROTOCOL_CONFIG = { // only sends once the end-to-end encrypted session has been established. transports: { ble: {enabled: true}, + // Android only. `autoAccept` lets the SDK form the Wi-Fi Direct group + // itself (Android 10+); below that, pair the phones in the system's + // Wi-Fi Direct settings (see README). + wifiDirect: {enabled: Platform.OS === 'android', autoAccept: true}, }, encryption: { enabled: true, diff --git a/examples/demo-app/src/context/ProtocolContext.tsx b/examples/demo-app/src/context/ProtocolContext.tsx index e31fc3b2c..c0f8c50e1 100644 --- a/examples/demo-app/src/context/ProtocolContext.tsx +++ b/examples/demo-app/src/context/ProtocolContext.tsx @@ -6,6 +6,7 @@ import React, { useRef, useEffect, } from 'react'; +import {Alert} from 'react-native'; import { OfflineProtocol, MeshServices, @@ -17,6 +18,7 @@ import { TYPING_INDICATOR_TIMEOUT_MS, NEARBY_THRESHOLD_MS, PROTOCOL_CONFIG, + PROFILE, TELEMETRY_API_KEY, TELEMETRY_APP_ID, APP_VERSION, @@ -48,7 +50,7 @@ interface ProtocolContextValue { telemetryEnabled: boolean; // Actions - initialize: (userId: string, userName: string) => Promise; + initialize: (userName: string) => Promise; sendMessage: (recipientId: string, content: string, priority?: 'medium' | 'critical') => Promise; sendConnectionRequest: (peerId: string) => Promise; acceptConnectionRequest: (peerId: string) => Promise; @@ -121,6 +123,7 @@ export function ProtocolProvider({children}: {children: React.ReactNode}) { const neighborsRef = useRef>(neighbors); const userNameRef = useRef(userName); const userIdRef = useRef(userId); + const clockWarningShownRef = useRef(false); const blockedUsersRef = useRef>(new Set()); // Peers with an established MLS session. autoKeyExchange establishes these // under the hood on discovery, independent of the app-level accept — so when a @@ -180,8 +183,14 @@ export function ProtocolProvider({children}: {children: React.ReactNode}) { const eventType = event.type; switch (eventType) { - case 'transport_switched': { - setCurrentTransport(event.to ?? null); + // The pill shows what DORS routes over. `transport_switched` also fires + // when a transport's layer comes up, before it carries anything (the + // Wi-Fi Direct layer reports up at start with no group), so the pill + // follows DORS's own selection instead. + case 'dors_transport_selected': + case 'dors_transport_switched': { + const to = event.transport ?? event.to; + if (to) {setCurrentTransport(to);} break; } case 'neighbor_discovered': { @@ -808,6 +817,22 @@ export function ProtocolProvider({children}: {children: React.ReactNode}) { break; } + case 'security_warning': { + // The one warning a user can act on: this device refused a peer's key + // package by its own clock. Past a 30-day gap neither side can set up + // encryption, and requests and messages wait with no other sign of + // why; under that the peer can still start the session. + const code = event.reason_code || event.reasonCode; + if (code === 'KEY_PACKAGE_OUTSIDE_VALIDITY_WINDOW' && !clockWarningShownRef.current) { + clockWarningShownRef.current = true; + Alert.alert( + 'Check date and time', + "A nearby device's clock does not match this one, which can stop the two from setting up encryption. Turn on automatic date and time, or set it correctly, on both devices.", + ); + } + break; + } + case 'message_deferred': { const msgId = event.message_id || event.messageId; if (!msgId) {break;} @@ -838,13 +863,12 @@ export function ProtocolProvider({children}: {children: React.ReactNode}) { // ─── Initialize Protocol ───────────────────────────────── - const initialize = useCallback(async (uid: string, uname: string) => { - setUserId(uid); + const initialize = useCallback(async (uname: string) => { setUserName(uname); const config = { ...PROTOCOL_CONFIG, - profile: uid, + profile: PROFILE, }; const proto = new OfflineProtocol(config); @@ -856,6 +880,29 @@ export function ProtocolProvider({children}: {children: React.ReactNode}) { // Start protocol await proto.start(); + + // This device's identity is the address derived from its key, and it is + // what every event names as sender, recipient and group member. The + // profile is only the keystore namespace. Using the profile here showed + // the wrong id in the header, listed this device twice in group rosters + // and misattributed its own group messages. + const address = await proto.localAddress(); + if (address) { + userIdRef.current = address; + setUserId(address); + } + + // Sessions outlive a restart now that the identity does, and a restored + // session raises no secure_session_established. Seed the set from the + // SDK, or a contact accepted after a restart is never marked as having a + // session, gets no presence, and reads as offline beside a live link. + try { + for (const peer of await proto.mlsListSessions()) { + sessionPeersRef.current.add(peer); + } + } catch (err) { + console.warn('[ProtocolContext] mlsListSessions failed:', err); + } setIsStarted(true); // Initialize mesh services @@ -917,18 +964,14 @@ export function ProtocolProvider({children}: {children: React.ReactNode}) { const interval = setInterval(() => { const now = Date.now(); - setNeighbors(prev => { - let changed = false; - const next = new Map(prev); - for (const [peerId, neighbor] of next) { - if (now - neighbor.discoveredAt > NEARBY_THRESHOLD_MS * 2) { - next.delete(peerId); - changed = true; - } - } - return changed ? next : prev; - }); - + // Neighbours are not aged out here. The SDK announces a peer when its + // link comes up and reports neighbor_lost when the link ends, and it + // does not re-announce a peer while the link holds: a quiet Bluetooth + // or Wi-Fi Direct neighbour stays connected without new events. Aging + // the list on a timer hid live peers a minute after they appeared, so + // a connection request could not be sent to the phone in your hand. + // A Bluetooth loss is reported once reconnect attempts run out, which + // takes a few minutes. setContacts(prev => { let changed = false; const next = new Map(prev); diff --git a/examples/demo-app/src/screens/OnboardingScreen.tsx b/examples/demo-app/src/screens/OnboardingScreen.tsx index a846bab46..8e57dc845 100644 --- a/examples/demo-app/src/screens/OnboardingScreen.tsx +++ b/examples/demo-app/src/screens/OnboardingScreen.tsx @@ -7,9 +7,11 @@ import { StyleSheet, Alert, ActivityIndicator, + Platform, } from 'react-native'; import {SafeAreaView} from 'react-native-safe-area-context'; -import {generateUserId, generateUserName, requestBluetoothPermissions, ensureBluetoothEnabled, showPermissionDeniedAlert} from '../utils'; +import {generateUserName, requestMeshPermissions, ensureBluetoothEnabled, showPermissionDeniedAlert} from '../utils'; +import {PROTOCOL_CONFIG} from '../constants'; import {useProtocol} from '../context/ProtocolContext'; interface OnboardingScreenProps { @@ -29,16 +31,20 @@ export function OnboardingScreen({onComplete}: OnboardingScreenProps) { setIsLoading(true); try { - // Request Bluetooth permissions - const permResult = await requestBluetoothPermissions(); + // Request Bluetooth (and, on Android, Wi-Fi Direct) permissions + const permResult = await requestMeshPermissions(); if (!permResult.granted) { showPermissionDeniedAlert(permResult); setIsLoading(false); return; } - // Ensure Bluetooth is enabled - const btEnabled = await ensureBluetoothEnabled(); + // Bluetooth is required only when it is the one transport. With Wi-Fi + // Direct on, the app works with Bluetooth off, which is how a Wi-Fi + // Direct link is tested on its own. + const wifiDirectOn = + Platform.OS === 'android' && PROTOCOL_CONFIG.transports.wifiDirect.enabled; + const btEnabled = wifiDirectOn || (await ensureBluetoothEnabled()); if (!btEnabled) { Alert.alert( 'Bluetooth Required', @@ -49,8 +55,7 @@ export function OnboardingScreen({onComplete}: OnboardingScreenProps) { } // Initialize protocol - const userId = generateUserId(); - await initialize(userId, name.trim()); + await initialize(name.trim()); onComplete(); } catch (error) { console.error('Failed to start protocol:', error); diff --git a/examples/demo-app/src/screens/PeopleScreen.tsx b/examples/demo-app/src/screens/PeopleScreen.tsx index dce0cc699..7a9064265 100644 --- a/examples/demo-app/src/screens/PeopleScreen.tsx +++ b/examples/demo-app/src/screens/PeopleScreen.tsx @@ -192,7 +192,7 @@ export function PeopleScreen({onOpenChat}: PeopleScreenProps) { 📡 Searching for peers... - Make sure Bluetooth is enabled on nearby devices running the Offline Demo app. + Make sure nearby devices running the Offline Demo app have Bluetooth on, or share a Wi-Fi Direct group with this one (Android). ); diff --git a/examples/demo-app/src/utils.ts b/examples/demo-app/src/utils.ts index f8f86f94f..b156f0e27 100644 --- a/examples/demo-app/src/utils.ts +++ b/examples/demo-app/src/utils.ts @@ -5,18 +5,6 @@ const {OfflineProtocolModule} = NativeModules; // ─── User ID & Name ────────────────────────────────────────── -let cachedUserId: string | null = null; - -export function generateUserId(): string { - if (cachedUserId) { - return cachedUserId; - } - const timestamp = Date.now().toString(36); - const random = Math.random().toString(36).substring(2, 8); - cachedUserId = `user_${timestamp}_${random}`; - return cachedUserId; -} - export function generateUserName(): string { const adjectives = [ 'Swift', 'Bright', 'Clever', 'Quiet', 'Bold', 'Gentle', 'Wise', 'Kind', @@ -142,7 +130,12 @@ const PERMISSION_LABELS: Record = { [PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION]: 'Location', }; -export async function requestBluetoothPermissions(): Promise { +/** + * Requests what the mesh needs. Bluetooth decides the result. The Wi-Fi Direct + * permissions are asked for on Android but are optional: denying them leaves + * Bluetooth working and the Wi-Fi Direct transport off. + */ +export async function requestMeshPermissions(): Promise { if (Platform.OS === 'ios') { return {granted: true, deniedPermissions: [], hasNeverAskAgain: false}; } @@ -167,7 +160,17 @@ export async function requestBluetoothPermissions(): Promise { ); } - const results = await PermissionsAndroid.requestMultiple(permissions); + // Wi-Fi Direct: NEARBY_WIFI_DEVICES on Android 13+ (the SDK declares it + // neverForLocation), fine location below that, where peer discovery + // needs it. Not required, so not added to `permissions`. + const optional: Permission[] = + androidVersion >= 33 + ? [PermissionsAndroid.PERMISSIONS.NEARBY_WIFI_DEVICES] + : androidVersion >= 31 + ? [PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION] + : []; + + const results = await PermissionsAndroid.requestMultiple([...permissions, ...optional]); const deniedPermissions: string[] = []; let allGranted = true; let hasNeverAskAgain = false;