Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions examples/demo-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 13 additions & 0 deletions examples/demo-app/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
79 changes: 61 additions & 18 deletions examples/demo-app/src/context/ProtocolContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import React, {
useRef,
useEffect,
} from 'react';
import {Alert} from 'react-native';
import {
OfflineProtocol,
MeshServices,
Expand All @@ -17,6 +18,7 @@ import {
TYPING_INDICATOR_TIMEOUT_MS,
NEARBY_THRESHOLD_MS,
PROTOCOL_CONFIG,
PROFILE,
TELEMETRY_API_KEY,
TELEMETRY_APP_ID,
APP_VERSION,
Expand Down Expand Up @@ -48,7 +50,7 @@ interface ProtocolContextValue {
telemetryEnabled: boolean;

// Actions
initialize: (userId: string, userName: string) => Promise<void>;
initialize: (userName: string) => Promise<void>;
sendMessage: (recipientId: string, content: string, priority?: 'medium' | 'critical') => Promise<void>;
sendConnectionRequest: (peerId: string) => Promise<void>;
acceptConnectionRequest: (peerId: string) => Promise<void>;
Expand Down Expand Up @@ -121,6 +123,7 @@ export function ProtocolProvider({children}: {children: React.ReactNode}) {
const neighborsRef = useRef<Map<string, Neighbor>>(neighbors);
const userNameRef = useRef(userName);
const userIdRef = useRef(userId);
const clockWarningShownRef = useRef(false);
const blockedUsersRef = useRef<Set<string>>(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
Expand Down Expand Up @@ -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': {
Expand Down Expand Up @@ -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;}
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 12 additions & 7 deletions examples/demo-app/src/screens/OnboardingScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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',
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion examples/demo-app/src/screens/PeopleScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export function PeopleScreen({onOpenChat}: PeopleScreenProps) {
<Text style={styles.emptyEmoji}>📡</Text>
<Text style={styles.emptyTitle}>Searching for peers...</Text>
<Text style={styles.emptySubtitle}>
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).
</Text>
</View>
);
Expand Down
31 changes: 17 additions & 14 deletions examples/demo-app/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -142,7 +130,12 @@ const PERMISSION_LABELS: Record<string, string> = {
[PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION]: 'Location',
};

export async function requestBluetoothPermissions(): Promise<PermissionResult> {
/**
* 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<PermissionResult> {
if (Platform.OS === 'ios') {
return {granted: true, deniedPermissions: [], hasNeverAskAgain: false};
}
Expand All @@ -167,7 +160,17 @@ export async function requestBluetoothPermissions(): Promise<PermissionResult> {
);
}

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;
Expand Down
Loading