Skip to content

Commit 3c2bf37

Browse files
authored
Merge pull request #722 from aj47/feature/696-mobile-tunnel-persistence
feat(mobile): add tunnel persistence and reconnection reliability
2 parents a95cb64 + a6239ad commit 3c2bf37

File tree

11 files changed

+1283
-86
lines changed

11 files changed

+1283
-86
lines changed

apps/mobile/App.tsx

Lines changed: 45 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ import { ConfigContext, useConfig, saveConfig } from './src/store/config';
88
import { SessionContext, useSessions } from './src/store/sessions';
99
import { MessageQueueContext, useMessageQueue } from './src/store/message-queue';
1010
import { ConnectionManagerContext, useConnectionManagerProvider } from './src/store/connectionManager';
11+
import { TunnelConnectionContext, useTunnelConnectionProvider } from './src/store/tunnelConnection';
1112
import { View, Image, Text, StyleSheet } from 'react-native';
1213
import { SafeAreaProvider } from 'react-native-safe-area-context';
1314
import { ThemeProvider, useTheme } from './src/ui/ThemeProvider';
15+
import { ConnectionStatusIndicator } from './src/ui/ConnectionStatusIndicator';
1416
import * as Linking from 'expo-linking';
1517
import { useEffect, useMemo } from 'react';
1618

