From 8d9c92f219f304cdc3d61e2cb0ec24210beef1c6 Mon Sep 17 00:00:00 2001 From: kivtxs <131401183+kivtxs@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:13:15 -0400 Subject: [PATCH] fix(examples): one identity per install, Wi-Fi Direct without Bluetooth, and peers that stay listed Found driving the demo on two Android phones for the v0.28 smoke test: - A new random profile per launch made every launch a new identity (and a new keystore namespace): contacts and sessions were lost on each restart. The profile is fixed, and userId is the device's off1 address from localAddress(), not the profile, which also fixes the header showing the wrong id, rosters listing this device twice, and outgoing group messages misattributed. Sessions restored on restart are seeded from mlsListSessions() so presence keeps flowing. - Neighbours were aged out after 60 s without a new neighbor_discovered, but the SDK announces a peer once per link and reports neighbor_lost when it ends, so live Bluetooth and Wi-Fi Direct peers vanished from People. - Onboarding required Bluetooth even with Wi-Fi Direct on; it no longer does on Android. Wi-Fi Direct is enabled on Android (autoAccept: true) and NEARBY_WIFI_DEVICES / fine location are requested as optional. - The header pill followed transport_switched, which the core also raises when a layer starts; it follows DORS's selection now. - KEY_PACKAGE_OUTSIDE_VALIDITY_WINDOW shows a "Check date and time" alert. --- examples/demo-app/README.md | 18 +++++ examples/demo-app/src/constants.ts | 13 +++ .../demo-app/src/context/ProtocolContext.tsx | 79 ++++++++++++++----- .../demo-app/src/screens/OnboardingScreen.tsx | 19 +++-- .../demo-app/src/screens/PeopleScreen.tsx | 2 +- examples/demo-app/src/utils.ts | 31 ++++---- 6 files changed, 122 insertions(+), 40 deletions(-) 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;