diff --git a/app/app.json b/app/app.json index 66fbb8c2..2315b46b 100644 --- a/app/app.json +++ b/app/app.json @@ -1,12 +1,12 @@ { "expo": { - "name": "friend-lite-app", - "slug": "friend-lite-app", + "name": "chronicle", + "slug": "chronicle", "version": "1.0.0", + "scheme": "chronicle", "orientation": "portrait", "icon": "./assets/icon.png", - "entryPoint": "./app/index.tsx", - "userInterfaceStyle": "light", + "userInterfaceStyle": "automatic", "splash": { "image": "./assets/splash.png", "resizeMode": "contain", @@ -17,9 +17,13 @@ ], "ios": { "supportsTablet": true, - "bundleIdentifier": "com.cupbearer5517.friendlite", + "bundleIdentifier": "com.cupbearer5517.chronicle", "infoPlist": { - "NSMicrophoneUsageDescription": "Friend Lite needs access to your microphone to stream audio to the backend for processing." + "NSMicrophoneUsageDescription": "Chronicle needs access to your microphone to stream audio to the backend for processing.", + "NSAppTransportSecurity": { + "NSAllowsArbitraryLoads": true, + "NSAllowsLocalNetworking": true + } } }, "android": { @@ -27,7 +31,7 @@ "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#ffffff" }, - "package": "com.cupbearer5517.friendlite", + "package": "com.cupbearer5517.chronicle", "permissions": [ "android.permission.BLUETOOTH", "android.permission.BLUETOOTH_ADMIN", @@ -91,7 +95,8 @@ ] } } - ] + ], + "./plugins/with-ats" ], "extra": { "eas": { diff --git a/app/app/_layout.tsx b/app/app/_layout.tsx index d2a8b0bc..b8110d09 100644 --- a/app/app/_layout.tsx +++ b/app/app/_layout.tsx @@ -1,5 +1,23 @@ import { Stack } from "expo-router"; +import { useTheme } from "@/theme"; +import { ConnectionLogProvider } from "@/contexts/ConnectionLogContext"; export default function RootLayout() { - return ; + const { colors, isDark } = useTheme(); + + return ( + + + + + + + ); } diff --git a/app/app/components/BackendStatus.tsx b/app/app/components/BackendStatus.tsx deleted file mode 100644 index 4f55d37f..00000000 --- a/app/app/components/BackendStatus.tsx +++ /dev/null @@ -1,319 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native'; - -interface BackendStatusProps { - backendUrl: string; - onBackendUrlChange: (url: string) => void; - jwtToken: string | null; -} - -interface HealthStatus { - status: 'unknown' | 'checking' | 'healthy' | 'unhealthy' | 'auth_required'; - message: string; - lastChecked?: Date; -} - -export const BackendStatus: React.FC = ({ - backendUrl, - onBackendUrlChange, - jwtToken, -}) => { - const [healthStatus, setHealthStatus] = useState({ - status: 'unknown', - message: 'Not checked', - }); - - const checkBackendHealth = async (showAlert: boolean = false) => { - if (!backendUrl.trim()) { - setHealthStatus({ - status: 'unhealthy', - message: 'Backend URL not set', - }); - return; - } - - setHealthStatus({ - status: 'checking', - message: 'Checking connection...', - }); - - try { - // Convert WebSocket URL to HTTP URL for health check - let baseUrl = backendUrl.trim(); - - // Handle different URL formats - if (baseUrl.startsWith('ws://')) { - baseUrl = baseUrl.replace('ws://', 'http://'); - } else if (baseUrl.startsWith('wss://')) { - baseUrl = baseUrl.replace('wss://', 'https://'); - } - - // Remove any WebSocket path if present - baseUrl = baseUrl.split('/ws')[0]; - - // Try health endpoint first - const healthUrl = `${baseUrl}/health`; - console.log('[BackendStatus] Checking health at:', healthUrl); - - const response = await fetch(healthUrl, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - ...(jwtToken ? { 'Authorization': `Bearer ${jwtToken}` } : {}), - }, - }); - - console.log('[BackendStatus] Health check response status:', response.status); - - if (response.ok) { - const healthData = await response.json(); - setHealthStatus({ - status: 'healthy', - message: `Connected (${healthData.status || 'OK'})`, - lastChecked: new Date(), - }); - - if (showAlert) { - Alert.alert('Connection Success', 'Successfully connected to backend!'); - } - } else if (response.status === 401 || response.status === 403) { - setHealthStatus({ - status: 'auth_required', - message: 'Authentication required', - lastChecked: new Date(), - }); - - if (showAlert) { - Alert.alert('Authentication Required', 'Please login to access the backend.'); - } - } else { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - } catch (error) { - console.error('[BackendStatus] Health check error:', error); - - let errorMessage = 'Connection failed'; - if (error instanceof Error) { - if (error.message.includes('Network request failed')) { - errorMessage = 'Network request failed - check URL and network connection'; - } else if (error.name === 'AbortError') { - errorMessage = 'Request timeout'; - } else { - errorMessage = error.message; - } - } - - setHealthStatus({ - status: 'unhealthy', - message: errorMessage, - lastChecked: new Date(), - }); - - if (showAlert) { - Alert.alert( - 'Connection Failed', - `Could not connect to backend: ${errorMessage}\n\nMake sure the backend is running and accessible.` - ); - } - } - }; - - // Auto-check health when backend URL or JWT token changes - useEffect(() => { - if (backendUrl.trim()) { - const timer = setTimeout(() => { - checkBackendHealth(false); - }, 500); // Debounce - - return () => clearTimeout(timer); - } - }, [backendUrl, jwtToken]); - - const getStatusColor = (status: HealthStatus['status']): string => { - switch (status) { - case 'healthy': - return '#4CD964'; - case 'checking': - return '#FF9500'; - case 'unhealthy': - return '#FF3B30'; - case 'auth_required': - return '#FF9500'; - default: - return '#8E8E93'; - } - }; - - const getStatusIcon = (status: HealthStatus['status']): string => { - switch (status) { - case 'healthy': - return 'βœ…'; - case 'checking': - return 'πŸ”„'; - case 'unhealthy': - return '❌'; - case 'auth_required': - return 'πŸ”'; - default: - return '❓'; - } - }; - - return ( - - Backend Connection - - Backend URL: - - - - - Status: - - {getStatusIcon(healthStatus.status)} - - {healthStatus.message} - - {healthStatus.status === 'checking' && ( - - )} - - - - {healthStatus.lastChecked && ( - - Last checked: {healthStatus.lastChecked.toLocaleTimeString()} - - )} - - - checkBackendHealth(true)} - disabled={healthStatus.status === 'checking'} - > - - {healthStatus.status === 'checking' ? 'Checking...' : 'Test Connection'} - - - - - Enter the WebSocket URL of your backend server. Simple backend: http://localhost:8000/ (no auth). - Advanced backend: http://localhost:8080/ (requires login). Status is automatically checked. - The websocket URL can be different or the same as the HTTP URL, with /ws endpoint and codec parameter (e.g., /ws?codec=pcm) - - - ); -}; - -const styles = StyleSheet.create({ - section: { - marginBottom: 25, - padding: 15, - backgroundColor: 'white', - borderRadius: 10, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.1, - shadowRadius: 3, - elevation: 2, - }, - sectionTitle: { - fontSize: 18, - fontWeight: '600', - marginBottom: 15, - color: '#333', - }, - inputLabel: { - fontSize: 14, - color: '#333', - marginBottom: 5, - fontWeight: '500', - }, - textInput: { - backgroundColor: '#f0f0f0', - borderWidth: 1, - borderColor: '#ddd', - borderRadius: 6, - padding: 10, - fontSize: 14, - width: '100%', - marginBottom: 15, - color: '#333', - }, - statusContainer: { - marginBottom: 15, - padding: 10, - backgroundColor: '#f8f9fa', - borderRadius: 6, - borderWidth: 1, - borderColor: '#e9ecef', - }, - statusRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - statusLabel: { - fontSize: 14, - fontWeight: '500', - color: '#333', - }, - statusValue: { - flexDirection: 'row', - alignItems: 'center', - flex: 1, - justifyContent: 'flex-end', - }, - statusIcon: { - fontSize: 16, - marginRight: 6, - }, - statusText: { - fontSize: 14, - fontWeight: '500', - }, - lastCheckedText: { - fontSize: 12, - color: '#666', - marginTop: 5, - textAlign: 'center', - fontStyle: 'italic', - }, - button: { - backgroundColor: '#007AFF', - paddingVertical: 12, - paddingHorizontal: 20, - borderRadius: 8, - alignItems: 'center', - marginBottom: 10, - elevation: 2, - }, - buttonDisabled: { - backgroundColor: '#A0A0A0', - opacity: 0.7, - }, - buttonText: { - color: 'white', - fontSize: 16, - fontWeight: '600', - }, - helpText: { - fontSize: 12, - color: '#666', - textAlign: 'center', - fontStyle: 'italic', - }, -}); - -export default BackendStatus; \ No newline at end of file diff --git a/app/app/components/DeviceListItem.tsx b/app/app/components/DeviceListItem.tsx deleted file mode 100644 index a8083035..00000000 --- a/app/app/components/DeviceListItem.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React from 'react'; -import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; -import { OmiDevice } from 'friend-lite-react-native'; - -interface DeviceListItemProps { - device: OmiDevice; - onConnect: (deviceId: string) => void; - onDisconnect: () => void; - isConnecting: boolean; - connectedDeviceId: string | null; -} - -export const DeviceListItem: React.FC = ({ - device, - onConnect, - onDisconnect, - isConnecting, - connectedDeviceId -}) => { - const isThisDeviceConnected = connectedDeviceId === device.id; - const isAnotherDeviceConnected = connectedDeviceId !== null && connectedDeviceId !== device.id; - - return ( - - - {device.name || 'Unknown Device'} - ID: {device.id} - {device.rssi != null && RSSI: {device.rssi} dBm} - - { - isThisDeviceConnected ? ( - - {isConnecting ? 'Disconnecting...' : 'Disconnect'} - - ) : ( - onConnect(device.id)} - disabled={isConnecting || isAnotherDeviceConnected} // Disable if connecting to this/another device or another device is connected - > - {isConnecting && connectedDeviceId === device.id ? 'Connecting...' : 'Connect'} - - ) - } - - ); -}; - -const styles = StyleSheet.create({ - deviceItem: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: 12, - paddingHorizontal: 5, // Added some horizontal padding - borderBottomWidth: 1, - borderBottomColor: '#eee', - }, - deviceInfoContainer: { - flex: 1, // Allow text to take available space and wrap if needed - marginRight: 10, // Space between text and button - }, - deviceName: { - fontSize: 16, - fontWeight: '500', - color: '#333', - }, - deviceInfo: { - fontSize: 12, - color: '#666', - marginTop: 2, - }, - button: { - backgroundColor: '#007AFF', - paddingVertical: 12, - paddingHorizontal: 20, - borderRadius: 8, - alignItems: 'center', - elevation: 1, - }, - smallButton: { - paddingVertical: 8, - paddingHorizontal: 12, - }, - buttonDanger: { - backgroundColor: '#FF3B30', - }, - buttonDisabled: { - backgroundColor: '#A0A0A0', - opacity: 0.7, - }, - buttonText: { - color: 'white', - fontSize: 14, // Slightly smaller for small buttons - fontWeight: '600', - }, -}); - -export default DeviceListItem; \ No newline at end of file diff --git a/app/app/components/ObsidianIngest.tsx b/app/app/components/ObsidianIngest.tsx deleted file mode 100644 index d14ca367..00000000 --- a/app/app/components/ObsidianIngest.tsx +++ /dev/null @@ -1,154 +0,0 @@ - -import React, { useState } from 'react'; -import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native'; - -interface ObsidianIngestProps { - backendUrl: string; - jwtToken: string | null; -} - -export const ObsidianIngest: React.FC = ({ - backendUrl, - jwtToken, -}) => { - const [vaultPath, setVaultPath] = useState('/app/data/obsidian_vault'); - const [loading, setLoading] = useState(false); - - const handleIngest = async () => { - if (!backendUrl) { - Alert.alert("Error", "Backend URL not set"); - return; - } - - if (!jwtToken) { - Alert.alert("Authentication Required", "Please login to ingest Obsidian vault."); - return; - } - - setLoading(true); - try { - let baseUrl = backendUrl.trim(); - // Handle different URL formats - if (baseUrl.startsWith('ws://')) { - baseUrl = baseUrl.replace('ws://', 'http://'); - } else if (baseUrl.startsWith('wss://')) { - baseUrl = baseUrl.replace('wss://', 'https://'); - } - baseUrl = baseUrl.split('/ws')[0]; - - const response = await fetch(`${baseUrl}/api/obsidian/ingest`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${jwtToken}` - }, - body: JSON.stringify({ vault_path: vaultPath }) - }); - - if (response.ok) { - Alert.alert("Success", "Ingestion started in background."); - } else { - const errorText = await response.text(); - Alert.alert("Error", `Ingestion failed: ${response.status} - ${errorText}`); - } - } catch (e) { - Alert.alert("Error", `Network request failed: ${e}`); - } finally { - setLoading(false); - } - }; - - return ( - - Obsidian Ingestion - - Vault Path (Backend Container): - - - - - {loading ? 'Starting Ingestion...' : 'Ingest to Neo4j'} - - - - - Enter the absolute path to the Obsidian vault INSIDE the backend container. - Ensure the folder is mounted to the container. - - - ); -}; - -const styles = StyleSheet.create({ - section: { - marginBottom: 25, - padding: 15, - backgroundColor: 'white', - borderRadius: 10, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.1, - shadowRadius: 3, - elevation: 2, - }, - sectionTitle: { - fontSize: 18, - fontWeight: '600', - marginBottom: 15, - color: '#333', - }, - inputLabel: { - fontSize: 14, - color: '#333', - marginBottom: 5, - fontWeight: '500', - }, - textInput: { - backgroundColor: '#f0f0f0', - borderWidth: 1, - borderColor: '#ddd', - borderRadius: 6, - padding: 10, - fontSize: 14, - width: '100%', - marginBottom: 15, - color: '#333', - }, - button: { - backgroundColor: '#9b59b6', // Purple for Obsidian - paddingVertical: 12, - paddingHorizontal: 20, - borderRadius: 8, - alignItems: 'center', - marginBottom: 10, - elevation: 2, - }, - buttonDisabled: { - backgroundColor: '#A0A0A0', - opacity: 0.7, - }, - buttonText: { - color: 'white', - fontSize: 16, - fontWeight: '600', - }, - helpText: { - fontSize: 12, - color: '#666', - textAlign: 'center', - fontStyle: 'italic', - }, -}); - -export default ObsidianIngest; diff --git a/app/app/components/PhoneAudioButton.tsx b/app/app/components/PhoneAudioButton.tsx deleted file mode 100644 index 1f486e55..00000000 --- a/app/app/components/PhoneAudioButton.tsx +++ /dev/null @@ -1,201 +0,0 @@ -// PhoneAudioButton.tsx -import React from 'react'; -import { - TouchableOpacity, - Text, - View, - StyleSheet, - ActivityIndicator, -} from 'react-native'; - -interface PhoneAudioButtonProps { - isRecording: boolean; - isInitializing: boolean; - isDisabled: boolean; - audioLevel: number; - error: string | null; - onPress: () => void; -} - -const PhoneAudioButton: React.FC = ({ - isRecording, - isInitializing, - isDisabled, - audioLevel, - error, - onPress, -}) => { - - const getButtonStyle = () => { - if (isDisabled && !isRecording) { - return [styles.button, styles.buttonDisabled]; - } - if (isRecording) { - return [styles.button, styles.buttonRecording]; - } - if (error) { - return [styles.button, styles.buttonError]; - } - return [styles.button, styles.buttonIdle]; - }; - - const getButtonText = () => { - if (isInitializing) { - return 'Initializing...'; - } - if (isRecording) { - return 'Stop Phone Audio'; - } - return 'Stream Phone Audio'; - }; - - const getMicrophoneIcon = () => { - if (isRecording) { - return '🎀'; // Recording microphone - } - return 'πŸŽ™οΈ'; // Idle microphone - }; - - return ( - - - - {isInitializing ? ( - - ) : ( - - {getMicrophoneIcon()} - {getButtonText()} - - )} - - - - {/* Audio Level Indicator */} - {isRecording && ( - - - - - Audio Level - - )} - - {/* Status Message */} - {isRecording && ( - - Streaming audio to backend... - - )} - - {/* Error Message */} - {error && !isRecording && ( - {error} - )} - - {/* Disabled Message */} - {isDisabled && !isRecording && ( - - Disconnect Bluetooth device to use phone audio - - )} - - ); -}; - -const styles = StyleSheet.create({ - container: { - marginVertical: 10, - paddingHorizontal: 20, - }, - buttonWrapper: { - alignSelf: 'stretch', - }, - button: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - paddingHorizontal: 20, - borderRadius: 8, - minHeight: 48, - }, - buttonContent: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - buttonIdle: { - backgroundColor: '#007AFF', - }, - buttonRecording: { - backgroundColor: '#FF3B30', - }, - buttonDisabled: { - backgroundColor: '#C7C7CC', - }, - buttonError: { - backgroundColor: '#FF9500', - }, - buttonText: { - color: '#FFFFFF', - fontSize: 16, - fontWeight: '600', - marginLeft: 8, - }, - icon: { - fontSize: 20, - }, - statusText: { - textAlign: 'center', - marginTop: 8, - fontSize: 12, - color: '#8E8E93', - }, - errorText: { - textAlign: 'center', - marginTop: 8, - fontSize: 12, - color: '#FF3B30', - }, - disabledText: { - textAlign: 'center', - marginTop: 8, - fontSize: 12, - color: '#8E8E93', - fontStyle: 'italic', - }, - audioLevelContainer: { - marginTop: 12, - alignItems: 'center', - }, - audioLevelBackground: { - width: '100%', - height: 4, - backgroundColor: '#E5E5EA', - borderRadius: 2, - overflow: 'hidden', - }, - audioLevelBar: { - height: '100%', - backgroundColor: '#34C759', - borderRadius: 2, - }, - audioLevelText: { - marginTop: 4, - fontSize: 10, - color: '#8E8E93', - }, -}); - -export default PhoneAudioButton; \ No newline at end of file diff --git a/app/app/diagnostics.tsx b/app/app/diagnostics.tsx new file mode 100644 index 00000000..18ade000 --- /dev/null +++ b/app/app/diagnostics.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { View, Text, FlatList, TouchableOpacity, StyleSheet, SafeAreaView } from 'react-native'; +import { useTheme, ThemeColors } from '@/theme'; +import { useConnectionLog, ConnectionEvent, ConnectionEventType } from '@/contexts/ConnectionLogContext'; + +const EVENT_BADGE_COLORS: Record = { + scan_start: '#007AFF', + scan_stop: '#8E8E93', + scan_result: '#5856D6', + connect_start: '#FF9500', + connect_success: '#34C759', + connect_fail: '#FF3B30', + disconnect: '#FF3B30', + battery_read: '#34C759', + audio_start: '#007AFF', + audio_stop: '#8E8E93', + error: '#FF3B30', + health_ping: '#34C759', + reconnect_attempt: '#FF9500', + bt_state_change: '#5856D6', +}; + +function formatTime(date: Date): string { + return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +function EventItem({ event, colors }: { event: ConnectionEvent; colors: ThemeColors }) { + const badgeColor = EVENT_BADGE_COLORS[event.type] || colors.textTertiary; + + return ( + + {formatTime(event.timestamp)} + + {event.type.replace(/_/g, ' ')} + + + {event.deviceName && {event.deviceName}} + {event.details && {event.details}} + {event.rssi != null && RSSI: {event.rssi} dBm} + + + ); +} + +const itemStyles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingVertical: 8, + paddingHorizontal: 12, + borderBottomWidth: 1, + }, + time: { + fontSize: 11, + fontFamily: 'monospace', + width: 65, + marginTop: 3, + }, + badge: { + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + marginRight: 8, + marginTop: 2, + }, + badgeText: { + color: 'white', + fontSize: 10, + fontWeight: '600', + textTransform: 'uppercase', + }, + details: { + flex: 1, + }, + device: { + fontSize: 13, + fontWeight: '500', + }, + detail: { + fontSize: 12, + marginTop: 1, + }, +}); + +export default function DiagnosticsScreen() { + const { colors } = useTheme(); + const { events, clearEvents } = useConnectionLog(); + + return ( + + + Connection Log ({events.length}) + + Clear + + + + {events.length === 0 ? ( + + No events recorded yet. Scan or connect a device to see events here. + + ) : ( + } + keyExtractor={(item) => item.id} + style={{ backgroundColor: colors.card }} + /> + )} + + ); +} + +const screenStyles = StyleSheet.create({ + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + }, + title: { + fontSize: 17, + fontWeight: '600', + }, + clearButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 6, + }, + clearText: { + fontSize: 14, + fontWeight: '500', + }, + empty: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 40, + }, + emptyText: { + fontSize: 15, + textAlign: 'center', + }, +}); diff --git a/app/app/index.tsx b/app/app/index.tsx index 649a2e2b..636e475e 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -1,600 +1,210 @@ import React, { useRef, useCallback, useEffect, useState } from 'react'; -import { StyleSheet, Text, View, SafeAreaView, ScrollView, Platform, FlatList, ActivityIndicator, Alert, Switch, Button, TouchableOpacity, KeyboardAvoidingView } from 'react-native'; -import { OmiConnection } from 'friend-lite-react-native'; // OmiDevice also comes from here -import { State as BluetoothState } from 'react-native-ble-plx'; // Import State from ble-plx +import { Text, View, SafeAreaView, ScrollView, Platform, FlatList, ActivityIndicator, Alert, Switch, TouchableOpacity, KeyboardAvoidingView, StyleSheet } from 'react-native'; +import { OmiConnection } from 'friend-lite-react-native'; +import { State as BluetoothState } from 'react-native-ble-plx'; +import { Link } from 'expo-router'; +import { useTheme, ThemeColors } from '@/theme'; // Hooks -import { useBluetoothManager } from './hooks/useBluetoothManager'; -import { useDeviceScanning } from './hooks/useDeviceScanning'; -import { useDeviceConnection } from './hooks/useDeviceConnection'; -import { - saveLastConnectedDeviceId, - getLastConnectedDeviceId, - saveWebSocketUrl, - getWebSocketUrl, - saveUserId, - getUserId, - getAuthEmail, - getJwtToken, -} from './utils/storage'; -import { useAudioListener } from './hooks/useAudioListener'; -import { useAudioStreamer } from './hooks/useAudioStreamer'; -import { usePhoneAudioRecorder } from './hooks/usePhoneAudioRecorder'; +import { useBluetoothManager } from '@/hooks/useBluetoothManager'; +import { useDeviceScanning } from '@/hooks/useDeviceScanning'; +import { useDeviceConnection } from '@/hooks/useDeviceConnection'; +import { useAppSettings } from '@/hooks/useAppSettings'; +import { useAutoReconnect } from '@/hooks/useAutoReconnect'; +import { useAudioStreamingOrchestrator } from '@/hooks/useAudioStreamingOrchestrator'; +import { useAudioListener } from '@/hooks/useAudioListener'; +import { useAudioStreamer } from '@/hooks/useAudioStreamer'; +import { usePhoneAudioRecorder } from '@/hooks/usePhoneAudioRecorder'; +import { useBatteryMonitor } from '@/hooks/useBatteryMonitor'; +import { saveLastConnectedDeviceId } from '@/utils/storage'; // Components -import BluetoothStatusBanner from './components/BluetoothStatusBanner'; -import ScanControls from './components/ScanControls'; -import DeviceListItem from './components/DeviceListItem'; -import DeviceDetails from './components/DeviceDetails'; -import AuthSection from './components/AuthSection'; -import BackendStatus from './components/BackendStatus'; -import ObsidianIngest from './components/ObsidianIngest'; -import PhoneAudioButton from './components/PhoneAudioButton'; +import BluetoothStatusBanner from '@/components/BluetoothStatusBanner'; +import ScanControls from '@/components/ScanControls'; +import DeviceListItem from '@/components/DeviceListItem'; +import DeviceDetails from '@/components/DeviceDetails'; +import AuthSection from '@/components/AuthSection'; +import BackendStatus from '@/components/BackendStatus'; +import ObsidianIngest from '@/components/ObsidianIngest'; +import PhoneAudioButton from '@/components/PhoneAudioButton'; export default function App() { - // Initialize OmiConnection + const { colors } = useTheme(); + const s = createStyles(colors); const omiConnection = useRef(new OmiConnection()).current; - - // Filter state const [showOnlyOmi, setShowOnlyOmi] = useState(false); - // State for remembering the last connected device - const [lastKnownDeviceId, setLastKnownDeviceId] = useState(null); - const [isAttemptingAutoReconnect, setIsAttemptingAutoReconnect] = useState(false); - const [triedAutoReconnectForCurrentId, setTriedAutoReconnectForCurrentId] = useState(false); - - // State for WebSocket URL for custom audio streaming - const [webSocketUrl, setWebSocketUrl] = useState(''); - - // State for User ID - const [userId, setUserId] = useState(''); - - // Authentication state - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [currentUserEmail, setCurrentUserEmail] = useState(null); - const [jwtToken, setJwtToken] = useState(null); - - // Bluetooth Management Hook - const { - bleManager, - bluetoothState, - permissionGranted, - requestBluetoothPermission, - isPermissionsLoading, - } = useBluetoothManager(); + // Bluetooth + const { bleManager, bluetoothState, permissionGranted, requestBluetoothPermission, isPermissionsLoading } = useBluetoothManager(); - // Custom Audio Streamer Hook + // Audio const audioStreamer = useAudioStreamer(); - - // Phone Audio Recorder Hook const phoneAudioRecorder = usePhoneAudioRecorder(); - const [isPhoneAudioMode, setIsPhoneAudioMode] = useState(false); - - const { - isListeningAudio: isOmiAudioListenerActive, - audioPacketsReceived, - startAudioListener: originalStartAudioListener, - stopAudioListener: originalStopAudioListener, - isRetrying: isAudioListenerRetrying, - retryAttempts: audioListenerRetryAttempts, - } = useAudioListener( - omiConnection, - () => !!deviceConnection.connectedDeviceId - ); + const { isListeningAudio: isOmiAudioListenerActive, audioPacketsReceived, startAudioListener: originalStartAudioListener, stopAudioListener: originalStopAudioListener, isRetrying: isAudioListenerRetrying, retryAttempts: audioListenerRetryAttempts } = useAudioListener(omiConnection, () => !!deviceConnection.connectedDeviceId); - // Refs to hold the current state for onDeviceDisconnect without causing re-memoization + // Refs for disconnect cleanup const isOmiAudioListenerActiveRef = useRef(isOmiAudioListenerActive); const isAudioStreamingRef = useRef(audioStreamer.isStreaming); + useEffect(() => { isOmiAudioListenerActiveRef.current = isOmiAudioListenerActive; }, [isOmiAudioListenerActive]); + useEffect(() => { isAudioStreamingRef.current = audioStreamer.isStreaming; }, [audioStreamer.isStreaming]); - useEffect(() => { - isOmiAudioListenerActiveRef.current = isOmiAudioListenerActive; - }, [isOmiAudioListenerActive]); - - useEffect(() => { - isAudioStreamingRef.current = audioStreamer.isStreaming; - }, [audioStreamer.isStreaming]); + // Settings + const settings = useAppSettings(); - // Now define the stable onDeviceConnect and onDeviceDisconnect callbacks + // Device callbacks const onDeviceConnect = useCallback(async () => { - console.log('[App.tsx] Device connected callback.'); - const deviceIdToSave = omiConnection.connectedDeviceId; // Corrected: Use property from OmiConnection instance - + const deviceIdToSave = omiConnection.connectedDeviceId; if (deviceIdToSave) { - console.log('[App.tsx] Saving connected device ID to storage:', deviceIdToSave); await saveLastConnectedDeviceId(deviceIdToSave); - setLastKnownDeviceId(deviceIdToSave); // Update state for consistency - setTriedAutoReconnectForCurrentId(false); // Reset if a new device connects successfully - } else { - console.warn('[App.tsx] onDeviceConnect: Could not determine connected device ID to save. omiConnection.connectedDeviceId was null/undefined.'); + autoReconnect.setLastKnownDeviceId(deviceIdToSave); + autoReconnect.setTriedAutoReconnectForCurrentId(false); } - // Actions on connect (e.g., auto-fetch codec/battery) - }, [omiConnection]); // saveLastConnectedDeviceId is stable, omiConnection is stable ref + }, [omiConnection]); const onDeviceDisconnect = useCallback(async () => { - console.log('[App.tsx] Device disconnected callback.'); - if (isOmiAudioListenerActiveRef.current) { - console.log('[App.tsx] Disconnect: Stopping audio listener.'); - await originalStopAudioListener(); - } - if (isAudioStreamingRef.current) { - console.log('[App.tsx] Disconnect: Stopping custom audio streaming.'); - audioStreamer.stopStreaming(); - } - // Also stop phone audio if it's running + if (isOmiAudioListenerActiveRef.current) await originalStopAudioListener(); + if (isAudioStreamingRef.current) audioStreamer.stopStreaming(); if (phoneAudioRecorder.isRecording) { - console.log('[App.tsx] Disconnect: Stopping phone audio recording.'); await phoneAudioRecorder.stopRecording(); - setIsPhoneAudioMode(false); + orchestrator.setIsPhoneAudioMode(false); } - }, [originalStopAudioListener, audioStreamer.stopStreaming, phoneAudioRecorder.stopRecording, phoneAudioRecorder.isRecording, setIsPhoneAudioMode]); - - // Initialize Device Connection hook, passing the memoized callbacks - const deviceConnection = useDeviceConnection( - omiConnection, - onDeviceDisconnect, - onDeviceConnect - ); - - // Effect to load settings on app startup - useEffect(() => { - const loadSettings = async () => { - const deviceId = await getLastConnectedDeviceId(); - if (deviceId) { - console.log('[App.tsx] Loaded last known device ID from storage:', deviceId); - setLastKnownDeviceId(deviceId); - setTriedAutoReconnectForCurrentId(false); - } else { - console.log('[App.tsx] No last known device ID found in storage. Auto-reconnect will not be attempted.'); - setLastKnownDeviceId(null); // Explicitly ensure it's null - setTriedAutoReconnectForCurrentId(true); // Mark that we shouldn't try (as no ID is known) - } + }, [originalStopAudioListener, audioStreamer.stopStreaming, phoneAudioRecorder.stopRecording, phoneAudioRecorder.isRecording]); - const storedWsUrl = await getWebSocketUrl(); - if (storedWsUrl) { - console.log('[App.tsx] Loaded WebSocket URL from storage:', storedWsUrl); - setWebSocketUrl(storedWsUrl); - } else { - // Set default to simple backend - const defaultUrl = 'ws://localhost:8000/ws'; - console.log('[App.tsx] No stored WebSocket URL, setting default for simple backend:', defaultUrl); - setWebSocketUrl(defaultUrl); - await saveWebSocketUrl(defaultUrl); - } - - const storedUserId = await getUserId(); - if (storedUserId) { - console.log('[App.tsx] Loaded User ID from storage:', storedUserId); - setUserId(storedUserId); - } - - // Load authentication data - const storedEmail = await getAuthEmail(); - const storedToken = await getJwtToken(); - if (storedEmail && storedToken) { - console.log('[App.tsx] Loaded auth data from storage for:', storedEmail); - setCurrentUserEmail(storedEmail); - setJwtToken(storedToken); - setIsAuthenticated(true); - } - }; - loadSettings(); - }, []); + const deviceConnection = useDeviceConnection(omiConnection, onDeviceDisconnect, onDeviceConnect); + // Battery monitor + const batteryMonitor = useBatteryMonitor({ + connectedDeviceId: deviceConnection.connectedDeviceId, + getBatteryLevel: deviceConnection.getRawBatteryLevel, + onConnectionLost: deviceConnection.disconnectFromDevice, + }); - // Device Scanning Hook - const { - devices: scannedDevices, - scanning, - startScan, - stopScan: stopDeviceScanAction, - } = useDeviceScanning( - bleManager, // From useBluetoothManager - omiConnection, - permissionGranted, // From useBluetoothManager - bluetoothState === BluetoothState.PoweredOn, // Derived from useBluetoothManager - requestBluetoothPermission // From useBluetoothManager, should be stable - ); - - // Effect for attempting auto-reconnection - useEffect(() => { - if ( - bluetoothState === BluetoothState.PoweredOn && - permissionGranted && - lastKnownDeviceId && - !deviceConnection.connectedDeviceId && // Only if not already connected - !deviceConnection.isConnecting && // Only if not currently trying to connect by other means - !scanning && // Only if not currently scanning - !isAttemptingAutoReconnect && // Only if not already attempting auto-reconnect - !triedAutoReconnectForCurrentId // Only try once per loaded/set lastKnownDeviceId - ) { - const attemptAutoConnect = async () => { - console.log(`[App.tsx] Attempting to auto-reconnect to device: ${lastKnownDeviceId}`); - setIsAttemptingAutoReconnect(true); - setTriedAutoReconnectForCurrentId(true); // Mark that we've initiated an attempt for this ID - try { - // useDeviceConnection.connectToDevice can take a device ID string directly - await deviceConnection.connectToDevice(lastKnownDeviceId); - // If connectToDevice throws, catch block handles it. - // If it resolves, the connection attempt was made. - // The onDeviceConnect callback will be triggered if successful. - console.log(`[App.tsx] Auto-reconnect attempt initiated for ${lastKnownDeviceId}. Waiting for connection event.`); - // Removed the if(success) block as connectToDevice is void - } catch (error) { - console.error(`[App.tsx] Error auto-reconnecting to ${lastKnownDeviceId}:`, error); - // Clear the problematic device ID from storage and state - if (lastKnownDeviceId) { // Ensure we have an ID to clear - console.log(`[App.tsx] Clearing problematic device ID ${lastKnownDeviceId} from storage due to auto-reconnect failure.`); - await saveLastConnectedDeviceId(null); // Clears from AsyncStorage - setLastKnownDeviceId(null); // Clears from current app state - } - } finally { - setIsAttemptingAutoReconnect(false); - } - }; - attemptAutoConnect(); - } - }, [ + // Auto-reconnect + const autoReconnect = useAutoReconnect({ bluetoothState, permissionGranted, - lastKnownDeviceId, - deviceConnection.connectedDeviceId, - deviceConnection.isConnecting, - scanning, - deviceConnection.connectToDevice, // Stable function from the hook - triedAutoReconnectForCurrentId, - isAttemptingAutoReconnect, // Added to prevent re-triggering while one is in progress - // Added saveLastConnectedDeviceId and setLastKnownDeviceId to dependency array if they were not already implicitly covered - // saveLastConnectedDeviceId is an import, setLastKnownDeviceId is a state setter - typically stable - ]); - - const handleStartAudioListeningAndStreaming = useCallback(async () => { - if (!webSocketUrl || webSocketUrl.trim() === '') { - Alert.alert('WebSocket URL Required', 'Please enter the WebSocket URL for streaming.'); - return; - } - if (!omiConnection.isConnected() || !deviceConnection.connectedDeviceId) { - Alert.alert('Device Not Connected', 'Please connect to an OMI device first.'); - return; - } - - try { - let finalWebSocketUrl = webSocketUrl.trim(); - - // Check if this is the advanced backend (requires authentication) or simple backend - const isAdvancedBackend = jwtToken && isAuthenticated; - - if (isAdvancedBackend) { - // Advanced backend: include JWT token and device parameters - const params = new URLSearchParams(); - params.append('token', jwtToken); - - if (userId && userId.trim() !== '') { - params.append('device_name', userId.trim()); - console.log('[App.tsx] Using advanced backend with token and device_name:', userId.trim()); - } else { - params.append('device_name', 'phone'); // Default device name - console.log('[App.tsx] Using advanced backend with token and default device_name'); - } - - const separator = webSocketUrl.includes('?') ? '&' : '?'; - finalWebSocketUrl = `${webSocketUrl}${separator}${params.toString()}`; - console.log('[App.tsx] Advanced backend WebSocket URL constructed (token hidden for security)'); - } else { - // Simple backend: use URL as-is without authentication - console.log('[App.tsx] Using simple backend without authentication:', finalWebSocketUrl); - } - - // Start custom WebSocket streaming first - await audioStreamer.startStreaming(finalWebSocketUrl); - - // Then start OMI audio listener - await originalStartAudioListener(async (audioBytes) => { - const wsReadyState = audioStreamer.getWebSocketReadyState(); - if (wsReadyState === WebSocket.OPEN && audioBytes.length > 0) { - await audioStreamer.sendAudio(audioBytes); - } - }); - } catch (error) { - console.error('[App.tsx] Error starting audio listening/streaming:', error); - Alert.alert('Error', 'Could not start audio listening or streaming.'); - // Ensure cleanup if one part started but the other failed - if (audioStreamer.isStreaming) audioStreamer.stopStreaming(); - } - }, [originalStartAudioListener, audioStreamer, webSocketUrl, userId, omiConnection, deviceConnection.connectedDeviceId, jwtToken, isAuthenticated]); - - const handleStopAudioListeningAndStreaming = useCallback(async () => { - console.log('[App.tsx] Stopping audio listening and streaming.'); - await originalStopAudioListener(); - audioStreamer.stopStreaming(); - }, [originalStopAudioListener, audioStreamer]); - - // Phone Audio Streaming Functions - const handleStartPhoneAudioStreaming = useCallback(async () => { - if (!webSocketUrl || webSocketUrl.trim() === '') { - Alert.alert('WebSocket URL Required', 'Please enter the WebSocket URL for streaming.'); - return; - } - - try { - let finalWebSocketUrl = webSocketUrl.trim(); - - // Convert HTTP/HTTPS to WS/WSS protocol - finalWebSocketUrl = finalWebSocketUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:'); - - // Ensure /ws endpoint is included - if (!finalWebSocketUrl.includes('/ws')) { - // Remove trailing slash if present, then add /ws - finalWebSocketUrl = finalWebSocketUrl.replace(/\/$/, '') + '/ws'; - } - - // Add codec parameter if not present - if (!finalWebSocketUrl.includes('codec=')) { - const separator = finalWebSocketUrl.includes('?') ? '&' : '?'; - finalWebSocketUrl = finalWebSocketUrl + separator + 'codec=pcm'; - } - - // Check if this is the advanced backend (requires authentication) or simple backend - const isAdvancedBackend = jwtToken && isAuthenticated; - - if (isAdvancedBackend) { - // Advanced backend: include JWT token and device parameters - const params = new URLSearchParams(); - params.append('token', jwtToken); - - const deviceName = userId && userId.trim() !== '' ? userId.trim() : 'phone-mic'; - params.append('device_name', deviceName); - console.log('[App.tsx] Using advanced backend with token and device_name:', deviceName); - - const separator = finalWebSocketUrl.includes('?') ? '&' : '?'; - finalWebSocketUrl = `${finalWebSocketUrl}${separator}${params.toString()}`; - console.log('[App.tsx] Advanced backend WebSocket URL constructed for phone audio'); - } else { - // Simple backend: use URL as-is without authentication - console.log('[App.tsx] Using simple backend without authentication for phone audio'); - } - - // Start WebSocket streaming first - await audioStreamer.startStreaming(finalWebSocketUrl); - - // Start phone audio recording - await phoneAudioRecorder.startRecording(async (pcmBuffer) => { - const wsReadyState = audioStreamer.getWebSocketReadyState(); - if (wsReadyState === WebSocket.OPEN && pcmBuffer.length > 0) { - await audioStreamer.sendAudio(pcmBuffer); - } - }); - - setIsPhoneAudioMode(true); - console.log('[App.tsx] Phone audio streaming started successfully'); - } catch (error) { - console.error('[App.tsx] Error starting phone audio streaming:', error); - Alert.alert('Error', 'Could not start phone audio streaming.'); - // Ensure cleanup if one part started but the other failed - if (audioStreamer.isStreaming) audioStreamer.stopStreaming(); - if (phoneAudioRecorder.isRecording) await phoneAudioRecorder.stopRecording(); - setIsPhoneAudioMode(false); - } - }, [audioStreamer, phoneAudioRecorder, webSocketUrl, userId, jwtToken, isAuthenticated]); + deviceConnection, + scanning: false, + }); - const handleStopPhoneAudioStreaming = useCallback(async () => { - console.log('[App.tsx] Stopping phone audio streaming.'); - await phoneAudioRecorder.stopRecording(); - audioStreamer.stopStreaming(); - setIsPhoneAudioMode(false); - }, [phoneAudioRecorder, audioStreamer]); + // Scanning + const { devices: scannedDevices, scanning, startScan, stopScan: stopDeviceScanAction } = useDeviceScanning(bleManager, omiConnection, permissionGranted, bluetoothState === BluetoothState.PoweredOn, requestBluetoothPermission); - const handleTogglePhoneAudio = useCallback(async () => { - if (isPhoneAudioMode || phoneAudioRecorder.isRecording) { - await handleStopPhoneAudioStreaming(); - } else { - await handleStartPhoneAudioStreaming(); - } - }, [isPhoneAudioMode, phoneAudioRecorder.isRecording, handleStartPhoneAudioStreaming, handleStopPhoneAudioStreaming]); - - // Store stable references for cleanup - const cleanupRefs = useRef({ + // Audio orchestrator + const orchestrator = useAudioStreamingOrchestrator({ omiConnection, - bleManager, - disconnectFromDevice: deviceConnection.disconnectFromDevice, - stopAudioStreaming: audioStreamer.stopStreaming, - stopPhoneAudio: phoneAudioRecorder.stopRecording, - }); - - // Update refs when functions change - useEffect(() => { - cleanupRefs.current = { - omiConnection, - bleManager, - disconnectFromDevice: deviceConnection.disconnectFromDevice, - stopAudioStreaming: audioStreamer.stopStreaming, - stopPhoneAudio: phoneAudioRecorder.stopRecording, - }; + deviceConnection, + audioStreamer, + phoneAudioRecorder, + originalStartAudioListener, + originalStopAudioListener, + settings, }); - // Cleanup only on actual unmount (no dependencies to avoid re-runs) + // Cleanup + const cleanupRefs = useRef({ omiConnection, bleManager, disconnectFromDevice: deviceConnection.disconnectFromDevice, stopAudioStreaming: audioStreamer.stopStreaming, stopPhoneAudio: phoneAudioRecorder.stopRecording }); + useEffect(() => { cleanupRefs.current = { omiConnection, bleManager, disconnectFromDevice: deviceConnection.disconnectFromDevice, stopAudioStreaming: audioStreamer.stopStreaming, stopPhoneAudio: phoneAudioRecorder.stopRecording }; }); useEffect(() => { return () => { - console.log('App unmounting - cleaning up OmiConnection, BleManager, AudioStreamer, and PhoneAudioRecorder'); const refs = cleanupRefs.current; - - if (refs.omiConnection.isConnected()) { - refs.disconnectFromDevice().catch(err => console.error("Error disconnecting in cleanup:", err)); - } - if (refs.bleManager) { - refs.bleManager.destroy(); - } + if (refs.omiConnection.isConnected()) refs.disconnectFromDevice().catch(() => {}); + if (refs.bleManager) refs.bleManager.destroy(); refs.stopAudioStreaming(); - // Phone audio stopRecording now handles inactive state gracefully - refs.stopPhoneAudio().catch(err => console.error("Error stopping phone audio in cleanup:", err)); + refs.stopPhoneAudio().catch(() => {}); }; - }, []); // Empty dependency array - only run on mount/unmount + }, []); const canScan = React.useMemo(() => ( - permissionGranted && - bluetoothState === BluetoothState.PoweredOn && - !isAttemptingAutoReconnect && - !deviceConnection.isConnecting && + permissionGranted && bluetoothState === BluetoothState.PoweredOn && + !autoReconnect.isAttemptingAutoReconnect && !deviceConnection.isConnecting && !deviceConnection.connectedDeviceId && - (triedAutoReconnectForCurrentId || !lastKnownDeviceId) - // Removed authentication requirement for scanning - ), [ - permissionGranted, - bluetoothState, - isAttemptingAutoReconnect, - deviceConnection.isConnecting, - deviceConnection.connectedDeviceId, - triedAutoReconnectForCurrentId, - lastKnownDeviceId, - ]); + (autoReconnect.triedAutoReconnectForCurrentId || !autoReconnect.lastKnownDeviceId) + ), [permissionGranted, bluetoothState, autoReconnect.isAttemptingAutoReconnect, deviceConnection.isConnecting, deviceConnection.connectedDeviceId, autoReconnect.triedAutoReconnectForCurrentId, autoReconnect.lastKnownDeviceId]); const filteredDevices = React.useMemo(() => { - if (!showOnlyOmi) { - return scannedDevices; - } - return scannedDevices.filter(device => { - const name = device.name?.toLowerCase() || ''; + if (!showOnlyOmi) return scannedDevices; + return scannedDevices.filter(d => { + const name = d.name?.toLowerCase() || ''; return name.includes('omi') || name.includes('friend'); }); }, [scannedDevices, showOnlyOmi]); - const handleSetAndSaveWebSocketUrl = useCallback(async (url: string) => { - setWebSocketUrl(url); - await saveWebSocketUrl(url); - }, []); - - const handleSetAndSaveUserId = useCallback(async (id: string) => { - setUserId(id); - await saveUserId(id || null); - }, []); - - // Authentication status change handler - const handleAuthStatusChange = useCallback((authenticated: boolean, email: string | null, token: string | null) => { - setIsAuthenticated(authenticated); - setCurrentUserEmail(email); - setJwtToken(token); - console.log('[App.tsx] Auth status changed:', { authenticated, email: email ? 'logged in' : 'logged out' }); - }, []); - - const handleCancelAutoReconnect = useCallback(async () => { - console.log('[App.tsx] Cancelling auto-reconnection attempt.'); - if (lastKnownDeviceId) { - // Clear the last known device ID to prevent further auto-reconnect attempts in this session - await saveLastConnectedDeviceId(null); - setLastKnownDeviceId(null); - setTriedAutoReconnectForCurrentId(true); // Mark as tried to prevent immediate re-trigger if conditions meet again - } - // Attempt to stop any ongoing connection process - // disconnectFromDevice also sets isConnecting to false internally. - await deviceConnection.disconnectFromDevice(); - setIsAttemptingAutoReconnect(false); // Explicitly set to false to hide the auto-reconnect screen - }, [deviceConnection, lastKnownDeviceId, saveLastConnectedDeviceId, setLastKnownDeviceId, setTriedAutoReconnectForCurrentId, setIsAttemptingAutoReconnect]); - + // Loading / auto-reconnect screens if (isPermissionsLoading && bluetoothState === BluetoothState.Unknown) { return ( - - - - {isAttemptingAutoReconnect - ? `Attempting to reconnect to the last device (${lastKnownDeviceId ? lastKnownDeviceId.substring(0, 10) + '...' : ''})...` + + + + {autoReconnect.isAttemptingAutoReconnect + ? `Reconnecting to ${autoReconnect.lastKnownDeviceId?.substring(0, 10)}...` : 'Initializing Bluetooth...'} ); } - if (isAttemptingAutoReconnect) { + if (autoReconnect.isAttemptingAutoReconnect) { return ( - - - - - Attempting to reconnect to the last device ({lastKnownDeviceId ? lastKnownDeviceId.substring(0, 10) + '...' : ''})... + + + + + Reconnecting to {autoReconnect.lastKnownDeviceId?.substring(0, 10)}... -