elsabro 2.3.0 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +698 -20
- package/bin/install.js +0 -0
- package/flows/development-flow.json +452 -0
- package/flows/quick-flow.json +118 -0
- package/hooks/hooks-config-updated.json +285 -0
- package/hooks/skill-discovery.sh +539 -0
- package/package.json +3 -2
- package/references/SYSTEM_INDEX.md +400 -5
- package/references/agent-marketplace.md +2274 -0
- package/references/agent-protocol.md +1126 -0
- package/references/ai-code-suggestions.md +2413 -0
- package/references/checkpointing.md +595 -0
- package/references/collaboration-patterns.md +851 -0
- package/references/collaborative-sessions.md +1081 -0
- package/references/configuration-management.md +1810 -0
- package/references/cost-tracking.md +1095 -0
- package/references/enterprise-sso.md +2001 -0
- package/references/error-contracts-v2.md +968 -0
- package/references/event-driven.md +1031 -0
- package/references/flow-orchestration.md +940 -0
- package/references/flow-visualization.md +1557 -0
- package/references/ide-integrations.md +3513 -0
- package/references/interrupt-system.md +681 -0
- package/references/kubernetes-deployment.md +3099 -0
- package/references/memory-system.md +683 -0
- package/references/mobile-companion.md +3236 -0
- package/references/multi-llm-providers.md +2494 -0
- package/references/multi-project-memory.md +1182 -0
- package/references/observability.md +793 -0
- package/references/output-schemas.md +858 -0
- package/references/performance-profiler.md +955 -0
- package/references/plugin-system.md +1526 -0
- package/references/prompt-management.md +292 -0
- package/references/sandbox-execution.md +303 -0
- package/references/security-system.md +1253 -0
- package/references/skill-marketplace-integration.md +3901 -0
- package/references/streaming.md +696 -0
- package/references/testing-framework.md +1151 -0
- package/references/time-travel.md +802 -0
- package/references/tool-registry.md +886 -0
- package/references/voice-commands.md +3296 -0
- package/templates/agent-marketplace-config.json +220 -0
- package/templates/agent-protocol-config.json +136 -0
- package/templates/ai-suggestions-config.json +100 -0
- package/templates/checkpoint-state.json +61 -0
- package/templates/collaboration-config.json +157 -0
- package/templates/collaborative-sessions-config.json +153 -0
- package/templates/configuration-config.json +245 -0
- package/templates/cost-tracking-config.json +148 -0
- package/templates/enterprise-sso-config.json +438 -0
- package/templates/events-config.json +148 -0
- package/templates/flow-visualization-config.json +196 -0
- package/templates/ide-integrations-config.json +442 -0
- package/templates/kubernetes-config.json +764 -0
- package/templates/memory-state.json +84 -0
- package/templates/mobile-companion-config.json +600 -0
- package/templates/multi-llm-config.json +544 -0
- package/templates/multi-project-memory-config.json +145 -0
- package/templates/observability-config.json +109 -0
- package/templates/performance-profiler-config.json +125 -0
- package/templates/plugin-config.json +170 -0
- package/templates/prompt-management-config.json +86 -0
- package/templates/sandbox-config.json +185 -0
- package/templates/schemas-config.json +65 -0
- package/templates/security-config.json +120 -0
- package/templates/skill-marketplace-config.json +441 -0
- package/templates/streaming-config.json +72 -0
- package/templates/testing-config.json +81 -0
- package/templates/timetravel-config.json +62 -0
- package/templates/tool-registry-config.json +109 -0
- package/templates/voice-commands-config.json +658 -0
|
@@ -0,0 +1,3236 @@
|
|
|
1
|
+
# ELSABRO Mobile Companion v3.7
|
|
2
|
+
|
|
3
|
+
## Technical Reference Documentation
|
|
4
|
+
|
|
5
|
+
> React Native + Expo SDK 52+ mobile application for ELSABRO AI orchestration system
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
1. [Mobile App Architecture](#1-mobileapparchitecture)
|
|
12
|
+
2. [Session Sync](#2-sessionsync)
|
|
13
|
+
3. [Push Notification Manager](#3-pushnotificationmanager)
|
|
14
|
+
4. [Voice Input Handler](#4-voiceinputhandler)
|
|
15
|
+
5. [Offline Mode](#5-offlinemode)
|
|
16
|
+
6. [Biometric Auth](#6-biometricauth)
|
|
17
|
+
7. [Widget Support](#7-widget-support)
|
|
18
|
+
8. [Commands Reference](#8-commands)
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Architecture Overview
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
+------------------------------------------------------------------+
|
|
26
|
+
| ELSABRO Mobile Companion |
|
|
27
|
+
+------------------------------------------------------------------+
|
|
28
|
+
| |
|
|
29
|
+
| +---------------------+ +---------------------+ |
|
|
30
|
+
| | Presentation | | Navigation | |
|
|
31
|
+
| | Layer | | (expo-router) | |
|
|
32
|
+
| +----------+----------+ +----------+----------+ |
|
|
33
|
+
| | | |
|
|
34
|
+
| +----------v--------------------------v----------+ |
|
|
35
|
+
| | State Management (Zustand) | |
|
|
36
|
+
| | +------------+ +------------+ +----------+ | |
|
|
37
|
+
| | | SessionStore| | AuthStore | | UIStore | | |
|
|
38
|
+
| | +------------+ +------------+ +----------+ | |
|
|
39
|
+
| +------------------------+-----------------------+ |
|
|
40
|
+
| | |
|
|
41
|
+
| +------------------------v-----------------------+ |
|
|
42
|
+
| | Data Layer (React Query) | |
|
|
43
|
+
| | +----------+ +-------------+ +-----------+ | |
|
|
44
|
+
| | | Queries | | Mutations | | Cache | | |
|
|
45
|
+
| | +----------+ +-------------+ +-----------+ | |
|
|
46
|
+
| +------------------------+-----------------------+ |
|
|
47
|
+
| | |
|
|
48
|
+
| +------------------------v-----------------------+ |
|
|
49
|
+
| | Native Modules | |
|
|
50
|
+
| | +-------+ +--------+ +------+ +------------+ | |
|
|
51
|
+
| | |Biometric| |Voice | |Push | |Widgets | | |
|
|
52
|
+
| | +-------+ +--------+ +------+ +------------+ | |
|
|
53
|
+
| +------------------------------------------------+ |
|
|
54
|
+
| | |
|
|
55
|
+
| +------------------------v-----------------------+ |
|
|
56
|
+
| | Network Layer (WebSocket + REST) | |
|
|
57
|
+
| +------------------------------------------------+ |
|
|
58
|
+
| | |
|
|
59
|
+
+---------------------------v---------------------------------------+
|
|
60
|
+
|
|
|
61
|
+
+-------------v--------------+
|
|
62
|
+
| ELSABRO Backend API |
|
|
63
|
+
| (WebSocket + REST) |
|
|
64
|
+
+----------------------------+
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 1. MobileAppArchitecture
|
|
70
|
+
|
|
71
|
+
### Project Structure (Expo SDK 52+)
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
elsabro-mobile/
|
|
75
|
+
+-- app/ # expo-router pages
|
|
76
|
+
| +-- (tabs)/ # Tab navigation group
|
|
77
|
+
| | +-- index.tsx # Dashboard
|
|
78
|
+
| | +-- sessions.tsx # Active sessions
|
|
79
|
+
| | +-- agents.tsx # Agent management
|
|
80
|
+
| | +-- settings.tsx # App settings
|
|
81
|
+
| | +-- _layout.tsx # Tab layout
|
|
82
|
+
| +-- session/
|
|
83
|
+
| | +-- [id].tsx # Session detail
|
|
84
|
+
| +-- auth/
|
|
85
|
+
| | +-- login.tsx # Login screen
|
|
86
|
+
| | +-- biometric.tsx # Biometric setup
|
|
87
|
+
| +-- _layout.tsx # Root layout
|
|
88
|
+
| +-- +not-found.tsx # 404 handler
|
|
89
|
+
+-- src/
|
|
90
|
+
| +-- components/ # Reusable components
|
|
91
|
+
| | +-- ui/ # Base UI components
|
|
92
|
+
| | +-- sessions/ # Session-specific
|
|
93
|
+
| | +-- agents/ # Agent-specific
|
|
94
|
+
| +-- stores/ # Zustand stores
|
|
95
|
+
| | +-- sessionStore.ts
|
|
96
|
+
| | +-- authStore.ts
|
|
97
|
+
| | +-- notificationStore.ts
|
|
98
|
+
| | +-- offlineStore.ts
|
|
99
|
+
| +-- hooks/ # Custom hooks
|
|
100
|
+
| | +-- useSession.ts
|
|
101
|
+
| | +-- useWebSocket.ts
|
|
102
|
+
| | +-- useVoice.ts
|
|
103
|
+
| | +-- useBiometric.ts
|
|
104
|
+
| +-- services/ # API services
|
|
105
|
+
| | +-- api.ts
|
|
106
|
+
| | +-- websocket.ts
|
|
107
|
+
| | +-- notifications.ts
|
|
108
|
+
| +-- types/ # TypeScript types
|
|
109
|
+
| +-- utils/ # Utilities
|
|
110
|
+
| +-- constants/ # App constants
|
|
111
|
+
+-- assets/ # Static assets
|
|
112
|
+
+-- widgets/ # Native widgets
|
|
113
|
+
| +-- ios/ # iOS WidgetKit
|
|
114
|
+
| +-- android/ # Android widgets
|
|
115
|
+
+-- app.json # Expo config
|
|
116
|
+
+-- eas.json # EAS Build config
|
|
117
|
+
+-- tsconfig.json
|
|
118
|
+
+-- package.json
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Core Dependencies
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"dependencies": {
|
|
126
|
+
"expo": "~52.0.0",
|
|
127
|
+
"expo-router": "~4.0.0",
|
|
128
|
+
"expo-notifications": "~0.29.0",
|
|
129
|
+
"expo-local-authentication": "~15.0.0",
|
|
130
|
+
"expo-secure-store": "~14.0.0",
|
|
131
|
+
"expo-speech": "~13.0.0",
|
|
132
|
+
"expo-haptics": "~14.0.0",
|
|
133
|
+
"@react-native-voice/voice": "^3.3.0",
|
|
134
|
+
"@tanstack/react-query": "^5.60.0",
|
|
135
|
+
"zustand": "^5.0.0",
|
|
136
|
+
"react-native-reanimated": "~3.16.0",
|
|
137
|
+
"react-native-gesture-handler": "~2.20.0"
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Root Layout Configuration
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
// app/_layout.tsx
|
|
146
|
+
import { Stack } from 'expo-router';
|
|
147
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
148
|
+
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
149
|
+
import { useEffect } from 'react';
|
|
150
|
+
import { useAuthStore } from '@/stores/authStore';
|
|
151
|
+
import { useNotificationStore } from '@/stores/notificationStore';
|
|
152
|
+
import { WebSocketProvider } from '@/providers/WebSocketProvider';
|
|
153
|
+
|
|
154
|
+
const queryClient = new QueryClient({
|
|
155
|
+
defaultOptions: {
|
|
156
|
+
queries: {
|
|
157
|
+
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
158
|
+
gcTime: 1000 * 60 * 30, // 30 minutes
|
|
159
|
+
retry: 3,
|
|
160
|
+
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
export default function RootLayout() {
|
|
166
|
+
const { initialize: initAuth } = useAuthStore();
|
|
167
|
+
const { registerForPushNotifications } = useNotificationStore();
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
initAuth();
|
|
171
|
+
registerForPushNotifications();
|
|
172
|
+
}, []);
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
176
|
+
<QueryClientProvider client={queryClient}>
|
|
177
|
+
<WebSocketProvider>
|
|
178
|
+
<Stack
|
|
179
|
+
screenOptions={{
|
|
180
|
+
headerShown: false,
|
|
181
|
+
animation: 'slide_from_right',
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
<Stack.Screen name="(tabs)" />
|
|
185
|
+
<Stack.Screen
|
|
186
|
+
name="session/[id]"
|
|
187
|
+
options={{ presentation: 'modal' }}
|
|
188
|
+
/>
|
|
189
|
+
<Stack.Screen
|
|
190
|
+
name="auth/login"
|
|
191
|
+
options={{ presentation: 'fullScreenModal' }}
|
|
192
|
+
/>
|
|
193
|
+
</Stack>
|
|
194
|
+
</WebSocketProvider>
|
|
195
|
+
</QueryClientProvider>
|
|
196
|
+
</GestureHandlerRootView>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Zustand Store Pattern
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
// src/stores/sessionStore.ts
|
|
205
|
+
import { create } from 'zustand';
|
|
206
|
+
import { persist, createJSONStorage } from 'zustand/middleware';
|
|
207
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
208
|
+
import { immer } from 'zustand/middleware/immer';
|
|
209
|
+
|
|
210
|
+
interface Session {
|
|
211
|
+
id: string;
|
|
212
|
+
name: string;
|
|
213
|
+
status: 'active' | 'paused' | 'completed' | 'error';
|
|
214
|
+
agents: string[];
|
|
215
|
+
currentTask: string | null;
|
|
216
|
+
progress: number;
|
|
217
|
+
startedAt: string;
|
|
218
|
+
lastActivity: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface SessionState {
|
|
222
|
+
sessions: Record<string, Session>;
|
|
223
|
+
activeSessionId: string | null;
|
|
224
|
+
isConnected: boolean;
|
|
225
|
+
lastSyncTimestamp: number;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
interface SessionActions {
|
|
229
|
+
setActiveSession: (id: string | null) => void;
|
|
230
|
+
updateSession: (id: string, updates: Partial<Session>) => void;
|
|
231
|
+
addSession: (session: Session) => void;
|
|
232
|
+
removeSession: (id: string) => void;
|
|
233
|
+
syncSessions: (sessions: Session[]) => void;
|
|
234
|
+
setConnected: (connected: boolean) => void;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export const useSessionStore = create<SessionState & SessionActions>()(
|
|
238
|
+
persist(
|
|
239
|
+
immer((set, get) => ({
|
|
240
|
+
// State
|
|
241
|
+
sessions: {},
|
|
242
|
+
activeSessionId: null,
|
|
243
|
+
isConnected: false,
|
|
244
|
+
lastSyncTimestamp: 0,
|
|
245
|
+
|
|
246
|
+
// Actions
|
|
247
|
+
setActiveSession: (id) =>
|
|
248
|
+
set((state) => {
|
|
249
|
+
state.activeSessionId = id;
|
|
250
|
+
}),
|
|
251
|
+
|
|
252
|
+
updateSession: (id, updates) =>
|
|
253
|
+
set((state) => {
|
|
254
|
+
if (state.sessions[id]) {
|
|
255
|
+
Object.assign(state.sessions[id], updates);
|
|
256
|
+
state.sessions[id].lastActivity = new Date().toISOString();
|
|
257
|
+
}
|
|
258
|
+
}),
|
|
259
|
+
|
|
260
|
+
addSession: (session) =>
|
|
261
|
+
set((state) => {
|
|
262
|
+
state.sessions[session.id] = session;
|
|
263
|
+
}),
|
|
264
|
+
|
|
265
|
+
removeSession: (id) =>
|
|
266
|
+
set((state) => {
|
|
267
|
+
delete state.sessions[id];
|
|
268
|
+
if (state.activeSessionId === id) {
|
|
269
|
+
state.activeSessionId = null;
|
|
270
|
+
}
|
|
271
|
+
}),
|
|
272
|
+
|
|
273
|
+
syncSessions: (sessions) =>
|
|
274
|
+
set((state) => {
|
|
275
|
+
sessions.forEach((session) => {
|
|
276
|
+
state.sessions[session.id] = session;
|
|
277
|
+
});
|
|
278
|
+
state.lastSyncTimestamp = Date.now();
|
|
279
|
+
}),
|
|
280
|
+
|
|
281
|
+
setConnected: (connected) =>
|
|
282
|
+
set((state) => {
|
|
283
|
+
state.isConnected = connected;
|
|
284
|
+
}),
|
|
285
|
+
})),
|
|
286
|
+
{
|
|
287
|
+
name: 'elsabro-sessions',
|
|
288
|
+
storage: createJSONStorage(() => AsyncStorage),
|
|
289
|
+
partialize: (state) => ({
|
|
290
|
+
sessions: state.sessions,
|
|
291
|
+
activeSessionId: state.activeSessionId,
|
|
292
|
+
lastSyncTimestamp: state.lastSyncTimestamp,
|
|
293
|
+
}),
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
);
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### React Query API Layer
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
// src/services/api.ts
|
|
303
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
304
|
+
import { useAuthStore } from '@/stores/authStore';
|
|
305
|
+
|
|
306
|
+
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'https://api.elsabro.io';
|
|
307
|
+
|
|
308
|
+
interface ApiOptions {
|
|
309
|
+
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
|
310
|
+
body?: unknown;
|
|
311
|
+
headers?: Record<string, string>;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function apiRequest<T>(endpoint: string, options: ApiOptions = {}): Promise<T> {
|
|
315
|
+
const token = useAuthStore.getState().accessToken;
|
|
316
|
+
|
|
317
|
+
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
|
318
|
+
method: options.method || 'GET',
|
|
319
|
+
headers: {
|
|
320
|
+
'Content-Type': 'application/json',
|
|
321
|
+
'Authorization': token ? `Bearer ${token}` : '',
|
|
322
|
+
'X-Client-Version': '3.7.0',
|
|
323
|
+
'X-Platform': Platform.OS,
|
|
324
|
+
...options.headers,
|
|
325
|
+
},
|
|
326
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
if (!response.ok) {
|
|
330
|
+
const error = await response.json().catch(() => ({}));
|
|
331
|
+
throw new ApiError(response.status, error.message || 'Request failed');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return response.json();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Query Keys Factory
|
|
338
|
+
export const queryKeys = {
|
|
339
|
+
sessions: {
|
|
340
|
+
all: ['sessions'] as const,
|
|
341
|
+
list: (filters?: SessionFilters) => [...queryKeys.sessions.all, 'list', filters] as const,
|
|
342
|
+
detail: (id: string) => [...queryKeys.sessions.all, 'detail', id] as const,
|
|
343
|
+
logs: (id: string) => [...queryKeys.sessions.all, 'logs', id] as const,
|
|
344
|
+
},
|
|
345
|
+
agents: {
|
|
346
|
+
all: ['agents'] as const,
|
|
347
|
+
available: () => [...queryKeys.agents.all, 'available'] as const,
|
|
348
|
+
detail: (id: string) => [...queryKeys.agents.all, 'detail', id] as const,
|
|
349
|
+
},
|
|
350
|
+
user: {
|
|
351
|
+
profile: ['user', 'profile'] as const,
|
|
352
|
+
preferences: ['user', 'preferences'] as const,
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
// Sessions Hooks
|
|
357
|
+
export function useSessions(filters?: SessionFilters) {
|
|
358
|
+
return useQuery({
|
|
359
|
+
queryKey: queryKeys.sessions.list(filters),
|
|
360
|
+
queryFn: () => apiRequest<Session[]>('/sessions', {
|
|
361
|
+
method: 'GET',
|
|
362
|
+
}),
|
|
363
|
+
staleTime: 1000 * 30, // 30 seconds for active data
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function useSession(id: string) {
|
|
368
|
+
return useQuery({
|
|
369
|
+
queryKey: queryKeys.sessions.detail(id),
|
|
370
|
+
queryFn: () => apiRequest<SessionDetail>(`/sessions/${id}`),
|
|
371
|
+
enabled: !!id,
|
|
372
|
+
refetchInterval: 5000, // Poll every 5 seconds for active session
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function useSessionLogs(id: string, options?: { enabled?: boolean }) {
|
|
377
|
+
return useQuery({
|
|
378
|
+
queryKey: queryKeys.sessions.logs(id),
|
|
379
|
+
queryFn: () => apiRequest<SessionLog[]>(`/sessions/${id}/logs`),
|
|
380
|
+
enabled: options?.enabled ?? !!id,
|
|
381
|
+
refetchInterval: 2000,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Mutations
|
|
386
|
+
export function useCreateSession() {
|
|
387
|
+
const queryClient = useQueryClient();
|
|
388
|
+
|
|
389
|
+
return useMutation({
|
|
390
|
+
mutationFn: (data: CreateSessionRequest) =>
|
|
391
|
+
apiRequest<Session>('/sessions', {
|
|
392
|
+
method: 'POST',
|
|
393
|
+
body: data,
|
|
394
|
+
}),
|
|
395
|
+
onSuccess: (newSession) => {
|
|
396
|
+
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
|
|
397
|
+
queryClient.setQueryData(
|
|
398
|
+
queryKeys.sessions.detail(newSession.id),
|
|
399
|
+
newSession
|
|
400
|
+
);
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function usePauseSession() {
|
|
406
|
+
const queryClient = useQueryClient();
|
|
407
|
+
|
|
408
|
+
return useMutation({
|
|
409
|
+
mutationFn: (id: string) =>
|
|
410
|
+
apiRequest<Session>(`/sessions/${id}/pause`, { method: 'POST' }),
|
|
411
|
+
onMutate: async (id) => {
|
|
412
|
+
await queryClient.cancelQueries({ queryKey: queryKeys.sessions.detail(id) });
|
|
413
|
+
const previous = queryClient.getQueryData(queryKeys.sessions.detail(id));
|
|
414
|
+
|
|
415
|
+
queryClient.setQueryData(queryKeys.sessions.detail(id), (old: Session) => ({
|
|
416
|
+
...old,
|
|
417
|
+
status: 'paused',
|
|
418
|
+
}));
|
|
419
|
+
|
|
420
|
+
return { previous };
|
|
421
|
+
},
|
|
422
|
+
onError: (err, id, context) => {
|
|
423
|
+
if (context?.previous) {
|
|
424
|
+
queryClient.setQueryData(queryKeys.sessions.detail(id), context.previous);
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
onSettled: (_, __, id) => {
|
|
428
|
+
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.detail(id) });
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function useSendCommand() {
|
|
434
|
+
return useMutation({
|
|
435
|
+
mutationFn: ({ sessionId, command }: { sessionId: string; command: string }) =>
|
|
436
|
+
apiRequest<CommandResponse>(`/sessions/${sessionId}/command`, {
|
|
437
|
+
method: 'POST',
|
|
438
|
+
body: { command },
|
|
439
|
+
}),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
## 2. SessionSync
|
|
447
|
+
|
|
448
|
+
### WebSocket Real-Time Synchronization
|
|
449
|
+
|
|
450
|
+
```
|
|
451
|
+
+-------------------+ +-------------------+
|
|
452
|
+
| Mobile Client | | ELSABRO Server |
|
|
453
|
+
+-------------------+ +-------------------+
|
|
454
|
+
| |
|
|
455
|
+
| 1. WS Connect + Auth Token |
|
|
456
|
+
|----------------------------->|
|
|
457
|
+
| |
|
|
458
|
+
| 2. Connection Confirmed |
|
|
459
|
+
|<-----------------------------|
|
|
460
|
+
| |
|
|
461
|
+
| 3. Subscribe to Session |
|
|
462
|
+
|----------------------------->|
|
|
463
|
+
| |
|
|
464
|
+
| 4. Initial State Snapshot |
|
|
465
|
+
|<-----------------------------|
|
|
466
|
+
| |
|
|
467
|
+
| 5. Real-time Updates |
|
|
468
|
+
|<-----------------------------|
|
|
469
|
+
|<-----------------------------|
|
|
470
|
+
| |
|
|
471
|
+
| 6. Client Action |
|
|
472
|
+
|----------------------------->|
|
|
473
|
+
| |
|
|
474
|
+
| 7. Action Confirmed |
|
|
475
|
+
|<-----------------------------|
|
|
476
|
+
| |
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
### WebSocket Service Implementation
|
|
480
|
+
|
|
481
|
+
```typescript
|
|
482
|
+
// src/services/websocket.ts
|
|
483
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
484
|
+
import { AppState, AppStateStatus } from 'react-native';
|
|
485
|
+
import { useAuthStore } from '@/stores/authStore';
|
|
486
|
+
import { useSessionStore } from '@/stores/sessionStore';
|
|
487
|
+
import { useOfflineStore } from '@/stores/offlineStore';
|
|
488
|
+
|
|
489
|
+
const WS_URL = process.env.EXPO_PUBLIC_WS_URL || 'wss://ws.elsabro.io';
|
|
490
|
+
|
|
491
|
+
interface WebSocketMessage {
|
|
492
|
+
type: string;
|
|
493
|
+
payload: unknown;
|
|
494
|
+
timestamp: number;
|
|
495
|
+
messageId: string;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
interface SessionUpdate {
|
|
499
|
+
sessionId: string;
|
|
500
|
+
type: 'status' | 'progress' | 'log' | 'agent' | 'task' | 'error';
|
|
501
|
+
data: unknown;
|
|
502
|
+
version: number;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
|
|
506
|
+
|
|
507
|
+
class ElsabroWebSocket {
|
|
508
|
+
private ws: WebSocket | null = null;
|
|
509
|
+
private reconnectAttempts = 0;
|
|
510
|
+
private maxReconnectAttempts = 10;
|
|
511
|
+
private reconnectDelay = 1000;
|
|
512
|
+
private heartbeatInterval: NodeJS.Timeout | null = null;
|
|
513
|
+
private messageQueue: WebSocketMessage[] = [];
|
|
514
|
+
private subscriptions = new Set<string>();
|
|
515
|
+
private stateVersions = new Map<string, number>();
|
|
516
|
+
|
|
517
|
+
private listeners = {
|
|
518
|
+
onConnect: new Set<() => void>(),
|
|
519
|
+
onDisconnect: new Set<(reason: string) => void>(),
|
|
520
|
+
onMessage: new Set<(message: WebSocketMessage) => void>(),
|
|
521
|
+
onError: new Set<(error: Error) => void>(),
|
|
522
|
+
onStateChange: new Set<(state: ConnectionState) => void>(),
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
private connectionState: ConnectionState = 'disconnected';
|
|
526
|
+
|
|
527
|
+
connect(token: string): void {
|
|
528
|
+
if (this.ws?.readyState === WebSocket.OPEN) return;
|
|
529
|
+
|
|
530
|
+
this.setConnectionState('connecting');
|
|
531
|
+
|
|
532
|
+
this.ws = new WebSocket(`${WS_URL}?token=${token}`);
|
|
533
|
+
|
|
534
|
+
this.ws.onopen = () => {
|
|
535
|
+
console.log('[WS] Connected');
|
|
536
|
+
this.setConnectionState('connected');
|
|
537
|
+
this.reconnectAttempts = 0;
|
|
538
|
+
this.startHeartbeat();
|
|
539
|
+
this.flushMessageQueue();
|
|
540
|
+
this.resubscribeAll();
|
|
541
|
+
this.listeners.onConnect.forEach(cb => cb());
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
this.ws.onmessage = (event) => {
|
|
545
|
+
try {
|
|
546
|
+
const message: WebSocketMessage = JSON.parse(event.data);
|
|
547
|
+
this.handleMessage(message);
|
|
548
|
+
} catch (error) {
|
|
549
|
+
console.error('[WS] Parse error:', error);
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
this.ws.onclose = (event) => {
|
|
554
|
+
console.log('[WS] Disconnected:', event.code, event.reason);
|
|
555
|
+
this.setConnectionState('disconnected');
|
|
556
|
+
this.stopHeartbeat();
|
|
557
|
+
this.listeners.onDisconnect.forEach(cb => cb(event.reason));
|
|
558
|
+
this.attemptReconnect(token);
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
this.ws.onerror = (error) => {
|
|
562
|
+
console.error('[WS] Error:', error);
|
|
563
|
+
this.listeners.onError.forEach(cb => cb(new Error('WebSocket error')));
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private handleMessage(message: WebSocketMessage): void {
|
|
568
|
+
switch (message.type) {
|
|
569
|
+
case 'session:update':
|
|
570
|
+
this.handleSessionUpdate(message.payload as SessionUpdate);
|
|
571
|
+
break;
|
|
572
|
+
case 'session:sync':
|
|
573
|
+
this.handleFullSync(message.payload);
|
|
574
|
+
break;
|
|
575
|
+
case 'pong':
|
|
576
|
+
// Heartbeat response
|
|
577
|
+
break;
|
|
578
|
+
case 'error':
|
|
579
|
+
console.error('[WS] Server error:', message.payload);
|
|
580
|
+
break;
|
|
581
|
+
default:
|
|
582
|
+
this.listeners.onMessage.forEach(cb => cb(message));
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private handleSessionUpdate(update: SessionUpdate): void {
|
|
587
|
+
const currentVersion = this.stateVersions.get(update.sessionId) || 0;
|
|
588
|
+
|
|
589
|
+
// Conflict detection - request full sync if version mismatch
|
|
590
|
+
if (update.version > currentVersion + 1) {
|
|
591
|
+
console.log('[WS] Version gap detected, requesting full sync');
|
|
592
|
+
this.requestFullSync(update.sessionId);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (update.version <= currentVersion) {
|
|
597
|
+
console.log('[WS] Stale update ignored');
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
this.stateVersions.set(update.sessionId, update.version);
|
|
602
|
+
|
|
603
|
+
const sessionStore = useSessionStore.getState();
|
|
604
|
+
|
|
605
|
+
switch (update.type) {
|
|
606
|
+
case 'status':
|
|
607
|
+
sessionStore.updateSession(update.sessionId, {
|
|
608
|
+
status: update.data as Session['status']
|
|
609
|
+
});
|
|
610
|
+
break;
|
|
611
|
+
case 'progress':
|
|
612
|
+
sessionStore.updateSession(update.sessionId, {
|
|
613
|
+
progress: update.data as number
|
|
614
|
+
});
|
|
615
|
+
break;
|
|
616
|
+
case 'task':
|
|
617
|
+
sessionStore.updateSession(update.sessionId, {
|
|
618
|
+
currentTask: update.data as string
|
|
619
|
+
});
|
|
620
|
+
break;
|
|
621
|
+
case 'error':
|
|
622
|
+
sessionStore.updateSession(update.sessionId, {
|
|
623
|
+
status: 'error',
|
|
624
|
+
error: update.data as string,
|
|
625
|
+
});
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private handleFullSync(payload: unknown): void {
|
|
631
|
+
const { sessions, versions } = payload as {
|
|
632
|
+
sessions: Session[];
|
|
633
|
+
versions: Record<string, number>;
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
useSessionStore.getState().syncSessions(sessions);
|
|
637
|
+
|
|
638
|
+
Object.entries(versions).forEach(([id, version]) => {
|
|
639
|
+
this.stateVersions.set(id, version);
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private requestFullSync(sessionId: string): void {
|
|
644
|
+
this.send({
|
|
645
|
+
type: 'session:sync:request',
|
|
646
|
+
payload: { sessionId },
|
|
647
|
+
timestamp: Date.now(),
|
|
648
|
+
messageId: crypto.randomUUID(),
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
subscribe(sessionId: string): void {
|
|
653
|
+
this.subscriptions.add(sessionId);
|
|
654
|
+
|
|
655
|
+
if (this.isConnected()) {
|
|
656
|
+
this.send({
|
|
657
|
+
type: 'session:subscribe',
|
|
658
|
+
payload: { sessionId },
|
|
659
|
+
timestamp: Date.now(),
|
|
660
|
+
messageId: crypto.randomUUID(),
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
unsubscribe(sessionId: string): void {
|
|
666
|
+
this.subscriptions.delete(sessionId);
|
|
667
|
+
this.stateVersions.delete(sessionId);
|
|
668
|
+
|
|
669
|
+
if (this.isConnected()) {
|
|
670
|
+
this.send({
|
|
671
|
+
type: 'session:unsubscribe',
|
|
672
|
+
payload: { sessionId },
|
|
673
|
+
timestamp: Date.now(),
|
|
674
|
+
messageId: crypto.randomUUID(),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
private resubscribeAll(): void {
|
|
680
|
+
this.subscriptions.forEach(sessionId => {
|
|
681
|
+
this.send({
|
|
682
|
+
type: 'session:subscribe',
|
|
683
|
+
payload: { sessionId },
|
|
684
|
+
timestamp: Date.now(),
|
|
685
|
+
messageId: crypto.randomUUID(),
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
send(message: WebSocketMessage): void {
|
|
691
|
+
if (this.isConnected()) {
|
|
692
|
+
this.ws!.send(JSON.stringify(message));
|
|
693
|
+
} else {
|
|
694
|
+
// Queue for later
|
|
695
|
+
this.messageQueue.push(message);
|
|
696
|
+
useOfflineStore.getState().addPendingAction({
|
|
697
|
+
id: message.messageId,
|
|
698
|
+
type: 'websocket',
|
|
699
|
+
data: message,
|
|
700
|
+
createdAt: Date.now(),
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private flushMessageQueue(): void {
|
|
706
|
+
while (this.messageQueue.length > 0) {
|
|
707
|
+
const message = this.messageQueue.shift()!;
|
|
708
|
+
this.ws!.send(JSON.stringify(message));
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private startHeartbeat(): void {
|
|
713
|
+
this.heartbeatInterval = setInterval(() => {
|
|
714
|
+
if (this.isConnected()) {
|
|
715
|
+
this.send({
|
|
716
|
+
type: 'ping',
|
|
717
|
+
payload: null,
|
|
718
|
+
timestamp: Date.now(),
|
|
719
|
+
messageId: crypto.randomUUID(),
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}, 30000);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private stopHeartbeat(): void {
|
|
726
|
+
if (this.heartbeatInterval) {
|
|
727
|
+
clearInterval(this.heartbeatInterval);
|
|
728
|
+
this.heartbeatInterval = null;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
private attemptReconnect(token: string): void {
|
|
733
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
734
|
+
console.log('[WS] Max reconnect attempts reached');
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
this.setConnectionState('reconnecting');
|
|
739
|
+
this.reconnectAttempts++;
|
|
740
|
+
|
|
741
|
+
const delay = Math.min(
|
|
742
|
+
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
|
|
743
|
+
30000
|
|
744
|
+
) + Math.random() * 1000; // Jitter
|
|
745
|
+
|
|
746
|
+
setTimeout(() => {
|
|
747
|
+
console.log(`[WS] Reconnect attempt ${this.reconnectAttempts}`);
|
|
748
|
+
this.connect(token);
|
|
749
|
+
}, delay);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private setConnectionState(state: ConnectionState): void {
|
|
753
|
+
this.connectionState = state;
|
|
754
|
+
this.listeners.onStateChange.forEach(cb => cb(state));
|
|
755
|
+
useSessionStore.getState().setConnected(state === 'connected');
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
isConnected(): boolean {
|
|
759
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
disconnect(): void {
|
|
763
|
+
this.stopHeartbeat();
|
|
764
|
+
this.ws?.close(1000, 'Client disconnect');
|
|
765
|
+
this.ws = null;
|
|
766
|
+
this.setConnectionState('disconnected');
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
on<K extends keyof typeof this.listeners>(
|
|
770
|
+
event: K,
|
|
771
|
+
callback: Parameters<typeof this.listeners[K]['add']>[0]
|
|
772
|
+
): () => void {
|
|
773
|
+
(this.listeners[event] as Set<unknown>).add(callback);
|
|
774
|
+
return () => (this.listeners[event] as Set<unknown>).delete(callback);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
export const wsClient = new ElsabroWebSocket();
|
|
779
|
+
|
|
780
|
+
// React Hook
|
|
781
|
+
export function useWebSocket() {
|
|
782
|
+
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
|
783
|
+
const { accessToken } = useAuthStore();
|
|
784
|
+
|
|
785
|
+
useEffect(() => {
|
|
786
|
+
if (!accessToken) return;
|
|
787
|
+
|
|
788
|
+
wsClient.connect(accessToken);
|
|
789
|
+
|
|
790
|
+
const unsubscribe = wsClient.on('onStateChange', setConnectionState);
|
|
791
|
+
|
|
792
|
+
// Handle app state changes
|
|
793
|
+
const handleAppState = (state: AppStateStatus) => {
|
|
794
|
+
if (state === 'active' && !wsClient.isConnected()) {
|
|
795
|
+
wsClient.connect(accessToken);
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const subscription = AppState.addEventListener('change', handleAppState);
|
|
800
|
+
|
|
801
|
+
return () => {
|
|
802
|
+
unsubscribe();
|
|
803
|
+
subscription.remove();
|
|
804
|
+
};
|
|
805
|
+
}, [accessToken]);
|
|
806
|
+
|
|
807
|
+
const subscribe = useCallback((sessionId: string) => {
|
|
808
|
+
wsClient.subscribe(sessionId);
|
|
809
|
+
return () => wsClient.unsubscribe(sessionId);
|
|
810
|
+
}, []);
|
|
811
|
+
|
|
812
|
+
return {
|
|
813
|
+
connectionState,
|
|
814
|
+
isConnected: connectionState === 'connected',
|
|
815
|
+
subscribe,
|
|
816
|
+
send: wsClient.send.bind(wsClient),
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
### Deep Linking Configuration
|
|
822
|
+
|
|
823
|
+
```typescript
|
|
824
|
+
// app.json (excerpt)
|
|
825
|
+
{
|
|
826
|
+
"expo": {
|
|
827
|
+
"scheme": "elsabro",
|
|
828
|
+
"ios": {
|
|
829
|
+
"bundleIdentifier": "io.elsabro.companion",
|
|
830
|
+
"associatedDomains": [
|
|
831
|
+
"applinks:elsabro.io",
|
|
832
|
+
"applinks:*.elsabro.io"
|
|
833
|
+
]
|
|
834
|
+
},
|
|
835
|
+
"android": {
|
|
836
|
+
"package": "io.elsabro.companion",
|
|
837
|
+
"intentFilters": [
|
|
838
|
+
{
|
|
839
|
+
"action": "VIEW",
|
|
840
|
+
"autoVerify": true,
|
|
841
|
+
"data": [
|
|
842
|
+
{
|
|
843
|
+
"scheme": "https",
|
|
844
|
+
"host": "elsabro.io",
|
|
845
|
+
"pathPrefix": "/session"
|
|
846
|
+
},
|
|
847
|
+
{
|
|
848
|
+
"scheme": "elsabro"
|
|
849
|
+
}
|
|
850
|
+
],
|
|
851
|
+
"category": ["BROWSABLE", "DEFAULT"]
|
|
852
|
+
}
|
|
853
|
+
]
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
```typescript
|
|
860
|
+
// src/hooks/useDeepLink.ts
|
|
861
|
+
import { useEffect } from 'react';
|
|
862
|
+
import { useRouter } from 'expo-router';
|
|
863
|
+
import * as Linking from 'expo-linking';
|
|
864
|
+
import { useAuthStore } from '@/stores/authStore';
|
|
865
|
+
|
|
866
|
+
export function useDeepLink() {
|
|
867
|
+
const router = useRouter();
|
|
868
|
+
const { isAuthenticated } = useAuthStore();
|
|
869
|
+
|
|
870
|
+
useEffect(() => {
|
|
871
|
+
// Handle initial URL
|
|
872
|
+
Linking.getInitialURL().then(handleUrl);
|
|
873
|
+
|
|
874
|
+
// Handle incoming URLs
|
|
875
|
+
const subscription = Linking.addEventListener('url', ({ url }) => {
|
|
876
|
+
handleUrl(url);
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
return () => subscription.remove();
|
|
880
|
+
}, [isAuthenticated]);
|
|
881
|
+
|
|
882
|
+
function handleUrl(url: string | null) {
|
|
883
|
+
if (!url) return;
|
|
884
|
+
|
|
885
|
+
const parsed = Linking.parse(url);
|
|
886
|
+
|
|
887
|
+
// Route patterns:
|
|
888
|
+
// elsabro://session/abc123
|
|
889
|
+
// https://elsabro.io/session/abc123
|
|
890
|
+
// elsabro://session/abc123/approve?taskId=xyz
|
|
891
|
+
|
|
892
|
+
if (parsed.path?.startsWith('session/')) {
|
|
893
|
+
const sessionId = parsed.path.split('/')[1];
|
|
894
|
+
|
|
895
|
+
if (!isAuthenticated) {
|
|
896
|
+
// Store for after auth
|
|
897
|
+
useAuthStore.getState().setPendingDeepLink(url);
|
|
898
|
+
router.push('/auth/login');
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (parsed.queryParams?.action === 'approve') {
|
|
903
|
+
router.push({
|
|
904
|
+
pathname: `/session/${sessionId}`,
|
|
905
|
+
params: {
|
|
906
|
+
action: 'approve',
|
|
907
|
+
taskId: parsed.queryParams.taskId as string,
|
|
908
|
+
},
|
|
909
|
+
});
|
|
910
|
+
} else {
|
|
911
|
+
router.push(`/session/${sessionId}`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return { handleUrl };
|
|
917
|
+
}
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
### Device Handoff
|
|
921
|
+
|
|
922
|
+
```typescript
|
|
923
|
+
// src/services/handoff.ts
|
|
924
|
+
import * as Linking from 'expo-linking';
|
|
925
|
+
import { Platform } from 'react-native';
|
|
926
|
+
|
|
927
|
+
interface HandoffState {
|
|
928
|
+
sessionId: string;
|
|
929
|
+
scrollPosition?: number;
|
|
930
|
+
selectedTab?: string;
|
|
931
|
+
timestamp: number;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
export function generateHandoffUrl(state: HandoffState): string {
|
|
935
|
+
const params = new URLSearchParams({
|
|
936
|
+
session: state.sessionId,
|
|
937
|
+
...(state.scrollPosition && { scroll: state.scrollPosition.toString() }),
|
|
938
|
+
...(state.selectedTab && { tab: state.selectedTab }),
|
|
939
|
+
ts: state.timestamp.toString(),
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
return `https://elsabro.io/handoff?${params.toString()}`;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export function generateQRHandoff(state: HandoffState): string {
|
|
946
|
+
// Returns URL that can be encoded as QR code
|
|
947
|
+
return `elsabro://handoff/${state.sessionId}?` +
|
|
948
|
+
`data=${encodeURIComponent(JSON.stringify(state))}`;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// iOS Handoff with NSUserActivity (native module required)
|
|
952
|
+
export async function startHandoffActivity(state: HandoffState): Promise<void> {
|
|
953
|
+
if (Platform.OS !== 'ios') return;
|
|
954
|
+
|
|
955
|
+
// Requires native module for NSUserActivity
|
|
956
|
+
const { HandoffModule } = require('@/native/HandoffModule');
|
|
957
|
+
|
|
958
|
+
await HandoffModule.startActivity({
|
|
959
|
+
activityType: 'io.elsabro.session.view',
|
|
960
|
+
title: `ELSABRO Session`,
|
|
961
|
+
userInfo: state,
|
|
962
|
+
webpageURL: generateHandoffUrl(state),
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
---
|
|
968
|
+
|
|
969
|
+
## 3. PushNotificationManager
|
|
970
|
+
|
|
971
|
+
### Notification Flow Architecture
|
|
972
|
+
|
|
973
|
+
```
|
|
974
|
+
+------------------+ +------------------+ +------------------+
|
|
975
|
+
| ELSABRO Server |---->| FCM / APNS |---->| Mobile App |
|
|
976
|
+
+------------------+ +------------------+ +------------------+
|
|
977
|
+
| | |
|
|
978
|
+
| 1. Event trigger | 2. Push delivery | 3. Handle
|
|
979
|
+
| (task complete, | (data + display) | notification
|
|
980
|
+
| build failed, | |
|
|
981
|
+
| approval needed) | |
|
|
982
|
+
+------------------------+------------------------+
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
### Notification Types
|
|
986
|
+
|
|
987
|
+
| Type | Priority | Display | Actions |
|
|
988
|
+
|------|----------|---------|---------|
|
|
989
|
+
| `task_completed` | Normal | Alert | View, Archive |
|
|
990
|
+
| `build_failed` | High | Alert + Sound | View Logs, Retry |
|
|
991
|
+
| `approval_needed` | Critical | Alert + Badge | Approve, Reject, Details |
|
|
992
|
+
| `session_update` | Low | Silent | - |
|
|
993
|
+
| `agent_error` | High | Alert | View, Restart Agent |
|
|
994
|
+
| `sync_reminder` | Normal | Alert | Sync Now, Later |
|
|
995
|
+
|
|
996
|
+
### Notification Service Implementation
|
|
997
|
+
|
|
998
|
+
```typescript
|
|
999
|
+
// src/services/notifications.ts
|
|
1000
|
+
import * as Notifications from 'expo-notifications';
|
|
1001
|
+
import * as Device from 'expo-device';
|
|
1002
|
+
import { Platform } from 'react-native';
|
|
1003
|
+
import Constants from 'expo-constants';
|
|
1004
|
+
import { router } from 'expo-router';
|
|
1005
|
+
import { useSessionStore } from '@/stores/sessionStore';
|
|
1006
|
+
|
|
1007
|
+
// Configure notification handling
|
|
1008
|
+
Notifications.setNotificationHandler({
|
|
1009
|
+
handleNotification: async (notification) => {
|
|
1010
|
+
const data = notification.request.content.data;
|
|
1011
|
+
|
|
1012
|
+
// Silent push for background sync
|
|
1013
|
+
if (data.silent) {
|
|
1014
|
+
await handleSilentPush(data);
|
|
1015
|
+
return {
|
|
1016
|
+
shouldShowAlert: false,
|
|
1017
|
+
shouldPlaySound: false,
|
|
1018
|
+
shouldSetBadge: false,
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
return {
|
|
1023
|
+
shouldShowAlert: true,
|
|
1024
|
+
shouldPlaySound: data.priority === 'high' || data.priority === 'critical',
|
|
1025
|
+
shouldSetBadge: data.type === 'approval_needed',
|
|
1026
|
+
};
|
|
1027
|
+
},
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
interface NotificationData {
|
|
1031
|
+
type: 'task_completed' | 'build_failed' | 'approval_needed' |
|
|
1032
|
+
'session_update' | 'agent_error' | 'sync_reminder';
|
|
1033
|
+
sessionId: string;
|
|
1034
|
+
taskId?: string;
|
|
1035
|
+
agentId?: string;
|
|
1036
|
+
priority: 'low' | 'normal' | 'high' | 'critical';
|
|
1037
|
+
silent?: boolean;
|
|
1038
|
+
payload?: Record<string, unknown>;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
async function handleSilentPush(data: NotificationData): Promise<void> {
|
|
1042
|
+
switch (data.type) {
|
|
1043
|
+
case 'session_update':
|
|
1044
|
+
// Trigger background sync
|
|
1045
|
+
const { syncSessions } = useSessionStore.getState();
|
|
1046
|
+
try {
|
|
1047
|
+
const response = await fetch(`${API_URL}/sessions/sync`, {
|
|
1048
|
+
method: 'POST',
|
|
1049
|
+
headers: getAuthHeaders(),
|
|
1050
|
+
});
|
|
1051
|
+
const sessions = await response.json();
|
|
1052
|
+
syncSessions(sessions);
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
console.error('Background sync failed:', error);
|
|
1055
|
+
}
|
|
1056
|
+
break;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
export async function registerForPushNotifications(): Promise<string | null> {
|
|
1061
|
+
if (!Device.isDevice) {
|
|
1062
|
+
console.log('Push notifications require a physical device');
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// Check permissions
|
|
1067
|
+
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
|
1068
|
+
let finalStatus = existingStatus;
|
|
1069
|
+
|
|
1070
|
+
if (existingStatus !== 'granted') {
|
|
1071
|
+
const { status } = await Notifications.requestPermissionsAsync({
|
|
1072
|
+
ios: {
|
|
1073
|
+
allowAlert: true,
|
|
1074
|
+
allowBadge: true,
|
|
1075
|
+
allowSound: true,
|
|
1076
|
+
allowCriticalAlerts: true, // For approval_needed
|
|
1077
|
+
},
|
|
1078
|
+
});
|
|
1079
|
+
finalStatus = status;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (finalStatus !== 'granted') {
|
|
1083
|
+
console.log('Push notification permission denied');
|
|
1084
|
+
return null;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Get push token
|
|
1088
|
+
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
|
|
1089
|
+
|
|
1090
|
+
const tokenData = await Notifications.getExpoPushTokenAsync({
|
|
1091
|
+
projectId,
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
// Platform-specific setup
|
|
1095
|
+
if (Platform.OS === 'android') {
|
|
1096
|
+
await setupAndroidChannels();
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Register token with backend
|
|
1100
|
+
await registerTokenWithBackend(tokenData.data);
|
|
1101
|
+
|
|
1102
|
+
return tokenData.data;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
async function setupAndroidChannels(): Promise<void> {
|
|
1106
|
+
// Critical alerts channel
|
|
1107
|
+
await Notifications.setNotificationChannelAsync('critical', {
|
|
1108
|
+
name: 'Critical Alerts',
|
|
1109
|
+
importance: Notifications.AndroidImportance.MAX,
|
|
1110
|
+
vibrationPattern: [0, 250, 250, 250],
|
|
1111
|
+
lightColor: '#FF0000',
|
|
1112
|
+
sound: 'critical.wav',
|
|
1113
|
+
bypassDnd: true,
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
// Build status channel
|
|
1117
|
+
await Notifications.setNotificationChannelAsync('builds', {
|
|
1118
|
+
name: 'Build Status',
|
|
1119
|
+
importance: Notifications.AndroidImportance.HIGH,
|
|
1120
|
+
sound: 'build.wav',
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
// General updates channel
|
|
1124
|
+
await Notifications.setNotificationChannelAsync('updates', {
|
|
1125
|
+
name: 'Session Updates',
|
|
1126
|
+
importance: Notifications.AndroidImportance.DEFAULT,
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
// Silent sync channel
|
|
1130
|
+
await Notifications.setNotificationChannelAsync('sync', {
|
|
1131
|
+
name: 'Background Sync',
|
|
1132
|
+
importance: Notifications.AndroidImportance.MIN,
|
|
1133
|
+
showBadge: false,
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
async function registerTokenWithBackend(token: string): Promise<void> {
|
|
1138
|
+
await fetch(`${API_URL}/devices/register`, {
|
|
1139
|
+
method: 'POST',
|
|
1140
|
+
headers: {
|
|
1141
|
+
'Content-Type': 'application/json',
|
|
1142
|
+
...getAuthHeaders(),
|
|
1143
|
+
},
|
|
1144
|
+
body: JSON.stringify({
|
|
1145
|
+
token,
|
|
1146
|
+
platform: Platform.OS,
|
|
1147
|
+
deviceId: Device.modelId,
|
|
1148
|
+
}),
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Notification response handler
|
|
1153
|
+
export function setupNotificationListeners(): () => void {
|
|
1154
|
+
// Handle notification when app is foregrounded
|
|
1155
|
+
const foregroundSubscription = Notifications.addNotificationReceivedListener(
|
|
1156
|
+
(notification) => {
|
|
1157
|
+
console.log('Notification received:', notification);
|
|
1158
|
+
}
|
|
1159
|
+
);
|
|
1160
|
+
|
|
1161
|
+
// Handle notification tap
|
|
1162
|
+
const responseSubscription = Notifications.addNotificationResponseReceivedListener(
|
|
1163
|
+
(response) => {
|
|
1164
|
+
const data = response.notification.request.content.data as NotificationData;
|
|
1165
|
+
const actionId = response.actionIdentifier;
|
|
1166
|
+
|
|
1167
|
+
handleNotificationAction(data, actionId);
|
|
1168
|
+
}
|
|
1169
|
+
);
|
|
1170
|
+
|
|
1171
|
+
return () => {
|
|
1172
|
+
foregroundSubscription.remove();
|
|
1173
|
+
responseSubscription.remove();
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function handleNotificationAction(data: NotificationData, actionId: string): void {
|
|
1178
|
+
switch (data.type) {
|
|
1179
|
+
case 'approval_needed':
|
|
1180
|
+
if (actionId === 'approve') {
|
|
1181
|
+
// Quick approve without opening app fully
|
|
1182
|
+
quickApprove(data.sessionId, data.taskId!);
|
|
1183
|
+
} else if (actionId === 'reject') {
|
|
1184
|
+
quickReject(data.sessionId, data.taskId!);
|
|
1185
|
+
} else {
|
|
1186
|
+
router.push({
|
|
1187
|
+
pathname: `/session/${data.sessionId}`,
|
|
1188
|
+
params: { action: 'approve', taskId: data.taskId },
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
break;
|
|
1192
|
+
|
|
1193
|
+
case 'build_failed':
|
|
1194
|
+
if (actionId === 'retry') {
|
|
1195
|
+
retryBuild(data.sessionId);
|
|
1196
|
+
} else {
|
|
1197
|
+
router.push({
|
|
1198
|
+
pathname: `/session/${data.sessionId}`,
|
|
1199
|
+
params: { tab: 'logs' },
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
break;
|
|
1203
|
+
|
|
1204
|
+
default:
|
|
1205
|
+
router.push(`/session/${data.sessionId}`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Set up notification categories with actions
|
|
1210
|
+
export async function setupNotificationCategories(): Promise<void> {
|
|
1211
|
+
await Notifications.setNotificationCategoryAsync('approval', [
|
|
1212
|
+
{
|
|
1213
|
+
identifier: 'approve',
|
|
1214
|
+
buttonTitle: 'Approve',
|
|
1215
|
+
options: {
|
|
1216
|
+
isDestructive: false,
|
|
1217
|
+
isAuthenticationRequired: true,
|
|
1218
|
+
},
|
|
1219
|
+
},
|
|
1220
|
+
{
|
|
1221
|
+
identifier: 'reject',
|
|
1222
|
+
buttonTitle: 'Reject',
|
|
1223
|
+
options: {
|
|
1224
|
+
isDestructive: true,
|
|
1225
|
+
isAuthenticationRequired: true,
|
|
1226
|
+
},
|
|
1227
|
+
},
|
|
1228
|
+
{
|
|
1229
|
+
identifier: 'details',
|
|
1230
|
+
buttonTitle: 'View Details',
|
|
1231
|
+
options: {
|
|
1232
|
+
opensAppToForeground: true,
|
|
1233
|
+
},
|
|
1234
|
+
},
|
|
1235
|
+
]);
|
|
1236
|
+
|
|
1237
|
+
await Notifications.setNotificationCategoryAsync('build', [
|
|
1238
|
+
{
|
|
1239
|
+
identifier: 'view_logs',
|
|
1240
|
+
buttonTitle: 'View Logs',
|
|
1241
|
+
options: {
|
|
1242
|
+
opensAppToForeground: true,
|
|
1243
|
+
},
|
|
1244
|
+
},
|
|
1245
|
+
{
|
|
1246
|
+
identifier: 'retry',
|
|
1247
|
+
buttonTitle: 'Retry Build',
|
|
1248
|
+
options: {
|
|
1249
|
+
isDestructive: false,
|
|
1250
|
+
},
|
|
1251
|
+
},
|
|
1252
|
+
]);
|
|
1253
|
+
}
|
|
1254
|
+
```
|
|
1255
|
+
|
|
1256
|
+
### Rich Notification with Image
|
|
1257
|
+
|
|
1258
|
+
```typescript
|
|
1259
|
+
// Server-side notification payload example
|
|
1260
|
+
const richNotification = {
|
|
1261
|
+
to: expoPushToken,
|
|
1262
|
+
title: 'Build Failed',
|
|
1263
|
+
body: 'Project "elsabro-api" build #142 failed at test stage',
|
|
1264
|
+
data: {
|
|
1265
|
+
type: 'build_failed',
|
|
1266
|
+
sessionId: 'sess_abc123',
|
|
1267
|
+
priority: 'high',
|
|
1268
|
+
},
|
|
1269
|
+
categoryId: 'build',
|
|
1270
|
+
// Rich media attachment
|
|
1271
|
+
attachments: [
|
|
1272
|
+
{
|
|
1273
|
+
url: 'https://elsabro.io/builds/142/screenshot.png',
|
|
1274
|
+
identifier: 'build-screenshot',
|
|
1275
|
+
},
|
|
1276
|
+
],
|
|
1277
|
+
// Android specific
|
|
1278
|
+
channelId: 'builds',
|
|
1279
|
+
// iOS specific
|
|
1280
|
+
sound: 'build_failed.wav',
|
|
1281
|
+
badge: 1,
|
|
1282
|
+
};
|
|
1283
|
+
```
|
|
1284
|
+
|
|
1285
|
+
---
|
|
1286
|
+
|
|
1287
|
+
## 4. VoiceInputHandler
|
|
1288
|
+
|
|
1289
|
+
### Voice Command Architecture
|
|
1290
|
+
|
|
1291
|
+
```
|
|
1292
|
+
+------------------+ +------------------+ +------------------+
|
|
1293
|
+
| Microphone |---->| STT Engine |---->| Command Parser |
|
|
1294
|
+
| Input | | (@react-native- | | |
|
|
1295
|
+
| | | voice/voice) | | |
|
|
1296
|
+
+------------------+ +------------------+ +------------------+
|
|
1297
|
+
|
|
|
1298
|
+
v
|
|
1299
|
+
+------------------+ +------------------+ +------------------+
|
|
1300
|
+
| Haptic |<----| TTS Engine |<----| Action |
|
|
1301
|
+
| Feedback | | (expo-speech) | | Executor |
|
|
1302
|
+
+------------------+ +------------------+ +------------------+
|
|
1303
|
+
```
|
|
1304
|
+
|
|
1305
|
+
### Supported Voice Commands
|
|
1306
|
+
|
|
1307
|
+
| Command Pattern | Action | Example |
|
|
1308
|
+
|-----------------|--------|---------|
|
|
1309
|
+
| "Start session [name]" | Create new session | "Start session API refactor" |
|
|
1310
|
+
| "Pause session" | Pause active session | "Pause session" |
|
|
1311
|
+
| "Resume session" | Resume paused session | "Resume session" |
|
|
1312
|
+
| "Status" | Get current status | "Status" |
|
|
1313
|
+
| "Approve [task]" | Approve pending task | "Approve deployment" |
|
|
1314
|
+
| "Reject [task]" | Reject pending task | "Reject with feedback" |
|
|
1315
|
+
| "Show logs" | Display session logs | "Show logs" |
|
|
1316
|
+
| "Switch to [session]" | Change active session | "Switch to backend work" |
|
|
1317
|
+
| "Stop listening" | Deactivate voice input | "Stop listening" |
|
|
1318
|
+
|
|
1319
|
+
### Voice Handler Implementation
|
|
1320
|
+
|
|
1321
|
+
```typescript
|
|
1322
|
+
// src/hooks/useVoice.ts
|
|
1323
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
1324
|
+
import Voice, {
|
|
1325
|
+
SpeechResultsEvent,
|
|
1326
|
+
SpeechErrorEvent,
|
|
1327
|
+
SpeechStartEvent,
|
|
1328
|
+
} from '@react-native-voice/voice';
|
|
1329
|
+
import * as Speech from 'expo-speech';
|
|
1330
|
+
import * as Haptics from 'expo-haptics';
|
|
1331
|
+
import { useSessionStore } from '@/stores/sessionStore';
|
|
1332
|
+
import { useSendCommand, usePauseSession } from '@/services/api';
|
|
1333
|
+
|
|
1334
|
+
interface VoiceState {
|
|
1335
|
+
isListening: boolean;
|
|
1336
|
+
transcript: string;
|
|
1337
|
+
partialTranscript: string;
|
|
1338
|
+
error: string | null;
|
|
1339
|
+
isProcessing: boolean;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
interface VoiceCommand {
|
|
1343
|
+
pattern: RegExp;
|
|
1344
|
+
action: (matches: RegExpMatchArray) => Promise<void>;
|
|
1345
|
+
feedback: string;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
export function useVoice() {
|
|
1349
|
+
const [state, setState] = useState<VoiceState>({
|
|
1350
|
+
isListening: false,
|
|
1351
|
+
transcript: '',
|
|
1352
|
+
partialTranscript: '',
|
|
1353
|
+
error: null,
|
|
1354
|
+
isProcessing: false,
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
const { activeSessionId, sessions } = useSessionStore();
|
|
1358
|
+
const sendCommand = useSendCommand();
|
|
1359
|
+
const pauseSession = usePauseSession();
|
|
1360
|
+
|
|
1361
|
+
const commandTimeoutRef = useRef<NodeJS.Timeout>();
|
|
1362
|
+
|
|
1363
|
+
// Voice command definitions
|
|
1364
|
+
const commands: VoiceCommand[] = [
|
|
1365
|
+
{
|
|
1366
|
+
pattern: /^(start|create|new) session (.+)$/i,
|
|
1367
|
+
action: async (matches) => {
|
|
1368
|
+
const sessionName = matches[2];
|
|
1369
|
+
await createSession(sessionName);
|
|
1370
|
+
},
|
|
1371
|
+
feedback: 'Creating new session',
|
|
1372
|
+
},
|
|
1373
|
+
{
|
|
1374
|
+
pattern: /^pause( session)?$/i,
|
|
1375
|
+
action: async () => {
|
|
1376
|
+
if (activeSessionId) {
|
|
1377
|
+
await pauseSession.mutateAsync(activeSessionId);
|
|
1378
|
+
}
|
|
1379
|
+
},
|
|
1380
|
+
feedback: 'Pausing session',
|
|
1381
|
+
},
|
|
1382
|
+
{
|
|
1383
|
+
pattern: /^resume( session)?$/i,
|
|
1384
|
+
action: async () => {
|
|
1385
|
+
if (activeSessionId) {
|
|
1386
|
+
await sendCommand.mutateAsync({
|
|
1387
|
+
sessionId: activeSessionId,
|
|
1388
|
+
command: '/elsabro:session resume',
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
},
|
|
1392
|
+
feedback: 'Resuming session',
|
|
1393
|
+
},
|
|
1394
|
+
{
|
|
1395
|
+
pattern: /^status$/i,
|
|
1396
|
+
action: async () => {
|
|
1397
|
+
const session = activeSessionId ? sessions[activeSessionId] : null;
|
|
1398
|
+
if (session) {
|
|
1399
|
+
await speak(
|
|
1400
|
+
`Session ${session.name} is ${session.status}. ` +
|
|
1401
|
+
`Progress: ${session.progress}%. ` +
|
|
1402
|
+
`Current task: ${session.currentTask || 'none'}.`
|
|
1403
|
+
);
|
|
1404
|
+
} else {
|
|
1405
|
+
await speak('No active session selected.');
|
|
1406
|
+
}
|
|
1407
|
+
},
|
|
1408
|
+
feedback: '',
|
|
1409
|
+
},
|
|
1410
|
+
{
|
|
1411
|
+
pattern: /^approve( task)?( (.+))?$/i,
|
|
1412
|
+
action: async (matches) => {
|
|
1413
|
+
const taskName = matches[3];
|
|
1414
|
+
if (activeSessionId) {
|
|
1415
|
+
await sendCommand.mutateAsync({
|
|
1416
|
+
sessionId: activeSessionId,
|
|
1417
|
+
command: `/elsabro:approve${taskName ? ` ${taskName}` : ''}`,
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
},
|
|
1421
|
+
feedback: 'Approving task',
|
|
1422
|
+
},
|
|
1423
|
+
{
|
|
1424
|
+
pattern: /^reject( task)?( with (.+))?$/i,
|
|
1425
|
+
action: async (matches) => {
|
|
1426
|
+
const reason = matches[3] || 'Rejected via voice';
|
|
1427
|
+
if (activeSessionId) {
|
|
1428
|
+
await sendCommand.mutateAsync({
|
|
1429
|
+
sessionId: activeSessionId,
|
|
1430
|
+
command: `/elsabro:reject --reason "${reason}"`,
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
},
|
|
1434
|
+
feedback: 'Rejecting task',
|
|
1435
|
+
},
|
|
1436
|
+
{
|
|
1437
|
+
pattern: /^switch to (.+)$/i,
|
|
1438
|
+
action: async (matches) => {
|
|
1439
|
+
const sessionName = matches[1].toLowerCase();
|
|
1440
|
+
const targetSession = Object.values(sessions).find(
|
|
1441
|
+
s => s.name.toLowerCase().includes(sessionName)
|
|
1442
|
+
);
|
|
1443
|
+
if (targetSession) {
|
|
1444
|
+
useSessionStore.getState().setActiveSession(targetSession.id);
|
|
1445
|
+
await speak(`Switched to session: ${targetSession.name}`);
|
|
1446
|
+
} else {
|
|
1447
|
+
await speak(`Session "${sessionName}" not found.`);
|
|
1448
|
+
}
|
|
1449
|
+
},
|
|
1450
|
+
feedback: '',
|
|
1451
|
+
},
|
|
1452
|
+
{
|
|
1453
|
+
pattern: /^stop listening$/i,
|
|
1454
|
+
action: async () => {
|
|
1455
|
+
await stopListening();
|
|
1456
|
+
await speak('Voice input deactivated.');
|
|
1457
|
+
},
|
|
1458
|
+
feedback: '',
|
|
1459
|
+
},
|
|
1460
|
+
];
|
|
1461
|
+
|
|
1462
|
+
useEffect(() => {
|
|
1463
|
+
Voice.onSpeechStart = handleSpeechStart;
|
|
1464
|
+
Voice.onSpeechEnd = handleSpeechEnd;
|
|
1465
|
+
Voice.onSpeechResults = handleSpeechResults;
|
|
1466
|
+
Voice.onSpeechPartialResults = handlePartialResults;
|
|
1467
|
+
Voice.onSpeechError = handleSpeechError;
|
|
1468
|
+
|
|
1469
|
+
return () => {
|
|
1470
|
+
Voice.destroy().then(Voice.removeAllListeners);
|
|
1471
|
+
};
|
|
1472
|
+
}, []);
|
|
1473
|
+
|
|
1474
|
+
const handleSpeechStart = (e: SpeechStartEvent) => {
|
|
1475
|
+
setState(prev => ({ ...prev, isListening: true, error: null }));
|
|
1476
|
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
1477
|
+
};
|
|
1478
|
+
|
|
1479
|
+
const handleSpeechEnd = () => {
|
|
1480
|
+
setState(prev => ({ ...prev, isListening: false }));
|
|
1481
|
+
};
|
|
1482
|
+
|
|
1483
|
+
const handleSpeechResults = async (e: SpeechResultsEvent) => {
|
|
1484
|
+
const transcript = e.value?.[0] || '';
|
|
1485
|
+
setState(prev => ({
|
|
1486
|
+
...prev,
|
|
1487
|
+
transcript,
|
|
1488
|
+
partialTranscript: '',
|
|
1489
|
+
}));
|
|
1490
|
+
|
|
1491
|
+
if (transcript) {
|
|
1492
|
+
await processCommand(transcript);
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
|
|
1496
|
+
const handlePartialResults = (e: SpeechResultsEvent) => {
|
|
1497
|
+
const partial = e.value?.[0] || '';
|
|
1498
|
+
setState(prev => ({ ...prev, partialTranscript: partial }));
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1501
|
+
const handleSpeechError = (e: SpeechErrorEvent) => {
|
|
1502
|
+
setState(prev => ({
|
|
1503
|
+
...prev,
|
|
1504
|
+
error: e.error?.message || 'Speech recognition error',
|
|
1505
|
+
isListening: false,
|
|
1506
|
+
}));
|
|
1507
|
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
const processCommand = async (transcript: string) => {
|
|
1511
|
+
setState(prev => ({ ...prev, isProcessing: true }));
|
|
1512
|
+
|
|
1513
|
+
try {
|
|
1514
|
+
for (const command of commands) {
|
|
1515
|
+
const matches = transcript.match(command.pattern);
|
|
1516
|
+
if (matches) {
|
|
1517
|
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
1518
|
+
|
|
1519
|
+
if (command.feedback) {
|
|
1520
|
+
await speak(command.feedback);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
await command.action(matches);
|
|
1524
|
+
|
|
1525
|
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// No command matched - treat as general input
|
|
1531
|
+
if (activeSessionId) {
|
|
1532
|
+
await sendCommand.mutateAsync({
|
|
1533
|
+
sessionId: activeSessionId,
|
|
1534
|
+
command: transcript,
|
|
1535
|
+
});
|
|
1536
|
+
await speak('Command sent.');
|
|
1537
|
+
} else {
|
|
1538
|
+
await speak('Command not recognized. Please try again.');
|
|
1539
|
+
}
|
|
1540
|
+
} catch (error) {
|
|
1541
|
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
1542
|
+
await speak('Error processing command. Please try again.');
|
|
1543
|
+
} finally {
|
|
1544
|
+
setState(prev => ({ ...prev, isProcessing: false }));
|
|
1545
|
+
}
|
|
1546
|
+
};
|
|
1547
|
+
|
|
1548
|
+
const speak = async (text: string): Promise<void> => {
|
|
1549
|
+
return new Promise((resolve) => {
|
|
1550
|
+
Speech.speak(text, {
|
|
1551
|
+
language: 'en-US',
|
|
1552
|
+
pitch: 1.0,
|
|
1553
|
+
rate: 0.9,
|
|
1554
|
+
onDone: resolve,
|
|
1555
|
+
onError: () => resolve(),
|
|
1556
|
+
});
|
|
1557
|
+
});
|
|
1558
|
+
};
|
|
1559
|
+
|
|
1560
|
+
const startListening = useCallback(async () => {
|
|
1561
|
+
try {
|
|
1562
|
+
setState(prev => ({ ...prev, error: null, transcript: '' }));
|
|
1563
|
+
|
|
1564
|
+
await Voice.start('en-US', {
|
|
1565
|
+
EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS: 1500,
|
|
1566
|
+
EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS: 500,
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
1570
|
+
} catch (error) {
|
|
1571
|
+
setState(prev => ({
|
|
1572
|
+
...prev,
|
|
1573
|
+
error: 'Failed to start voice recognition',
|
|
1574
|
+
}));
|
|
1575
|
+
}
|
|
1576
|
+
}, []);
|
|
1577
|
+
|
|
1578
|
+
const stopListening = useCallback(async () => {
|
|
1579
|
+
try {
|
|
1580
|
+
await Voice.stop();
|
|
1581
|
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
1582
|
+
} catch (error) {
|
|
1583
|
+
console.error('Failed to stop voice recognition:', error);
|
|
1584
|
+
}
|
|
1585
|
+
}, []);
|
|
1586
|
+
|
|
1587
|
+
const cancelListening = useCallback(async () => {
|
|
1588
|
+
try {
|
|
1589
|
+
await Voice.cancel();
|
|
1590
|
+
setState(prev => ({
|
|
1591
|
+
...prev,
|
|
1592
|
+
isListening: false,
|
|
1593
|
+
partialTranscript: '',
|
|
1594
|
+
}));
|
|
1595
|
+
} catch (error) {
|
|
1596
|
+
console.error('Failed to cancel voice recognition:', error);
|
|
1597
|
+
}
|
|
1598
|
+
}, []);
|
|
1599
|
+
|
|
1600
|
+
return {
|
|
1601
|
+
...state,
|
|
1602
|
+
startListening,
|
|
1603
|
+
stopListening,
|
|
1604
|
+
cancelListening,
|
|
1605
|
+
speak,
|
|
1606
|
+
isAvailable: Voice.isAvailable,
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
```
|
|
1610
|
+
|
|
1611
|
+
### Voice Input UI Component
|
|
1612
|
+
|
|
1613
|
+
```typescript
|
|
1614
|
+
// src/components/VoiceInput.tsx
|
|
1615
|
+
import React from 'react';
|
|
1616
|
+
import { View, Text, Pressable, StyleSheet } from 'react-native';
|
|
1617
|
+
import Animated, {
|
|
1618
|
+
useAnimatedStyle,
|
|
1619
|
+
withRepeat,
|
|
1620
|
+
withTiming,
|
|
1621
|
+
useSharedValue,
|
|
1622
|
+
withSequence,
|
|
1623
|
+
} from 'react-native-reanimated';
|
|
1624
|
+
import { Mic, MicOff, Loader } from 'lucide-react-native';
|
|
1625
|
+
import { useVoice } from '@/hooks/useVoice';
|
|
1626
|
+
|
|
1627
|
+
export function VoiceInput() {
|
|
1628
|
+
const {
|
|
1629
|
+
isListening,
|
|
1630
|
+
partialTranscript,
|
|
1631
|
+
transcript,
|
|
1632
|
+
isProcessing,
|
|
1633
|
+
error,
|
|
1634
|
+
startListening,
|
|
1635
|
+
stopListening,
|
|
1636
|
+
} = useVoice();
|
|
1637
|
+
|
|
1638
|
+
const pulseScale = useSharedValue(1);
|
|
1639
|
+
|
|
1640
|
+
React.useEffect(() => {
|
|
1641
|
+
if (isListening) {
|
|
1642
|
+
pulseScale.value = withRepeat(
|
|
1643
|
+
withSequence(
|
|
1644
|
+
withTiming(1.2, { duration: 500 }),
|
|
1645
|
+
withTiming(1, { duration: 500 })
|
|
1646
|
+
),
|
|
1647
|
+
-1,
|
|
1648
|
+
true
|
|
1649
|
+
);
|
|
1650
|
+
} else {
|
|
1651
|
+
pulseScale.value = withTiming(1, { duration: 200 });
|
|
1652
|
+
}
|
|
1653
|
+
}, [isListening]);
|
|
1654
|
+
|
|
1655
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
1656
|
+
transform: [{ scale: pulseScale.value }],
|
|
1657
|
+
}));
|
|
1658
|
+
|
|
1659
|
+
return (
|
|
1660
|
+
<View style={styles.container}>
|
|
1661
|
+
{(partialTranscript || transcript) && (
|
|
1662
|
+
<View style={styles.transcriptContainer}>
|
|
1663
|
+
<Text style={styles.transcript}>
|
|
1664
|
+
{partialTranscript || transcript}
|
|
1665
|
+
</Text>
|
|
1666
|
+
</View>
|
|
1667
|
+
)}
|
|
1668
|
+
|
|
1669
|
+
{error && (
|
|
1670
|
+
<Text style={styles.error}>{error}</Text>
|
|
1671
|
+
)}
|
|
1672
|
+
|
|
1673
|
+
<Animated.View style={[styles.buttonWrapper, animatedStyle]}>
|
|
1674
|
+
<Pressable
|
|
1675
|
+
style={[
|
|
1676
|
+
styles.button,
|
|
1677
|
+
isListening && styles.buttonActive,
|
|
1678
|
+
isProcessing && styles.buttonProcessing,
|
|
1679
|
+
]}
|
|
1680
|
+
onPress={isListening ? stopListening : startListening}
|
|
1681
|
+
disabled={isProcessing}
|
|
1682
|
+
>
|
|
1683
|
+
{isProcessing ? (
|
|
1684
|
+
<Loader size={32} color="#fff" />
|
|
1685
|
+
) : isListening ? (
|
|
1686
|
+
<MicOff size={32} color="#fff" />
|
|
1687
|
+
) : (
|
|
1688
|
+
<Mic size={32} color="#fff" />
|
|
1689
|
+
)}
|
|
1690
|
+
</Pressable>
|
|
1691
|
+
</Animated.View>
|
|
1692
|
+
|
|
1693
|
+
<Text style={styles.hint}>
|
|
1694
|
+
{isListening
|
|
1695
|
+
? 'Listening... Tap to stop'
|
|
1696
|
+
: isProcessing
|
|
1697
|
+
? 'Processing...'
|
|
1698
|
+
: 'Tap to speak'}
|
|
1699
|
+
</Text>
|
|
1700
|
+
</View>
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
const styles = StyleSheet.create({
|
|
1705
|
+
container: {
|
|
1706
|
+
alignItems: 'center',
|
|
1707
|
+
padding: 20,
|
|
1708
|
+
},
|
|
1709
|
+
transcriptContainer: {
|
|
1710
|
+
backgroundColor: '#f0f0f0',
|
|
1711
|
+
borderRadius: 12,
|
|
1712
|
+
padding: 16,
|
|
1713
|
+
marginBottom: 20,
|
|
1714
|
+
width: '100%',
|
|
1715
|
+
},
|
|
1716
|
+
transcript: {
|
|
1717
|
+
fontSize: 16,
|
|
1718
|
+
color: '#333',
|
|
1719
|
+
textAlign: 'center',
|
|
1720
|
+
},
|
|
1721
|
+
error: {
|
|
1722
|
+
color: '#e53935',
|
|
1723
|
+
marginBottom: 10,
|
|
1724
|
+
},
|
|
1725
|
+
buttonWrapper: {
|
|
1726
|
+
marginVertical: 20,
|
|
1727
|
+
},
|
|
1728
|
+
button: {
|
|
1729
|
+
width: 80,
|
|
1730
|
+
height: 80,
|
|
1731
|
+
borderRadius: 40,
|
|
1732
|
+
backgroundColor: '#6366f1',
|
|
1733
|
+
alignItems: 'center',
|
|
1734
|
+
justifyContent: 'center',
|
|
1735
|
+
shadowColor: '#000',
|
|
1736
|
+
shadowOffset: { width: 0, height: 4 },
|
|
1737
|
+
shadowOpacity: 0.3,
|
|
1738
|
+
shadowRadius: 8,
|
|
1739
|
+
elevation: 8,
|
|
1740
|
+
},
|
|
1741
|
+
buttonActive: {
|
|
1742
|
+
backgroundColor: '#ef4444',
|
|
1743
|
+
},
|
|
1744
|
+
buttonProcessing: {
|
|
1745
|
+
backgroundColor: '#9ca3af',
|
|
1746
|
+
},
|
|
1747
|
+
hint: {
|
|
1748
|
+
color: '#666',
|
|
1749
|
+
fontSize: 14,
|
|
1750
|
+
},
|
|
1751
|
+
});
|
|
1752
|
+
```
|
|
1753
|
+
|
|
1754
|
+
---
|
|
1755
|
+
|
|
1756
|
+
## 5. OfflineMode
|
|
1757
|
+
|
|
1758
|
+
### Offline Architecture
|
|
1759
|
+
|
|
1760
|
+
```
|
|
1761
|
+
+------------------------------------------------------------------+
|
|
1762
|
+
| Offline Mode System |
|
|
1763
|
+
+------------------------------------------------------------------+
|
|
1764
|
+
| |
|
|
1765
|
+
| +------------------+ +------------------+ |
|
|
1766
|
+
| | Network | | Connectivity | |
|
|
1767
|
+
| | Monitor |---->| Store | |
|
|
1768
|
+
| +------------------+ +------------------+ |
|
|
1769
|
+
| | |
|
|
1770
|
+
| +----------------------+----------------------+ |
|
|
1771
|
+
| | | | |
|
|
1772
|
+
| v v v |
|
|
1773
|
+
| +------------------+ +------------------+ +------------------+|
|
|
1774
|
+
| | Action Queue | | Data Cache | | UI Indicators ||
|
|
1775
|
+
| | (pending ops) | | (AsyncStorage) | | (offline bar) ||
|
|
1776
|
+
| +------------------+ +------------------+ +------------------+|
|
|
1777
|
+
| | | |
|
|
1778
|
+
| v v |
|
|
1779
|
+
| +------------------+ +------------------+ |
|
|
1780
|
+
| | Sync Engine | | Cache Manager | |
|
|
1781
|
+
| | (on reconnect) | | (TTL, LRU) | |
|
|
1782
|
+
| +------------------+ +------------------+ |
|
|
1783
|
+
| |
|
|
1784
|
+
+------------------------------------------------------------------+
|
|
1785
|
+
```
|
|
1786
|
+
|
|
1787
|
+
### Offline Store Implementation
|
|
1788
|
+
|
|
1789
|
+
```typescript
|
|
1790
|
+
// src/stores/offlineStore.ts
|
|
1791
|
+
import { create } from 'zustand';
|
|
1792
|
+
import { persist, createJSONStorage } from 'zustand/middleware';
|
|
1793
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
1794
|
+
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
|
|
1795
|
+
import { immer } from 'zustand/middleware/immer';
|
|
1796
|
+
|
|
1797
|
+
interface PendingAction {
|
|
1798
|
+
id: string;
|
|
1799
|
+
type: 'command' | 'approval' | 'rejection' | 'websocket';
|
|
1800
|
+
data: unknown;
|
|
1801
|
+
createdAt: number;
|
|
1802
|
+
retryCount: number;
|
|
1803
|
+
maxRetries: number;
|
|
1804
|
+
sessionId?: string;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
interface CachedData {
|
|
1808
|
+
key: string;
|
|
1809
|
+
data: unknown;
|
|
1810
|
+
timestamp: number;
|
|
1811
|
+
ttl: number; // Time to live in ms
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
interface OfflineState {
|
|
1815
|
+
isOnline: boolean;
|
|
1816
|
+
isConnecting: boolean;
|
|
1817
|
+
pendingActions: PendingAction[];
|
|
1818
|
+
cache: Record<string, CachedData>;
|
|
1819
|
+
lastSyncTimestamp: number;
|
|
1820
|
+
syncInProgress: boolean;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
interface OfflineActions {
|
|
1824
|
+
setOnline: (online: boolean) => void;
|
|
1825
|
+
setConnecting: (connecting: boolean) => void;
|
|
1826
|
+
addPendingAction: (action: Omit<PendingAction, 'retryCount' | 'maxRetries'>) => void;
|
|
1827
|
+
removePendingAction: (id: string) => void;
|
|
1828
|
+
incrementRetry: (id: string) => void;
|
|
1829
|
+
setCache: (key: string, data: unknown, ttl?: number) => void;
|
|
1830
|
+
getCache: <T>(key: string) => T | null;
|
|
1831
|
+
clearExpiredCache: () => void;
|
|
1832
|
+
syncPendingActions: () => Promise<void>;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
const DEFAULT_TTL = 1000 * 60 * 30; // 30 minutes
|
|
1836
|
+
const MAX_CACHE_SIZE = 100;
|
|
1837
|
+
|
|
1838
|
+
export const useOfflineStore = create<OfflineState & OfflineActions>()(
|
|
1839
|
+
persist(
|
|
1840
|
+
immer((set, get) => ({
|
|
1841
|
+
// State
|
|
1842
|
+
isOnline: true,
|
|
1843
|
+
isConnecting: false,
|
|
1844
|
+
pendingActions: [],
|
|
1845
|
+
cache: {},
|
|
1846
|
+
lastSyncTimestamp: 0,
|
|
1847
|
+
syncInProgress: false,
|
|
1848
|
+
|
|
1849
|
+
// Actions
|
|
1850
|
+
setOnline: (online) =>
|
|
1851
|
+
set((state) => {
|
|
1852
|
+
state.isOnline = online;
|
|
1853
|
+
}),
|
|
1854
|
+
|
|
1855
|
+
setConnecting: (connecting) =>
|
|
1856
|
+
set((state) => {
|
|
1857
|
+
state.isConnecting = connecting;
|
|
1858
|
+
}),
|
|
1859
|
+
|
|
1860
|
+
addPendingAction: (action) =>
|
|
1861
|
+
set((state) => {
|
|
1862
|
+
state.pendingActions.push({
|
|
1863
|
+
...action,
|
|
1864
|
+
retryCount: 0,
|
|
1865
|
+
maxRetries: 3,
|
|
1866
|
+
});
|
|
1867
|
+
}),
|
|
1868
|
+
|
|
1869
|
+
removePendingAction: (id) =>
|
|
1870
|
+
set((state) => {
|
|
1871
|
+
state.pendingActions = state.pendingActions.filter(a => a.id !== id);
|
|
1872
|
+
}),
|
|
1873
|
+
|
|
1874
|
+
incrementRetry: (id) =>
|
|
1875
|
+
set((state) => {
|
|
1876
|
+
const action = state.pendingActions.find(a => a.id === id);
|
|
1877
|
+
if (action) {
|
|
1878
|
+
action.retryCount++;
|
|
1879
|
+
}
|
|
1880
|
+
}),
|
|
1881
|
+
|
|
1882
|
+
setCache: (key, data, ttl = DEFAULT_TTL) =>
|
|
1883
|
+
set((state) => {
|
|
1884
|
+
// Implement LRU eviction
|
|
1885
|
+
const cacheKeys = Object.keys(state.cache);
|
|
1886
|
+
if (cacheKeys.length >= MAX_CACHE_SIZE) {
|
|
1887
|
+
// Remove oldest entry
|
|
1888
|
+
const oldest = cacheKeys.reduce((a, b) =>
|
|
1889
|
+
state.cache[a].timestamp < state.cache[b].timestamp ? a : b
|
|
1890
|
+
);
|
|
1891
|
+
delete state.cache[oldest];
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
state.cache[key] = {
|
|
1895
|
+
key,
|
|
1896
|
+
data,
|
|
1897
|
+
timestamp: Date.now(),
|
|
1898
|
+
ttl,
|
|
1899
|
+
};
|
|
1900
|
+
}),
|
|
1901
|
+
|
|
1902
|
+
getCache: <T>(key: string): T | null => {
|
|
1903
|
+
const cached = get().cache[key];
|
|
1904
|
+
if (!cached) return null;
|
|
1905
|
+
|
|
1906
|
+
const isExpired = Date.now() - cached.timestamp > cached.ttl;
|
|
1907
|
+
if (isExpired) {
|
|
1908
|
+
get().clearExpiredCache();
|
|
1909
|
+
return null;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
return cached.data as T;
|
|
1913
|
+
},
|
|
1914
|
+
|
|
1915
|
+
clearExpiredCache: () =>
|
|
1916
|
+
set((state) => {
|
|
1917
|
+
const now = Date.now();
|
|
1918
|
+
Object.keys(state.cache).forEach((key) => {
|
|
1919
|
+
const cached = state.cache[key];
|
|
1920
|
+
if (now - cached.timestamp > cached.ttl) {
|
|
1921
|
+
delete state.cache[key];
|
|
1922
|
+
}
|
|
1923
|
+
});
|
|
1924
|
+
}),
|
|
1925
|
+
|
|
1926
|
+
syncPendingActions: async () => {
|
|
1927
|
+
const state = get();
|
|
1928
|
+
if (state.syncInProgress || !state.isOnline) return;
|
|
1929
|
+
|
|
1930
|
+
set((s) => { s.syncInProgress = true; });
|
|
1931
|
+
|
|
1932
|
+
const actionsToSync = [...state.pendingActions];
|
|
1933
|
+
|
|
1934
|
+
for (const action of actionsToSync) {
|
|
1935
|
+
if (action.retryCount >= action.maxRetries) {
|
|
1936
|
+
// Max retries exceeded, remove and notify user
|
|
1937
|
+
get().removePendingAction(action.id);
|
|
1938
|
+
continue;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
try {
|
|
1942
|
+
await executeAction(action);
|
|
1943
|
+
get().removePendingAction(action.id);
|
|
1944
|
+
} catch (error) {
|
|
1945
|
+
console.error(`Failed to sync action ${action.id}:`, error);
|
|
1946
|
+
get().incrementRetry(action.id);
|
|
1947
|
+
|
|
1948
|
+
// Exponential backoff
|
|
1949
|
+
const delay = Math.min(1000 * Math.pow(2, action.retryCount), 30000);
|
|
1950
|
+
await new Promise(r => setTimeout(r, delay));
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
set((s) => {
|
|
1955
|
+
s.syncInProgress = false;
|
|
1956
|
+
s.lastSyncTimestamp = Date.now();
|
|
1957
|
+
});
|
|
1958
|
+
},
|
|
1959
|
+
})),
|
|
1960
|
+
{
|
|
1961
|
+
name: 'elsabro-offline',
|
|
1962
|
+
storage: createJSONStorage(() => AsyncStorage),
|
|
1963
|
+
partialize: (state) => ({
|
|
1964
|
+
pendingActions: state.pendingActions,
|
|
1965
|
+
cache: state.cache,
|
|
1966
|
+
lastSyncTimestamp: state.lastSyncTimestamp,
|
|
1967
|
+
}),
|
|
1968
|
+
}
|
|
1969
|
+
)
|
|
1970
|
+
);
|
|
1971
|
+
|
|
1972
|
+
async function executeAction(action: PendingAction): Promise<void> {
|
|
1973
|
+
const API_URL = process.env.EXPO_PUBLIC_API_URL;
|
|
1974
|
+
|
|
1975
|
+
switch (action.type) {
|
|
1976
|
+
case 'command':
|
|
1977
|
+
await fetch(`${API_URL}/sessions/${action.sessionId}/command`, {
|
|
1978
|
+
method: 'POST',
|
|
1979
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1980
|
+
body: JSON.stringify(action.data),
|
|
1981
|
+
});
|
|
1982
|
+
break;
|
|
1983
|
+
|
|
1984
|
+
case 'approval':
|
|
1985
|
+
await fetch(`${API_URL}/tasks/approve`, {
|
|
1986
|
+
method: 'POST',
|
|
1987
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1988
|
+
body: JSON.stringify(action.data),
|
|
1989
|
+
});
|
|
1990
|
+
break;
|
|
1991
|
+
|
|
1992
|
+
case 'rejection':
|
|
1993
|
+
await fetch(`${API_URL}/tasks/reject`, {
|
|
1994
|
+
method: 'POST',
|
|
1995
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1996
|
+
body: JSON.stringify(action.data),
|
|
1997
|
+
});
|
|
1998
|
+
break;
|
|
1999
|
+
|
|
2000
|
+
default:
|
|
2001
|
+
console.warn('Unknown action type:', action.type);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// Network monitor hook
|
|
2006
|
+
export function useNetworkMonitor() {
|
|
2007
|
+
const { setOnline, setConnecting, syncPendingActions } = useOfflineStore();
|
|
2008
|
+
|
|
2009
|
+
React.useEffect(() => {
|
|
2010
|
+
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
|
|
2011
|
+
const wasOffline = !useOfflineStore.getState().isOnline;
|
|
2012
|
+
const isNowOnline = state.isConnected && state.isInternetReachable;
|
|
2013
|
+
|
|
2014
|
+
setOnline(isNowOnline ?? false);
|
|
2015
|
+
setConnecting(state.isConnected && !state.isInternetReachable);
|
|
2016
|
+
|
|
2017
|
+
// Trigger sync when coming back online
|
|
2018
|
+
if (wasOffline && isNowOnline) {
|
|
2019
|
+
syncPendingActions();
|
|
2020
|
+
}
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
return () => unsubscribe();
|
|
2024
|
+
}, []);
|
|
2025
|
+
}
|
|
2026
|
+
```
|
|
2027
|
+
|
|
2028
|
+
### Offline Indicator Component
|
|
2029
|
+
|
|
2030
|
+
```typescript
|
|
2031
|
+
// src/components/OfflineIndicator.tsx
|
|
2032
|
+
import React from 'react';
|
|
2033
|
+
import { View, Text, StyleSheet } from 'react-native';
|
|
2034
|
+
import Animated, {
|
|
2035
|
+
useAnimatedStyle,
|
|
2036
|
+
withTiming,
|
|
2037
|
+
withRepeat,
|
|
2038
|
+
useSharedValue,
|
|
2039
|
+
} from 'react-native-reanimated';
|
|
2040
|
+
import { WifiOff, Loader, CloudOff } from 'lucide-react-native';
|
|
2041
|
+
import { useOfflineStore } from '@/stores/offlineStore';
|
|
2042
|
+
|
|
2043
|
+
export function OfflineIndicator() {
|
|
2044
|
+
const { isOnline, isConnecting, pendingActions, syncInProgress } = useOfflineStore();
|
|
2045
|
+
const opacity = useSharedValue(0);
|
|
2046
|
+
|
|
2047
|
+
React.useEffect(() => {
|
|
2048
|
+
opacity.value = withTiming(isOnline ? 0 : 1, { duration: 300 });
|
|
2049
|
+
}, [isOnline]);
|
|
2050
|
+
|
|
2051
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
2052
|
+
opacity: opacity.value,
|
|
2053
|
+
transform: [{ translateY: opacity.value === 0 ? -50 : 0 }],
|
|
2054
|
+
}));
|
|
2055
|
+
|
|
2056
|
+
if (isOnline && !syncInProgress) return null;
|
|
2057
|
+
|
|
2058
|
+
return (
|
|
2059
|
+
<Animated.View style={[styles.container, animatedStyle]}>
|
|
2060
|
+
<View style={[
|
|
2061
|
+
styles.bar,
|
|
2062
|
+
isConnecting && styles.barConnecting,
|
|
2063
|
+
syncInProgress && styles.barSyncing,
|
|
2064
|
+
]}>
|
|
2065
|
+
{syncInProgress ? (
|
|
2066
|
+
<>
|
|
2067
|
+
<Loader size={16} color="#fff" />
|
|
2068
|
+
<Text style={styles.text}>Syncing {pendingActions.length} actions...</Text>
|
|
2069
|
+
</>
|
|
2070
|
+
) : isConnecting ? (
|
|
2071
|
+
<>
|
|
2072
|
+
<Loader size={16} color="#fff" />
|
|
2073
|
+
<Text style={styles.text}>Connecting...</Text>
|
|
2074
|
+
</>
|
|
2075
|
+
) : (
|
|
2076
|
+
<>
|
|
2077
|
+
<WifiOff size={16} color="#fff" />
|
|
2078
|
+
<Text style={styles.text}>
|
|
2079
|
+
Offline {pendingActions.length > 0 && `(${pendingActions.length} pending)`}
|
|
2080
|
+
</Text>
|
|
2081
|
+
</>
|
|
2082
|
+
)}
|
|
2083
|
+
</View>
|
|
2084
|
+
</Animated.View>
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
const styles = StyleSheet.create({
|
|
2089
|
+
container: {
|
|
2090
|
+
position: 'absolute',
|
|
2091
|
+
top: 0,
|
|
2092
|
+
left: 0,
|
|
2093
|
+
right: 0,
|
|
2094
|
+
zIndex: 1000,
|
|
2095
|
+
},
|
|
2096
|
+
bar: {
|
|
2097
|
+
flexDirection: 'row',
|
|
2098
|
+
alignItems: 'center',
|
|
2099
|
+
justifyContent: 'center',
|
|
2100
|
+
gap: 8,
|
|
2101
|
+
paddingVertical: 8,
|
|
2102
|
+
paddingTop: 48, // Account for status bar
|
|
2103
|
+
backgroundColor: '#ef4444',
|
|
2104
|
+
},
|
|
2105
|
+
barConnecting: {
|
|
2106
|
+
backgroundColor: '#f59e0b',
|
|
2107
|
+
},
|
|
2108
|
+
barSyncing: {
|
|
2109
|
+
backgroundColor: '#3b82f6',
|
|
2110
|
+
},
|
|
2111
|
+
text: {
|
|
2112
|
+
color: '#fff',
|
|
2113
|
+
fontSize: 14,
|
|
2114
|
+
fontWeight: '500',
|
|
2115
|
+
},
|
|
2116
|
+
});
|
|
2117
|
+
```
|
|
2118
|
+
|
|
2119
|
+
---
|
|
2120
|
+
|
|
2121
|
+
## 6. BiometricAuth
|
|
2122
|
+
|
|
2123
|
+
### Authentication Flow
|
|
2124
|
+
|
|
2125
|
+
```
|
|
2126
|
+
+------------------------------------------------------------------+
|
|
2127
|
+
| Biometric Authentication Flow |
|
|
2128
|
+
+------------------------------------------------------------------+
|
|
2129
|
+
| |
|
|
2130
|
+
| +------------------+ |
|
|
2131
|
+
| | App Launch | |
|
|
2132
|
+
| +--------+---------+ |
|
|
2133
|
+
| | |
|
|
2134
|
+
| v |
|
|
2135
|
+
| +------------------+ +------------------+ |
|
|
2136
|
+
| | Check Biometric |---->| Not Enrolled |---> Password Login |
|
|
2137
|
+
| | Enrollment | +------------------+ |
|
|
2138
|
+
| +--------+---------+ |
|
|
2139
|
+
| | Enrolled |
|
|
2140
|
+
| v |
|
|
2141
|
+
| +------------------+ |
|
|
2142
|
+
| | Prompt Biometric | |
|
|
2143
|
+
| +--------+---------+ |
|
|
2144
|
+
| | |
|
|
2145
|
+
| +-----+-----+ |
|
|
2146
|
+
| | | |
|
|
2147
|
+
| v v |
|
|
2148
|
+
| Success Failure |
|
|
2149
|
+
| | | |
|
|
2150
|
+
| v v |
|
|
2151
|
+
| +------------------+ +------------------+ |
|
|
2152
|
+
| | Retrieve Token | | Show PIN Fallback| |
|
|
2153
|
+
| | from SecureStore | +--------+---------+ |
|
|
2154
|
+
| +--------+---------+ | |
|
|
2155
|
+
| | | |
|
|
2156
|
+
| v v |
|
|
2157
|
+
| +------------------+ +------------------+ |
|
|
2158
|
+
| | Validate Token | | Validate PIN | |
|
|
2159
|
+
| +--------+---------+ +--------+---------+ |
|
|
2160
|
+
| | | |
|
|
2161
|
+
| +------------------------+ |
|
|
2162
|
+
| | |
|
|
2163
|
+
| v |
|
|
2164
|
+
| +------------------+ |
|
|
2165
|
+
| | Authenticated | |
|
|
2166
|
+
| +------------------+ |
|
|
2167
|
+
| |
|
|
2168
|
+
+------------------------------------------------------------------+
|
|
2169
|
+
```
|
|
2170
|
+
|
|
2171
|
+
### Biometric Auth Implementation
|
|
2172
|
+
|
|
2173
|
+
```typescript
|
|
2174
|
+
// src/hooks/useBiometric.ts
|
|
2175
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
2176
|
+
import * as LocalAuthentication from 'expo-local-authentication';
|
|
2177
|
+
import * as SecureStore from 'expo-secure-store';
|
|
2178
|
+
import { Platform } from 'react-native';
|
|
2179
|
+
import * as Haptics from 'expo-haptics';
|
|
2180
|
+
|
|
2181
|
+
interface BiometricState {
|
|
2182
|
+
isAvailable: boolean;
|
|
2183
|
+
biometricType: 'fingerprint' | 'facial' | 'iris' | null;
|
|
2184
|
+
isEnrolled: boolean;
|
|
2185
|
+
isAuthenticating: boolean;
|
|
2186
|
+
error: string | null;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
const SECURE_KEYS = {
|
|
2190
|
+
ACCESS_TOKEN: 'elsabro_access_token',
|
|
2191
|
+
REFRESH_TOKEN: 'elsabro_refresh_token',
|
|
2192
|
+
PIN_HASH: 'elsabro_pin_hash',
|
|
2193
|
+
BIOMETRIC_ENABLED: 'elsabro_biometric_enabled',
|
|
2194
|
+
} as const;
|
|
2195
|
+
|
|
2196
|
+
export function useBiometric() {
|
|
2197
|
+
const [state, setState] = useState<BiometricState>({
|
|
2198
|
+
isAvailable: false,
|
|
2199
|
+
biometricType: null,
|
|
2200
|
+
isEnrolled: false,
|
|
2201
|
+
isAuthenticating: false,
|
|
2202
|
+
error: null,
|
|
2203
|
+
});
|
|
2204
|
+
|
|
2205
|
+
useEffect(() => {
|
|
2206
|
+
checkBiometricAvailability();
|
|
2207
|
+
}, []);
|
|
2208
|
+
|
|
2209
|
+
const checkBiometricAvailability = async () => {
|
|
2210
|
+
try {
|
|
2211
|
+
const compatible = await LocalAuthentication.hasHardwareAsync();
|
|
2212
|
+
const enrolled = await LocalAuthentication.isEnrolledAsync();
|
|
2213
|
+
const types = await LocalAuthentication.supportedAuthenticationTypesAsync();
|
|
2214
|
+
|
|
2215
|
+
let biometricType: BiometricState['biometricType'] = null;
|
|
2216
|
+
|
|
2217
|
+
if (types.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION)) {
|
|
2218
|
+
biometricType = 'facial';
|
|
2219
|
+
} else if (types.includes(LocalAuthentication.AuthenticationType.FINGERPRINT)) {
|
|
2220
|
+
biometricType = 'fingerprint';
|
|
2221
|
+
} else if (types.includes(LocalAuthentication.AuthenticationType.IRIS)) {
|
|
2222
|
+
biometricType = 'iris';
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
setState(prev => ({
|
|
2226
|
+
...prev,
|
|
2227
|
+
isAvailable: compatible,
|
|
2228
|
+
isEnrolled: enrolled,
|
|
2229
|
+
biometricType,
|
|
2230
|
+
}));
|
|
2231
|
+
} catch (error) {
|
|
2232
|
+
console.error('Biometric check failed:', error);
|
|
2233
|
+
}
|
|
2234
|
+
};
|
|
2235
|
+
|
|
2236
|
+
const authenticate = useCallback(async (): Promise<boolean> => {
|
|
2237
|
+
setState(prev => ({ ...prev, isAuthenticating: true, error: null }));
|
|
2238
|
+
|
|
2239
|
+
try {
|
|
2240
|
+
const result = await LocalAuthentication.authenticateAsync({
|
|
2241
|
+
promptMessage: 'Authenticate to access ELSABRO',
|
|
2242
|
+
subtitle: Platform.OS === 'android' ? 'Use biometrics or PIN' : undefined,
|
|
2243
|
+
fallbackLabel: 'Use PIN',
|
|
2244
|
+
cancelLabel: 'Cancel',
|
|
2245
|
+
disableDeviceFallback: false,
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
if (result.success) {
|
|
2249
|
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
2250
|
+
setState(prev => ({ ...prev, isAuthenticating: false }));
|
|
2251
|
+
return true;
|
|
2252
|
+
} else {
|
|
2253
|
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
2254
|
+
setState(prev => ({
|
|
2255
|
+
...prev,
|
|
2256
|
+
isAuthenticating: false,
|
|
2257
|
+
error: result.error === 'user_cancel'
|
|
2258
|
+
? 'Authentication cancelled'
|
|
2259
|
+
: 'Authentication failed',
|
|
2260
|
+
}));
|
|
2261
|
+
return false;
|
|
2262
|
+
}
|
|
2263
|
+
} catch (error) {
|
|
2264
|
+
setState(prev => ({
|
|
2265
|
+
...prev,
|
|
2266
|
+
isAuthenticating: false,
|
|
2267
|
+
error: 'Authentication error',
|
|
2268
|
+
}));
|
|
2269
|
+
return false;
|
|
2270
|
+
}
|
|
2271
|
+
}, []);
|
|
2272
|
+
|
|
2273
|
+
return {
|
|
2274
|
+
...state,
|
|
2275
|
+
authenticate,
|
|
2276
|
+
checkBiometricAvailability,
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
// Secure Storage Utilities
|
|
2281
|
+
export const SecureStorage = {
|
|
2282
|
+
async setToken(token: string): Promise<void> {
|
|
2283
|
+
await SecureStore.setItemAsync(SECURE_KEYS.ACCESS_TOKEN, token, {
|
|
2284
|
+
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
2285
|
+
});
|
|
2286
|
+
},
|
|
2287
|
+
|
|
2288
|
+
async getToken(): Promise<string | null> {
|
|
2289
|
+
return SecureStore.getItemAsync(SECURE_KEYS.ACCESS_TOKEN);
|
|
2290
|
+
},
|
|
2291
|
+
|
|
2292
|
+
async setRefreshToken(token: string): Promise<void> {
|
|
2293
|
+
await SecureStore.setItemAsync(SECURE_KEYS.REFRESH_TOKEN, token, {
|
|
2294
|
+
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
2295
|
+
});
|
|
2296
|
+
},
|
|
2297
|
+
|
|
2298
|
+
async getRefreshToken(): Promise<string | null> {
|
|
2299
|
+
return SecureStore.getItemAsync(SECURE_KEYS.REFRESH_TOKEN);
|
|
2300
|
+
},
|
|
2301
|
+
|
|
2302
|
+
async setPinHash(hash: string): Promise<void> {
|
|
2303
|
+
await SecureStore.setItemAsync(SECURE_KEYS.PIN_HASH, hash, {
|
|
2304
|
+
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
2305
|
+
});
|
|
2306
|
+
},
|
|
2307
|
+
|
|
2308
|
+
async getPinHash(): Promise<string | null> {
|
|
2309
|
+
return SecureStore.getItemAsync(SECURE_KEYS.PIN_HASH);
|
|
2310
|
+
},
|
|
2311
|
+
|
|
2312
|
+
async setBiometricEnabled(enabled: boolean): Promise<void> {
|
|
2313
|
+
await SecureStore.setItemAsync(
|
|
2314
|
+
SECURE_KEYS.BIOMETRIC_ENABLED,
|
|
2315
|
+
enabled ? 'true' : 'false'
|
|
2316
|
+
);
|
|
2317
|
+
},
|
|
2318
|
+
|
|
2319
|
+
async isBiometricEnabled(): Promise<boolean> {
|
|
2320
|
+
const value = await SecureStore.getItemAsync(SECURE_KEYS.BIOMETRIC_ENABLED);
|
|
2321
|
+
return value === 'true';
|
|
2322
|
+
},
|
|
2323
|
+
|
|
2324
|
+
async clearAll(): Promise<void> {
|
|
2325
|
+
await Promise.all([
|
|
2326
|
+
SecureStore.deleteItemAsync(SECURE_KEYS.ACCESS_TOKEN),
|
|
2327
|
+
SecureStore.deleteItemAsync(SECURE_KEYS.REFRESH_TOKEN),
|
|
2328
|
+
SecureStore.deleteItemAsync(SECURE_KEYS.PIN_HASH),
|
|
2329
|
+
SecureStore.deleteItemAsync(SECURE_KEYS.BIOMETRIC_ENABLED),
|
|
2330
|
+
]);
|
|
2331
|
+
},
|
|
2332
|
+
};
|
|
2333
|
+
```
|
|
2334
|
+
|
|
2335
|
+
### Auth Store
|
|
2336
|
+
|
|
2337
|
+
```typescript
|
|
2338
|
+
// src/stores/authStore.ts
|
|
2339
|
+
import { create } from 'zustand';
|
|
2340
|
+
import { SecureStorage } from '@/hooks/useBiometric';
|
|
2341
|
+
|
|
2342
|
+
interface AuthState {
|
|
2343
|
+
isAuthenticated: boolean;
|
|
2344
|
+
isLoading: boolean;
|
|
2345
|
+
accessToken: string | null;
|
|
2346
|
+
user: User | null;
|
|
2347
|
+
pendingDeepLink: string | null;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
interface AuthActions {
|
|
2351
|
+
initialize: () => Promise<void>;
|
|
2352
|
+
login: (credentials: LoginCredentials) => Promise<void>;
|
|
2353
|
+
loginWithBiometric: () => Promise<boolean>;
|
|
2354
|
+
logout: () => Promise<void>;
|
|
2355
|
+
refreshToken: () => Promise<void>;
|
|
2356
|
+
setPendingDeepLink: (url: string | null) => void;
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
export const useAuthStore = create<AuthState & AuthActions>((set, get) => ({
|
|
2360
|
+
isAuthenticated: false,
|
|
2361
|
+
isLoading: true,
|
|
2362
|
+
accessToken: null,
|
|
2363
|
+
user: null,
|
|
2364
|
+
pendingDeepLink: null,
|
|
2365
|
+
|
|
2366
|
+
initialize: async () => {
|
|
2367
|
+
try {
|
|
2368
|
+
const token = await SecureStorage.getToken();
|
|
2369
|
+
|
|
2370
|
+
if (token) {
|
|
2371
|
+
// Validate token with server
|
|
2372
|
+
const response = await fetch(`${API_URL}/auth/validate`, {
|
|
2373
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
2374
|
+
});
|
|
2375
|
+
|
|
2376
|
+
if (response.ok) {
|
|
2377
|
+
const user = await response.json();
|
|
2378
|
+
set({
|
|
2379
|
+
isAuthenticated: true,
|
|
2380
|
+
accessToken: token,
|
|
2381
|
+
user,
|
|
2382
|
+
isLoading: false,
|
|
2383
|
+
});
|
|
2384
|
+
return;
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
// Token invalid, try refresh
|
|
2388
|
+
await get().refreshToken();
|
|
2389
|
+
}
|
|
2390
|
+
} catch (error) {
|
|
2391
|
+
console.error('Auth initialization failed:', error);
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
set({ isLoading: false });
|
|
2395
|
+
},
|
|
2396
|
+
|
|
2397
|
+
login: async (credentials) => {
|
|
2398
|
+
const response = await fetch(`${API_URL}/auth/login`, {
|
|
2399
|
+
method: 'POST',
|
|
2400
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2401
|
+
body: JSON.stringify(credentials),
|
|
2402
|
+
});
|
|
2403
|
+
|
|
2404
|
+
if (!response.ok) {
|
|
2405
|
+
throw new Error('Login failed');
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
const { accessToken, refreshToken, user } = await response.json();
|
|
2409
|
+
|
|
2410
|
+
await SecureStorage.setToken(accessToken);
|
|
2411
|
+
await SecureStorage.setRefreshToken(refreshToken);
|
|
2412
|
+
|
|
2413
|
+
set({
|
|
2414
|
+
isAuthenticated: true,
|
|
2415
|
+
accessToken,
|
|
2416
|
+
user,
|
|
2417
|
+
});
|
|
2418
|
+
},
|
|
2419
|
+
|
|
2420
|
+
loginWithBiometric: async () => {
|
|
2421
|
+
const token = await SecureStorage.getToken();
|
|
2422
|
+
|
|
2423
|
+
if (!token) {
|
|
2424
|
+
return false;
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
// Validate stored token
|
|
2428
|
+
const response = await fetch(`${API_URL}/auth/validate`, {
|
|
2429
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
2430
|
+
});
|
|
2431
|
+
|
|
2432
|
+
if (response.ok) {
|
|
2433
|
+
const user = await response.json();
|
|
2434
|
+
set({
|
|
2435
|
+
isAuthenticated: true,
|
|
2436
|
+
accessToken: token,
|
|
2437
|
+
user,
|
|
2438
|
+
});
|
|
2439
|
+
return true;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
// Token expired, try refresh
|
|
2443
|
+
try {
|
|
2444
|
+
await get().refreshToken();
|
|
2445
|
+
return true;
|
|
2446
|
+
} catch {
|
|
2447
|
+
return false;
|
|
2448
|
+
}
|
|
2449
|
+
},
|
|
2450
|
+
|
|
2451
|
+
logout: async () => {
|
|
2452
|
+
await SecureStorage.clearAll();
|
|
2453
|
+
set({
|
|
2454
|
+
isAuthenticated: false,
|
|
2455
|
+
accessToken: null,
|
|
2456
|
+
user: null,
|
|
2457
|
+
});
|
|
2458
|
+
},
|
|
2459
|
+
|
|
2460
|
+
refreshToken: async () => {
|
|
2461
|
+
const refreshToken = await SecureStorage.getRefreshToken();
|
|
2462
|
+
|
|
2463
|
+
if (!refreshToken) {
|
|
2464
|
+
throw new Error('No refresh token');
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
const response = await fetch(`${API_URL}/auth/refresh`, {
|
|
2468
|
+
method: 'POST',
|
|
2469
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2470
|
+
body: JSON.stringify({ refreshToken }),
|
|
2471
|
+
});
|
|
2472
|
+
|
|
2473
|
+
if (!response.ok) {
|
|
2474
|
+
await get().logout();
|
|
2475
|
+
throw new Error('Refresh failed');
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
const { accessToken, user } = await response.json();
|
|
2479
|
+
|
|
2480
|
+
await SecureStorage.setToken(accessToken);
|
|
2481
|
+
|
|
2482
|
+
set({
|
|
2483
|
+
isAuthenticated: true,
|
|
2484
|
+
accessToken,
|
|
2485
|
+
user,
|
|
2486
|
+
});
|
|
2487
|
+
},
|
|
2488
|
+
|
|
2489
|
+
setPendingDeepLink: (url) => set({ pendingDeepLink: url }),
|
|
2490
|
+
}));
|
|
2491
|
+
```
|
|
2492
|
+
|
|
2493
|
+
---
|
|
2494
|
+
|
|
2495
|
+
## 7. Widget Support
|
|
2496
|
+
|
|
2497
|
+
### Widget Architecture
|
|
2498
|
+
|
|
2499
|
+
```
|
|
2500
|
+
+------------------------------------------------------------------+
|
|
2501
|
+
| Widget System Architecture |
|
|
2502
|
+
+------------------------------------------------------------------+
|
|
2503
|
+
| |
|
|
2504
|
+
| iOS (WidgetKit) Android (App Widgets) |
|
|
2505
|
+
| +-------------------------+ +-------------------------+ |
|
|
2506
|
+
| | SwiftUI Widget View | | RemoteViews Layout | |
|
|
2507
|
+
| +------------+------------+ +------------+------------+ |
|
|
2508
|
+
| | | |
|
|
2509
|
+
| v v |
|
|
2510
|
+
| +-------------------------+ +-------------------------+ |
|
|
2511
|
+
| | Widget Timeline | | AppWidgetProvider | |
|
|
2512
|
+
| | Provider | | | |
|
|
2513
|
+
| +------------+------------+ +------------+------------+ |
|
|
2514
|
+
| | | |
|
|
2515
|
+
| v v |
|
|
2516
|
+
| +-------------------------+ +-------------------------+ |
|
|
2517
|
+
| | App Group Shared | | SharedPreferences | |
|
|
2518
|
+
| | UserDefaults | | (MODE_PRIVATE) | |
|
|
2519
|
+
| +------------+------------+ +------------+------------+ |
|
|
2520
|
+
| | | |
|
|
2521
|
+
| +----------------------------------+ |
|
|
2522
|
+
| | |
|
|
2523
|
+
| v |
|
|
2524
|
+
| +------------------------------+ |
|
|
2525
|
+
| | React Native Bridge | |
|
|
2526
|
+
| | (expo-widget-extension) | |
|
|
2527
|
+
| +------------------------------+ |
|
|
2528
|
+
| |
|
|
2529
|
+
+------------------------------------------------------------------+
|
|
2530
|
+
```
|
|
2531
|
+
|
|
2532
|
+
### iOS Widget Implementation
|
|
2533
|
+
|
|
2534
|
+
```swift
|
|
2535
|
+
// widgets/ios/ElsabroWidget/ElsabroWidget.swift
|
|
2536
|
+
import WidgetKit
|
|
2537
|
+
import SwiftUI
|
|
2538
|
+
|
|
2539
|
+
struct SessionEntry: TimelineEntry {
|
|
2540
|
+
let date: Date
|
|
2541
|
+
let session: SessionData?
|
|
2542
|
+
let configuration: ConfigurationIntent
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
struct SessionData: Codable {
|
|
2546
|
+
let id: String
|
|
2547
|
+
let name: String
|
|
2548
|
+
let status: String
|
|
2549
|
+
let progress: Int
|
|
2550
|
+
let currentTask: String?
|
|
2551
|
+
let agentCount: Int
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
struct Provider: IntentTimelineProvider {
|
|
2555
|
+
func placeholder(in context: Context) -> SessionEntry {
|
|
2556
|
+
SessionEntry(
|
|
2557
|
+
date: Date(),
|
|
2558
|
+
session: SessionData(
|
|
2559
|
+
id: "placeholder",
|
|
2560
|
+
name: "Loading...",
|
|
2561
|
+
status: "active",
|
|
2562
|
+
progress: 50,
|
|
2563
|
+
currentTask: nil,
|
|
2564
|
+
agentCount: 0
|
|
2565
|
+
),
|
|
2566
|
+
configuration: ConfigurationIntent()
|
|
2567
|
+
)
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
func getSnapshot(
|
|
2571
|
+
for configuration: ConfigurationIntent,
|
|
2572
|
+
in context: Context,
|
|
2573
|
+
completion: @escaping (SessionEntry) -> Void
|
|
2574
|
+
) {
|
|
2575
|
+
let entry = SessionEntry(
|
|
2576
|
+
date: Date(),
|
|
2577
|
+
session: loadSessionData(),
|
|
2578
|
+
configuration: configuration
|
|
2579
|
+
)
|
|
2580
|
+
completion(entry)
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
func getTimeline(
|
|
2584
|
+
for configuration: ConfigurationIntent,
|
|
2585
|
+
in context: Context,
|
|
2586
|
+
completion: @escaping (Timeline<SessionEntry>) -> Void
|
|
2587
|
+
) {
|
|
2588
|
+
let session = loadSessionData()
|
|
2589
|
+
let entry = SessionEntry(
|
|
2590
|
+
date: Date(),
|
|
2591
|
+
session: session,
|
|
2592
|
+
configuration: configuration
|
|
2593
|
+
)
|
|
2594
|
+
|
|
2595
|
+
// Update every 5 minutes
|
|
2596
|
+
let nextUpdate = Calendar.current.date(
|
|
2597
|
+
byAdding: .minute,
|
|
2598
|
+
value: 5,
|
|
2599
|
+
to: Date()
|
|
2600
|
+
)!
|
|
2601
|
+
|
|
2602
|
+
let timeline = Timeline(
|
|
2603
|
+
entries: [entry],
|
|
2604
|
+
policy: .after(nextUpdate)
|
|
2605
|
+
)
|
|
2606
|
+
completion(timeline)
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
private func loadSessionData() -> SessionData? {
|
|
2610
|
+
guard let userDefaults = UserDefaults(suiteName: "group.io.elsabro.companion"),
|
|
2611
|
+
let data = userDefaults.data(forKey: "activeSession"),
|
|
2612
|
+
let session = try? JSONDecoder().decode(SessionData.self, from: data)
|
|
2613
|
+
else {
|
|
2614
|
+
return nil
|
|
2615
|
+
}
|
|
2616
|
+
return session
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
struct ElsabroWidgetEntryView: View {
|
|
2621
|
+
var entry: Provider.Entry
|
|
2622
|
+
@Environment(\.widgetFamily) var family
|
|
2623
|
+
|
|
2624
|
+
var body: some View {
|
|
2625
|
+
switch family {
|
|
2626
|
+
case .systemSmall:
|
|
2627
|
+
SmallWidgetView(session: entry.session)
|
|
2628
|
+
case .systemMedium:
|
|
2629
|
+
MediumWidgetView(session: entry.session)
|
|
2630
|
+
case .systemLarge:
|
|
2631
|
+
LargeWidgetView(session: entry.session)
|
|
2632
|
+
default:
|
|
2633
|
+
SmallWidgetView(session: entry.session)
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
struct SmallWidgetView: View {
|
|
2639
|
+
let session: SessionData?
|
|
2640
|
+
|
|
2641
|
+
var body: some View {
|
|
2642
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
2643
|
+
HStack {
|
|
2644
|
+
Image(systemName: "sparkles")
|
|
2645
|
+
.foregroundColor(.purple)
|
|
2646
|
+
Text("ELSABRO")
|
|
2647
|
+
.font(.caption)
|
|
2648
|
+
.fontWeight(.bold)
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
if let session = session {
|
|
2652
|
+
Text(session.name)
|
|
2653
|
+
.font(.headline)
|
|
2654
|
+
.lineLimit(1)
|
|
2655
|
+
|
|
2656
|
+
ProgressView(value: Double(session.progress) / 100)
|
|
2657
|
+
.tint(statusColor(session.status))
|
|
2658
|
+
|
|
2659
|
+
Text("\(session.progress)%")
|
|
2660
|
+
.font(.caption2)
|
|
2661
|
+
.foregroundColor(.secondary)
|
|
2662
|
+
} else {
|
|
2663
|
+
Text("No active session")
|
|
2664
|
+
.font(.caption)
|
|
2665
|
+
.foregroundColor(.secondary)
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
.padding()
|
|
2669
|
+
.widgetURL(URL(string: "elsabro://session/\(session?.id ?? "")"))
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
private func statusColor(_ status: String) -> Color {
|
|
2673
|
+
switch status {
|
|
2674
|
+
case "active": return .green
|
|
2675
|
+
case "paused": return .orange
|
|
2676
|
+
case "error": return .red
|
|
2677
|
+
default: return .blue
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
struct MediumWidgetView: View {
|
|
2683
|
+
let session: SessionData?
|
|
2684
|
+
|
|
2685
|
+
var body: some View {
|
|
2686
|
+
HStack(spacing: 16) {
|
|
2687
|
+
SmallWidgetView(session: session)
|
|
2688
|
+
|
|
2689
|
+
if let session = session {
|
|
2690
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
2691
|
+
Label {
|
|
2692
|
+
Text(session.status.capitalized)
|
|
2693
|
+
} icon: {
|
|
2694
|
+
Circle()
|
|
2695
|
+
.fill(statusColor(session.status))
|
|
2696
|
+
.frame(width: 8, height: 8)
|
|
2697
|
+
}
|
|
2698
|
+
.font(.caption)
|
|
2699
|
+
|
|
2700
|
+
if let task = session.currentTask {
|
|
2701
|
+
Text(task)
|
|
2702
|
+
.font(.caption2)
|
|
2703
|
+
.foregroundColor(.secondary)
|
|
2704
|
+
.lineLimit(2)
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
Spacer()
|
|
2708
|
+
|
|
2709
|
+
HStack {
|
|
2710
|
+
Image(systemName: "person.2.fill")
|
|
2711
|
+
Text("\(session.agentCount) agents")
|
|
2712
|
+
}
|
|
2713
|
+
.font(.caption2)
|
|
2714
|
+
.foregroundColor(.secondary)
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
.padding()
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
private func statusColor(_ status: String) -> Color {
|
|
2722
|
+
switch status {
|
|
2723
|
+
case "active": return .green
|
|
2724
|
+
case "paused": return .orange
|
|
2725
|
+
case "error": return .red
|
|
2726
|
+
default: return .blue
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
@main
|
|
2732
|
+
struct ElsabroWidget: Widget {
|
|
2733
|
+
let kind: String = "ElsabroWidget"
|
|
2734
|
+
|
|
2735
|
+
var body: some WidgetConfiguration {
|
|
2736
|
+
IntentConfiguration(
|
|
2737
|
+
kind: kind,
|
|
2738
|
+
intent: ConfigurationIntent.self,
|
|
2739
|
+
provider: Provider()
|
|
2740
|
+
) { entry in
|
|
2741
|
+
ElsabroWidgetEntryView(entry: entry)
|
|
2742
|
+
}
|
|
2743
|
+
.configurationDisplayName("ELSABRO Session")
|
|
2744
|
+
.description("Monitor your active ELSABRO session")
|
|
2745
|
+
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
```
|
|
2749
|
+
|
|
2750
|
+
### Android Widget Implementation
|
|
2751
|
+
|
|
2752
|
+
```kotlin
|
|
2753
|
+
// widgets/android/app/src/main/java/io/elsabro/companion/widget/ElsabroWidget.kt
|
|
2754
|
+
package io.elsabro.companion.widget
|
|
2755
|
+
|
|
2756
|
+
import android.app.PendingIntent
|
|
2757
|
+
import android.appwidget.AppWidgetManager
|
|
2758
|
+
import android.appwidget.AppWidgetProvider
|
|
2759
|
+
import android.content.Context
|
|
2760
|
+
import android.content.Intent
|
|
2761
|
+
import android.net.Uri
|
|
2762
|
+
import android.widget.RemoteViews
|
|
2763
|
+
import com.google.gson.Gson
|
|
2764
|
+
import io.elsabro.companion.R
|
|
2765
|
+
|
|
2766
|
+
data class SessionData(
|
|
2767
|
+
val id: String,
|
|
2768
|
+
val name: String,
|
|
2769
|
+
val status: String,
|
|
2770
|
+
val progress: Int,
|
|
2771
|
+
val currentTask: String?,
|
|
2772
|
+
val agentCount: Int
|
|
2773
|
+
)
|
|
2774
|
+
|
|
2775
|
+
class ElsabroWidget : AppWidgetProvider() {
|
|
2776
|
+
|
|
2777
|
+
override fun onUpdate(
|
|
2778
|
+
context: Context,
|
|
2779
|
+
appWidgetManager: AppWidgetManager,
|
|
2780
|
+
appWidgetIds: IntArray
|
|
2781
|
+
) {
|
|
2782
|
+
for (appWidgetId in appWidgetIds) {
|
|
2783
|
+
updateAppWidget(context, appWidgetManager, appWidgetId)
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
2788
|
+
super.onReceive(context, intent)
|
|
2789
|
+
|
|
2790
|
+
when (intent.action) {
|
|
2791
|
+
ACTION_REFRESH -> {
|
|
2792
|
+
val appWidgetId = intent.getIntExtra(
|
|
2793
|
+
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
|
2794
|
+
AppWidgetManager.INVALID_APPWIDGET_ID
|
|
2795
|
+
)
|
|
2796
|
+
if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
|
|
2797
|
+
val appWidgetManager = AppWidgetManager.getInstance(context)
|
|
2798
|
+
updateAppWidget(context, appWidgetManager, appWidgetId)
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
companion object {
|
|
2805
|
+
const val ACTION_REFRESH = "io.elsabro.companion.WIDGET_REFRESH"
|
|
2806
|
+
|
|
2807
|
+
internal fun updateAppWidget(
|
|
2808
|
+
context: Context,
|
|
2809
|
+
appWidgetManager: AppWidgetManager,
|
|
2810
|
+
appWidgetId: Int
|
|
2811
|
+
) {
|
|
2812
|
+
val views = RemoteViews(context.packageName, R.layout.elsabro_widget)
|
|
2813
|
+
|
|
2814
|
+
val session = loadSessionData(context)
|
|
2815
|
+
|
|
2816
|
+
if (session != null) {
|
|
2817
|
+
views.setTextViewText(R.id.session_name, session.name)
|
|
2818
|
+
views.setTextViewText(R.id.progress_text, "${session.progress}%")
|
|
2819
|
+
views.setProgressBar(R.id.progress_bar, 100, session.progress, false)
|
|
2820
|
+
views.setTextViewText(R.id.status_text, session.status.capitalize())
|
|
2821
|
+
views.setTextViewText(
|
|
2822
|
+
R.id.current_task,
|
|
2823
|
+
session.currentTask ?: "No current task"
|
|
2824
|
+
)
|
|
2825
|
+
views.setTextViewText(R.id.agent_count, "${session.agentCount} agents")
|
|
2826
|
+
|
|
2827
|
+
// Set status indicator color
|
|
2828
|
+
val statusColor = when (session.status) {
|
|
2829
|
+
"active" -> android.R.color.holo_green_light
|
|
2830
|
+
"paused" -> android.R.color.holo_orange_light
|
|
2831
|
+
"error" -> android.R.color.holo_red_light
|
|
2832
|
+
else -> android.R.color.holo_blue_light
|
|
2833
|
+
}
|
|
2834
|
+
views.setInt(R.id.status_indicator, "setColorFilter",
|
|
2835
|
+
context.getColor(statusColor))
|
|
2836
|
+
|
|
2837
|
+
// Set click intent to open session
|
|
2838
|
+
val openIntent = Intent(
|
|
2839
|
+
Intent.ACTION_VIEW,
|
|
2840
|
+
Uri.parse("elsabro://session/${session.id}")
|
|
2841
|
+
).apply {
|
|
2842
|
+
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
|
2843
|
+
}
|
|
2844
|
+
val openPendingIntent = PendingIntent.getActivity(
|
|
2845
|
+
context,
|
|
2846
|
+
0,
|
|
2847
|
+
openIntent,
|
|
2848
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
2849
|
+
)
|
|
2850
|
+
views.setOnClickPendingIntent(R.id.widget_container, openPendingIntent)
|
|
2851
|
+
} else {
|
|
2852
|
+
views.setTextViewText(R.id.session_name, "No active session")
|
|
2853
|
+
views.setTextViewText(R.id.progress_text, "--")
|
|
2854
|
+
views.setProgressBar(R.id.progress_bar, 100, 0, false)
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
// Refresh button
|
|
2858
|
+
val refreshIntent = Intent(context, ElsabroWidget::class.java).apply {
|
|
2859
|
+
action = ACTION_REFRESH
|
|
2860
|
+
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
|
2861
|
+
}
|
|
2862
|
+
val refreshPendingIntent = PendingIntent.getBroadcast(
|
|
2863
|
+
context,
|
|
2864
|
+
appWidgetId,
|
|
2865
|
+
refreshIntent,
|
|
2866
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
2867
|
+
)
|
|
2868
|
+
views.setOnClickPendingIntent(R.id.refresh_button, refreshPendingIntent)
|
|
2869
|
+
|
|
2870
|
+
appWidgetManager.updateAppWidget(appWidgetId, views)
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
private fun loadSessionData(context: Context): SessionData? {
|
|
2874
|
+
val prefs = context.getSharedPreferences(
|
|
2875
|
+
"elsabro_widget_data",
|
|
2876
|
+
Context.MODE_PRIVATE
|
|
2877
|
+
)
|
|
2878
|
+
val json = prefs.getString("activeSession", null) ?: return null
|
|
2879
|
+
return try {
|
|
2880
|
+
Gson().fromJson(json, SessionData::class.java)
|
|
2881
|
+
} catch (e: Exception) {
|
|
2882
|
+
null
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
```
|
|
2888
|
+
|
|
2889
|
+
### React Native Widget Bridge
|
|
2890
|
+
|
|
2891
|
+
```typescript
|
|
2892
|
+
// src/services/widgetBridge.ts
|
|
2893
|
+
import { NativeModules, Platform } from 'react-native';
|
|
2894
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2895
|
+
|
|
2896
|
+
interface WidgetData {
|
|
2897
|
+
id: string;
|
|
2898
|
+
name: string;
|
|
2899
|
+
status: string;
|
|
2900
|
+
progress: number;
|
|
2901
|
+
currentTask: string | null;
|
|
2902
|
+
agentCount: number;
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
const { WidgetModule } = NativeModules;
|
|
2906
|
+
|
|
2907
|
+
export async function updateWidget(session: WidgetData | null): Promise<void> {
|
|
2908
|
+
const data = session ? JSON.stringify(session) : null;
|
|
2909
|
+
|
|
2910
|
+
if (Platform.OS === 'ios') {
|
|
2911
|
+
// Update shared UserDefaults for iOS widget
|
|
2912
|
+
await WidgetModule.setWidgetData('activeSession', data);
|
|
2913
|
+
await WidgetModule.reloadAllTimelines();
|
|
2914
|
+
} else if (Platform.OS === 'android') {
|
|
2915
|
+
// Update SharedPreferences for Android widget
|
|
2916
|
+
await WidgetModule.updateWidgetData('activeSession', data);
|
|
2917
|
+
await WidgetModule.requestWidgetUpdate();
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
export async function clearWidgetData(): Promise<void> {
|
|
2922
|
+
if (Platform.OS === 'ios') {
|
|
2923
|
+
await WidgetModule.setWidgetData('activeSession', null);
|
|
2924
|
+
await WidgetModule.reloadAllTimelines();
|
|
2925
|
+
} else if (Platform.OS === 'android') {
|
|
2926
|
+
await WidgetModule.updateWidgetData('activeSession', null);
|
|
2927
|
+
await WidgetModule.requestWidgetUpdate();
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
// Hook to keep widget updated
|
|
2932
|
+
export function useWidgetSync() {
|
|
2933
|
+
const { activeSessionId, sessions } = useSessionStore();
|
|
2934
|
+
|
|
2935
|
+
useEffect(() => {
|
|
2936
|
+
if (activeSessionId && sessions[activeSessionId]) {
|
|
2937
|
+
const session = sessions[activeSessionId];
|
|
2938
|
+
updateWidget({
|
|
2939
|
+
id: session.id,
|
|
2940
|
+
name: session.name,
|
|
2941
|
+
status: session.status,
|
|
2942
|
+
progress: session.progress,
|
|
2943
|
+
currentTask: session.currentTask,
|
|
2944
|
+
agentCount: session.agents.length,
|
|
2945
|
+
});
|
|
2946
|
+
} else {
|
|
2947
|
+
clearWidgetData();
|
|
2948
|
+
}
|
|
2949
|
+
}, [activeSessionId, sessions]);
|
|
2950
|
+
}
|
|
2951
|
+
```
|
|
2952
|
+
|
|
2953
|
+
---
|
|
2954
|
+
|
|
2955
|
+
## 8. Commands
|
|
2956
|
+
|
|
2957
|
+
### /elsabro:mobile Commands
|
|
2958
|
+
|
|
2959
|
+
| Command | Description | Options |
|
|
2960
|
+
|---------|-------------|---------|
|
|
2961
|
+
| `/elsabro:mobile setup` | Initialize mobile companion | `--platform ios\|android\|both` |
|
|
2962
|
+
| `/elsabro:mobile sync` | Force synchronization | `--full`, `--sessions-only` |
|
|
2963
|
+
| `/elsabro:mobile notify` | Manage notifications | `--test`, `--status`, `--clear` |
|
|
2964
|
+
| `/elsabro:mobile voice` | Voice input control | `--enable`, `--disable`, `--test` |
|
|
2965
|
+
|
|
2966
|
+
### Command Implementation
|
|
2967
|
+
|
|
2968
|
+
```typescript
|
|
2969
|
+
// src/commands/mobile.ts
|
|
2970
|
+
import { useSessionStore } from '@/stores/sessionStore';
|
|
2971
|
+
import { useOfflineStore } from '@/stores/offlineStore';
|
|
2972
|
+
import { useNotificationStore } from '@/stores/notificationStore';
|
|
2973
|
+
import { wsClient } from '@/services/websocket';
|
|
2974
|
+
|
|
2975
|
+
interface CommandResult {
|
|
2976
|
+
success: boolean;
|
|
2977
|
+
message: string;
|
|
2978
|
+
data?: unknown;
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
export async function executeCommand(
|
|
2982
|
+
command: string,
|
|
2983
|
+
args: string[]
|
|
2984
|
+
): Promise<CommandResult> {
|
|
2985
|
+
const [subcommand, ...options] = args;
|
|
2986
|
+
|
|
2987
|
+
switch (subcommand) {
|
|
2988
|
+
case 'setup':
|
|
2989
|
+
return handleSetup(options);
|
|
2990
|
+
case 'sync':
|
|
2991
|
+
return handleSync(options);
|
|
2992
|
+
case 'notify':
|
|
2993
|
+
return handleNotify(options);
|
|
2994
|
+
case 'voice':
|
|
2995
|
+
return handleVoice(options);
|
|
2996
|
+
default:
|
|
2997
|
+
return {
|
|
2998
|
+
success: false,
|
|
2999
|
+
message: `Unknown subcommand: ${subcommand}`,
|
|
3000
|
+
};
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
async function handleSetup(options: string[]): Promise<CommandResult> {
|
|
3005
|
+
const platform = options.find(o => o.startsWith('--platform='))
|
|
3006
|
+
?.split('=')[1] || 'both';
|
|
3007
|
+
|
|
3008
|
+
// Verify dependencies
|
|
3009
|
+
const checks = {
|
|
3010
|
+
notifications: await checkNotificationPermission(),
|
|
3011
|
+
biometrics: await checkBiometricEnrollment(),
|
|
3012
|
+
network: await checkNetworkConnectivity(),
|
|
3013
|
+
};
|
|
3014
|
+
|
|
3015
|
+
return {
|
|
3016
|
+
success: Object.values(checks).every(Boolean),
|
|
3017
|
+
message: 'Mobile setup complete',
|
|
3018
|
+
data: {
|
|
3019
|
+
platform,
|
|
3020
|
+
checks,
|
|
3021
|
+
timestamp: new Date().toISOString(),
|
|
3022
|
+
},
|
|
3023
|
+
};
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
async function handleSync(options: string[]): Promise<CommandResult> {
|
|
3027
|
+
const fullSync = options.includes('--full');
|
|
3028
|
+
const sessionsOnly = options.includes('--sessions-only');
|
|
3029
|
+
|
|
3030
|
+
try {
|
|
3031
|
+
if (fullSync) {
|
|
3032
|
+
// Clear cache and sync everything
|
|
3033
|
+
const offlineStore = useOfflineStore.getState();
|
|
3034
|
+
offlineStore.clearExpiredCache();
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
// Trigger sync
|
|
3038
|
+
wsClient.send({
|
|
3039
|
+
type: 'session:sync:request',
|
|
3040
|
+
payload: {
|
|
3041
|
+
fullSync,
|
|
3042
|
+
sessionsOnly,
|
|
3043
|
+
lastSync: useSessionStore.getState().lastSyncTimestamp,
|
|
3044
|
+
},
|
|
3045
|
+
timestamp: Date.now(),
|
|
3046
|
+
messageId: crypto.randomUUID(),
|
|
3047
|
+
});
|
|
3048
|
+
|
|
3049
|
+
// Also sync pending actions
|
|
3050
|
+
await useOfflineStore.getState().syncPendingActions();
|
|
3051
|
+
|
|
3052
|
+
return {
|
|
3053
|
+
success: true,
|
|
3054
|
+
message: fullSync ? 'Full sync initiated' : 'Sync initiated',
|
|
3055
|
+
data: {
|
|
3056
|
+
pendingActions: useOfflineStore.getState().pendingActions.length,
|
|
3057
|
+
lastSync: useSessionStore.getState().lastSyncTimestamp,
|
|
3058
|
+
},
|
|
3059
|
+
};
|
|
3060
|
+
} catch (error) {
|
|
3061
|
+
return {
|
|
3062
|
+
success: false,
|
|
3063
|
+
message: `Sync failed: ${error}`,
|
|
3064
|
+
};
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
async function handleNotify(options: string[]): Promise<CommandResult> {
|
|
3069
|
+
if (options.includes('--test')) {
|
|
3070
|
+
// Send test notification
|
|
3071
|
+
await Notifications.scheduleNotificationAsync({
|
|
3072
|
+
content: {
|
|
3073
|
+
title: 'ELSABRO Test',
|
|
3074
|
+
body: 'This is a test notification from ELSABRO Mobile',
|
|
3075
|
+
data: { type: 'test' },
|
|
3076
|
+
},
|
|
3077
|
+
trigger: null,
|
|
3078
|
+
});
|
|
3079
|
+
return { success: true, message: 'Test notification sent' };
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
if (options.includes('--status')) {
|
|
3083
|
+
const permissions = await Notifications.getPermissionsAsync();
|
|
3084
|
+
return {
|
|
3085
|
+
success: true,
|
|
3086
|
+
message: 'Notification status',
|
|
3087
|
+
data: {
|
|
3088
|
+
granted: permissions.granted,
|
|
3089
|
+
canAskAgain: permissions.canAskAgain,
|
|
3090
|
+
status: permissions.status,
|
|
3091
|
+
},
|
|
3092
|
+
};
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
if (options.includes('--clear')) {
|
|
3096
|
+
await Notifications.dismissAllNotificationsAsync();
|
|
3097
|
+
await Notifications.setBadgeCountAsync(0);
|
|
3098
|
+
return { success: true, message: 'Notifications cleared' };
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
return { success: false, message: 'No valid option provided' };
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
async function handleVoice(options: string[]): Promise<CommandResult> {
|
|
3105
|
+
if (options.includes('--enable')) {
|
|
3106
|
+
await AsyncStorage.setItem('voice_enabled', 'true');
|
|
3107
|
+
return { success: true, message: 'Voice input enabled' };
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
if (options.includes('--disable')) {
|
|
3111
|
+
await AsyncStorage.setItem('voice_enabled', 'false');
|
|
3112
|
+
return { success: true, message: 'Voice input disabled' };
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
if (options.includes('--test')) {
|
|
3116
|
+
const isAvailable = await Voice.isAvailable();
|
|
3117
|
+
return {
|
|
3118
|
+
success: true,
|
|
3119
|
+
message: 'Voice input status',
|
|
3120
|
+
data: { available: isAvailable },
|
|
3121
|
+
};
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
return { success: false, message: 'No valid option provided' };
|
|
3125
|
+
}
|
|
3126
|
+
```
|
|
3127
|
+
|
|
3128
|
+
---
|
|
3129
|
+
|
|
3130
|
+
## Appendix: Type Definitions
|
|
3131
|
+
|
|
3132
|
+
```typescript
|
|
3133
|
+
// src/types/index.ts
|
|
3134
|
+
|
|
3135
|
+
export interface Session {
|
|
3136
|
+
id: string;
|
|
3137
|
+
name: string;
|
|
3138
|
+
status: 'active' | 'paused' | 'completed' | 'error';
|
|
3139
|
+
agents: string[];
|
|
3140
|
+
currentTask: string | null;
|
|
3141
|
+
progress: number;
|
|
3142
|
+
startedAt: string;
|
|
3143
|
+
lastActivity: string;
|
|
3144
|
+
error?: string;
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
export interface SessionDetail extends Session {
|
|
3148
|
+
logs: SessionLog[];
|
|
3149
|
+
tasks: Task[];
|
|
3150
|
+
metrics: SessionMetrics;
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
export interface SessionLog {
|
|
3154
|
+
id: string;
|
|
3155
|
+
timestamp: string;
|
|
3156
|
+
level: 'info' | 'warn' | 'error' | 'debug';
|
|
3157
|
+
message: string;
|
|
3158
|
+
agentId?: string;
|
|
3159
|
+
metadata?: Record<string, unknown>;
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
export interface Task {
|
|
3163
|
+
id: string;
|
|
3164
|
+
name: string;
|
|
3165
|
+
status: 'pending' | 'running' | 'completed' | 'failed' | 'awaiting_approval';
|
|
3166
|
+
assignedAgent: string;
|
|
3167
|
+
createdAt: string;
|
|
3168
|
+
completedAt?: string;
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
export interface SessionMetrics {
|
|
3172
|
+
tokenUsage: number;
|
|
3173
|
+
estimatedCost: number;
|
|
3174
|
+
duration: number;
|
|
3175
|
+
tasksCompleted: number;
|
|
3176
|
+
tasksFailed: number;
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
export interface User {
|
|
3180
|
+
id: string;
|
|
3181
|
+
email: string;
|
|
3182
|
+
name: string;
|
|
3183
|
+
avatar?: string;
|
|
3184
|
+
preferences: UserPreferences;
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
export interface UserPreferences {
|
|
3188
|
+
theme: 'light' | 'dark' | 'system';
|
|
3189
|
+
notifications: NotificationPreferences;
|
|
3190
|
+
voiceEnabled: boolean;
|
|
3191
|
+
hapticFeedback: boolean;
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
export interface NotificationPreferences {
|
|
3195
|
+
taskCompleted: boolean;
|
|
3196
|
+
buildFailed: boolean;
|
|
3197
|
+
approvalNeeded: boolean;
|
|
3198
|
+
sessionUpdates: boolean;
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
export interface LoginCredentials {
|
|
3202
|
+
email: string;
|
|
3203
|
+
password: string;
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
export interface CreateSessionRequest {
|
|
3207
|
+
name: string;
|
|
3208
|
+
agents: string[];
|
|
3209
|
+
config?: Record<string, unknown>;
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
export interface CommandResponse {
|
|
3213
|
+
success: boolean;
|
|
3214
|
+
message: string;
|
|
3215
|
+
data?: unknown;
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
export interface SessionFilters {
|
|
3219
|
+
status?: Session['status'][];
|
|
3220
|
+
search?: string;
|
|
3221
|
+
sortBy?: 'name' | 'startedAt' | 'lastActivity';
|
|
3222
|
+
sortOrder?: 'asc' | 'desc';
|
|
3223
|
+
}
|
|
3224
|
+
```
|
|
3225
|
+
|
|
3226
|
+
---
|
|
3227
|
+
|
|
3228
|
+
## Version History
|
|
3229
|
+
|
|
3230
|
+
| Version | Date | Changes |
|
|
3231
|
+
|---------|------|---------|
|
|
3232
|
+
| 3.7.0 | 2026-02-02 | Initial mobile companion documentation |
|
|
3233
|
+
|
|
3234
|
+
---
|
|
3235
|
+
|
|
3236
|
+
*ELSABRO Mobile Companion - Technical Reference v3.7*
|