19+
1720
const speakMCPIcon = require('./assets/speakmcp-icon.png');
1821
const darkSpinner = require('./assets/loading-spinner.gif');
1922
const lightSpinner = require('./assets/light-spinner.gif');
@@ -47,6 +50,9 @@ function Navigation() {
4750
const sessionStore = useSessions();
4851
const messageQueueStore = useMessageQueue();
4952

53+
// Initialize tunnel connection manager for persistence and auto-reconnection
54+
const tunnelConnection = useTunnelConnectionProvider();
55+
5056
// Create connection manager config from app config
5157
const clientConfig = useMemo(() => ({
5258
baseUrl: cfg.config.baseUrl,
@@ -125,36 +131,45 @@ function Navigation() {
125131
<SessionContext.Provider value={sessionStore}>
126132
<MessageQueueContext.Provider value={messageQueueStore}>
127133
<ConnectionManagerContext.Provider value={connectionManager}>
128-
<NavigationContainer theme={navTheme}>
129-
<Stack.Navigator
130-
initialRouteName="Settings"
131-
screenOptions={{
132-
headerTitleStyle: { ...theme.typography.h2 },
133-
headerStyle: { backgroundColor: theme.colors.card },
134-
headerTintColor: theme.colors.foreground,
135-
contentStyle: { backgroundColor: theme.colors.background },
136-
headerLeft: () => (
137-
<Image
138-
source={speakMCPIcon}
139-
style={{ width: 28, height: 28, marginLeft: 12, marginRight: 8 }}
140-
resizeMode="contain"
141-
/>
142-
),
143-
}}
144-
>
145-
<Stack.Screen
146-
name="Settings"
147-
component={SettingsScreen}
148-
options={{ title: 'SpeakMCP' }}
149-
/>
150-
<Stack.Screen
151-
name="Sessions"
152-
component={SessionListScreen}
153-
options={{ title: 'Chats' }}
154-
/>
155-
<Stack.Screen name="Chat" component={ChatScreen} />
156-
</Stack.Navigator>
157-
</NavigationContainer>
134+
<TunnelConnectionContext.Provider value={tunnelConnection}>
135+
<NavigationContainer theme={navTheme}>
136+
<Stack.Navigator
137+
initialRouteName="Settings"
138+
screenOptions={{
139+
headerTitleStyle: { ...theme.typography.h2 },
140+
headerStyle: { backgroundColor: theme.colors.card },
141+
headerTintColor: theme.colors.foreground,
142+
contentStyle: { backgroundColor: theme.colors.background },
143+
headerLeft: () => (
144+
<Image
145+
source={speakMCPIcon}
146+
style={{ width: 28, height: 28, marginLeft: 12, marginRight: 8 }}
147+
resizeMode="contain"
148+
/>
149+
),
150+
headerRight: () => (
151+
<ConnectionStatusIndicator
152+
state={tunnelConnection.connectionInfo.state}
153+
retryCount={tunnelConnection.connectionInfo.retryCount}
154+
compact
155+
/>
156+
),
157+
}}
158+
>
159+
<Stack.Screen
160+
name="Settings"
161+
component={SettingsScreen}
162+
options={{ title: 'SpeakMCP' }}
163+
/>
164+
<Stack.Screen
165+
name="Sessions"
166+
component={SessionListScreen}
167+
options={{ title: 'Chats' }}
168+
/>
169+
<Stack.Screen name="Chat" component={ChatScreen} />
170+
</Stack.Navigator>
171+
</NavigationContainer>
172+
</TunnelConnectionContext.Provider>
158173
</ConnectionManagerContext.Provider>
159174
</MessageQueueContext.Provider>
160175
</SessionContext.Provider>

apps/mobile/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@speakmcp/shared": "workspace:^",
1717
"expo": "~54.0.6",
1818
"expo-camera": "^17.0.9",
19+
"expo-crypto": "~15.0.8",
1920
"expo-linking": "^8.0.9",
2021
"expo-speech": "~14.0.7",
2122
"expo-speech-recognition": "^2.1.2",
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import AsyncStorage from '@react-native-async-storage/async-storage';
2+
import * as Crypto from 'expo-crypto';
3+
4+
const DEVICE_ID_KEY = 'speakmcp_device_id_v1';
5+
6+
/**
7+
* Device identity for stable tunnel identification.
8+
* This ID persists across app restarts (but not reinstalls, as AsyncStorage is cleared on uninstall).
9+
*/
10+
export interface DeviceIdentity {
11+
deviceId: string;
12+
createdAt: number;
13+
}
14+
15+
/**
16+
* Generate a cryptographically random device ID.
17+
* Uses UUID v4 format for compatibility.
18+
*/
19+
async function generateDeviceId(): Promise<string> {
20+
const randomBytes = await Crypto.getRandomBytesAsync(16);
21+
const hex = Array.from(randomBytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
22+
// Format as UUID v4
23+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${(parseInt(hex.slice(16, 18), 16) & 0x3f | 0x80).toString(16)}${hex.slice(18, 20)}-${hex.slice(20, 32)}`;
24+
}
25+
26+
/**
27+
* Get or create a persistent device identity.
28+
* The device ID is generated once and stored in AsyncStorage.
29+
*/
30+
export async function getDeviceIdentity(): Promise<DeviceIdentity> {
31+
try {
32+
const stored = await AsyncStorage.getItem(DEVICE_ID_KEY);
33+
if (stored) {
34+
const parsed = JSON.parse(stored);
35+
if (parsed.deviceId && typeof parsed.deviceId === 'string') {
36+
return parsed as DeviceIdentity;
37+
}
38+
}
39+
} catch (error) {
40+
console.warn('[DeviceIdentity] Error reading stored identity:', error);
41+
}
42+
43+
// Generate new device identity
44+
const identity: DeviceIdentity = {
45+
deviceId: await generateDeviceId(),
46+
createdAt: Date.now(),
47+
};
48+
49+
try {
50+
await AsyncStorage.setItem(DEVICE_ID_KEY, JSON.stringify(identity));
51+
console.log('[DeviceIdentity] Created new device identity:', identity.deviceId);
52+
} catch (error) {
53+
console.error('[DeviceIdentity] Failed to persist device identity:', error);
54+
}
55+
56+
return identity;
57+
}
58+
59+
/**
60+
* Clear the device identity (for testing or reset scenarios).
61+
*/
62+
export async function clearDeviceIdentity(): Promise<void> {
63+
try {
64+
await AsyncStorage.removeItem(DEVICE_ID_KEY);
65+
console.log('[DeviceIdentity] Cleared device identity');
66+
} catch (error) {
67+
console.error('[DeviceIdentity] Failed to clear device identity:', error);
68+
}
69+
}
70+

0 commit comments

Comments
 (0)