@ateam-ai-mobile/expo 0.1.19 → 0.1.21
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/dist/aiLive/provider.d.mts +20 -0
- package/dist/aiLive/provider.d.ts +20 -0
- package/dist/aiLive/provider.js +400 -0
- package/dist/aiLive/provider.js.map +1 -0
- package/dist/aiLive/provider.mjs +15 -0
- package/dist/aiLive/provider.mjs.map +1 -0
- package/dist/bootstrap.js +350 -3
- package/dist/bootstrap.js.map +1 -1
- package/dist/bootstrap.mjs +17 -12
- package/dist/bootstrap.mjs.map +1 -1
- package/dist/capabilities/index.d.mts +40 -1
- package/dist/capabilities/index.d.ts +40 -1
- package/dist/capabilities/index.js +50 -14
- package/dist/capabilities/index.js.map +1 -1
- package/dist/capabilities/index.mjs +49 -32
- package/dist/capabilities/index.mjs.map +1 -1
- package/dist/{chunk-42FP5DTO.mjs → chunk-2ZQEQJQ6.mjs} +1 -1
- package/dist/chunk-2ZQEQJQ6.mjs.map +1 -0
- package/dist/chunk-AE55BKYV.mjs +24 -0
- package/dist/chunk-AE55BKYV.mjs.map +1 -0
- package/dist/chunk-BAZFMTCC.mjs +815 -0
- package/dist/chunk-BAZFMTCC.mjs.map +1 -0
- package/dist/{chunk-Z5AI2SXX.mjs → chunk-C62NQA4F.mjs} +203 -1004
- package/dist/chunk-C62NQA4F.mjs.map +1 -0
- package/dist/{chunk-DE6Y4VRI.mjs → chunk-GA7DS3PA.mjs} +1 -1
- package/dist/{chunk-DE6Y4VRI.mjs.map → chunk-GA7DS3PA.mjs.map} +1 -1
- package/dist/chunk-GW7YGXOW.mjs +47 -0
- package/dist/chunk-GW7YGXOW.mjs.map +1 -0
- package/dist/chunk-JUBLG3PN.mjs +351 -0
- package/dist/chunk-JUBLG3PN.mjs.map +1 -0
- package/dist/{chunk-Z3P7RUCC.mjs → chunk-OEX4OKYQ.mjs} +13 -13
- package/dist/{chunk-6JX222YX.mjs → chunk-WQK6PUBN.mjs} +9 -2
- package/dist/chunk-WQK6PUBN.mjs.map +1 -0
- package/dist/device/createDeviceProvider.js.map +1 -1
- package/dist/device/createDeviceProvider.mjs +7 -6
- package/dist/device-bridge/index.mjs +8 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/push/index.d.mts +24 -0
- package/dist/push/index.d.ts +24 -0
- package/dist/push/index.js +224 -0
- package/dist/push/index.js.map +1 -0
- package/dist/push/index.mjs +174 -0
- package/dist/push/index.mjs.map +1 -0
- package/dist/push/silentSync.d.mts +8 -0
- package/dist/push/silentSync.d.ts +8 -0
- package/dist/push/silentSync.js +1075 -0
- package/dist/push/silentSync.js.map +1 -0
- package/dist/push/silentSync.mjs +67 -0
- package/dist/push/silentSync.mjs.map +1 -0
- package/dist/vision/index.d.mts +7 -1
- package/dist/vision/index.d.ts +7 -1
- package/dist/vision/index.js +8 -0
- package/dist/vision/index.js.map +1 -1
- package/dist/vision/index.mjs +3 -1
- package/ios/AdaVisionModule.swift +44 -1
- package/package.json +28 -6
- package/src/aiLive/provider.ts +242 -0
- package/src/aiLive/streamProtocol.ts +90 -0
- package/src/bootstrap.ts +7 -1
- package/src/capabilities/choices.ts +65 -0
- package/src/capabilities/config.ts +5 -4
- package/src/capabilities/index.ts +2 -0
- package/src/capabilities/picturesToAi.ts +40 -0
- package/src/capabilities/store.ts +38 -37
- package/src/index.ts +3 -0
- package/src/push/index.ts +18 -0
- package/src/push/messaging.ts +49 -0
- package/src/push/registration.ts +31 -0
- package/src/push/silentSync.ts +83 -0
- package/src/push/usePushNotifications.ts +224 -0
- package/src/vision/index.ts +14 -0
- package/dist/chunk-42FP5DTO.mjs.map +0 -1
- package/dist/chunk-6JX222YX.mjs.map +0 -1
- package/dist/chunk-Z5AI2SXX.mjs.map +0 -1
- /package/dist/{chunk-Z3P7RUCC.mjs.map → chunk-OEX4OKYQ.mjs.map} +0 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ateam-ai-mobile/expo",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Expo-backed providers for the ADAS SDK
|
|
3
|
+
"version": "0.1.21",
|
|
4
|
+
"description": "Expo-backed providers for the ADAS SDK \u2014 haptics, bundle disk cache, attachment cache, device bridge.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
"build": "tsup"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@ateam-ai-mobile/core": "0.1.
|
|
13
|
-
"@ateam-ai-mobile/plugin-runtime": "0.1.
|
|
14
|
-
"@ateam-ai-mobile/ui": "0.1.
|
|
12
|
+
"@ateam-ai-mobile/core": "0.1.21",
|
|
13
|
+
"@ateam-ai-mobile/plugin-runtime": "0.1.21",
|
|
14
|
+
"@ateam-ai-mobile/ui": "0.1.21",
|
|
15
15
|
"zustand": "^5.0.0"
|
|
16
16
|
},
|
|
17
17
|
"peerDependencies": {
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"expo-task-manager": "*",
|
|
41
41
|
"expo-web-browser": "*",
|
|
42
42
|
"react": ">=18",
|
|
43
|
-
"react-native": "*"
|
|
43
|
+
"react-native": "*",
|
|
44
|
+
"@react-native-firebase/messaging": "*"
|
|
44
45
|
},
|
|
45
46
|
"peerDependenciesMeta": {
|
|
46
47
|
"@react-native-google-signin/google-signin": {
|
|
@@ -99,6 +100,9 @@
|
|
|
99
100
|
},
|
|
100
101
|
"expo-modules-core": {
|
|
101
102
|
"optional": true
|
|
103
|
+
},
|
|
104
|
+
"@react-native-firebase/messaging": {
|
|
105
|
+
"optional": true
|
|
102
106
|
}
|
|
103
107
|
},
|
|
104
108
|
"exports": {
|
|
@@ -132,6 +136,12 @@
|
|
|
132
136
|
"import": "./dist/vision/index.mjs",
|
|
133
137
|
"require": "./dist/vision/index.js"
|
|
134
138
|
},
|
|
139
|
+
"./ai-live": {
|
|
140
|
+
"react-native": "./src/aiLive/provider.ts",
|
|
141
|
+
"types": "./dist/aiLive/provider.d.ts",
|
|
142
|
+
"import": "./dist/aiLive/provider.mjs",
|
|
143
|
+
"require": "./dist/aiLive/provider.js"
|
|
144
|
+
},
|
|
135
145
|
"./maps": {
|
|
136
146
|
"react-native": "./src/maps/index.ts",
|
|
137
147
|
"types": "./dist/maps/index.d.ts",
|
|
@@ -161,6 +171,18 @@
|
|
|
161
171
|
"types": "./dist/capabilities/index.d.ts",
|
|
162
172
|
"import": "./dist/capabilities/index.mjs",
|
|
163
173
|
"require": "./dist/capabilities/index.js"
|
|
174
|
+
},
|
|
175
|
+
"./push": {
|
|
176
|
+
"react-native": "./src/push/index.ts",
|
|
177
|
+
"types": "./dist/push/index.d.ts",
|
|
178
|
+
"import": "./dist/push/index.mjs",
|
|
179
|
+
"require": "./dist/push/index.js"
|
|
180
|
+
},
|
|
181
|
+
"./silent-push": {
|
|
182
|
+
"react-native": "./src/push/silentSync.ts",
|
|
183
|
+
"types": "./dist/push/silentSync.d.ts",
|
|
184
|
+
"import": "./dist/push/silentSync.mjs",
|
|
185
|
+
"require": "./dist/push/silentSync.js"
|
|
164
186
|
}
|
|
165
187
|
},
|
|
166
188
|
"module": "dist/index.mjs",
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI live provider: this phone's camera → the workspace's live session → the chosen AI,
|
|
3
|
+
* and what the AI saw back to the screen. Installed by the host once at startup:
|
|
4
|
+
* import { createAiLiveProvider } from '@ateam-ai-mobile/expo/ai-live';
|
|
5
|
+
* setAiLiveProvider(createAiLiveProvider());
|
|
6
|
+
* A plugin reaches it only through props.aiLive,
|
|
7
|
+
* whose gate (plugin-runtime aiLiveHost) has already checked the two-sided consent.
|
|
8
|
+
*
|
|
9
|
+
* One camera, one sender: while one screen sends, another's start() is refused by name.
|
|
10
|
+
*
|
|
11
|
+
* start():
|
|
12
|
+
* 1. the live session: reuse the one running (an admin may have started it on the AI Models
|
|
13
|
+
* page) or start one for the phone with a capped money slice (PHONE_SLICE_USD), and remember
|
|
14
|
+
* that this phone started it, so stop() ends only what it began;
|
|
15
|
+
* 2. the camera: the screen's held visual session on the requested camera;
|
|
16
|
+
* 3. Core's stream endpoint (/ws/stream, kind "ai-models.live"), the login in the subprotocol;
|
|
17
|
+
* 4. once Core says "opened": one picture per slot at the TESTED rate and size (the session's
|
|
18
|
+
* `camera`), from the newest fresh frame, as a JPEG made by native (jpegById). A slot with no
|
|
19
|
+
* fresh frame, or while the socket is still busy, sends nothing and still takes its sequence
|
|
20
|
+
* number, so Core reports it as a gap;
|
|
21
|
+
* 5. {"event":"episode"} from Core → the screen's onEpisode callbacks.
|
|
22
|
+
*/
|
|
23
|
+
import { getCoreWsUrl, getTenant, getToken, mcpToolCall, unwrapMcpResult } from '@ateam-ai-mobile/core';
|
|
24
|
+
import type { AiLiveEpisode, AiLiveProvider, AiLiveSnapshot, AiLiveStartOptions, DeviceState, Subscription } from '@ateam-ai-mobile/plugin-runtime';
|
|
25
|
+
import { jpegById as nativeJpegById } from '../vision';
|
|
26
|
+
import { CLOSE_MESSAGE, STREAM_PATH, SUBPROTOCOL, base64ToBytes, describeClose, encodeFrame, openMessage, parseServerEvent } from './streamProtocol';
|
|
27
|
+
|
|
28
|
+
/** The most one phone session may spend (owner decision 2026-10-29), held from the AI Models budget. */
|
|
29
|
+
export const PHONE_SLICE_USD = 0.5;
|
|
30
|
+
export const LIVE_KIND = 'ai-models.live';
|
|
31
|
+
/** A frame older than this is not "what the camera sees now" and is not sent. */
|
|
32
|
+
const FRESH_MS = 3000;
|
|
33
|
+
/** Bytes still waiting in the socket above which a slot is skipped rather than queued. */
|
|
34
|
+
const MAX_BUFFERED = 512 * 1024;
|
|
35
|
+
const DEFAULT_CAMERA = { fps: 1, maxPx: 768 };
|
|
36
|
+
|
|
37
|
+
type Visual = DeviceState['visual'];
|
|
38
|
+
|
|
39
|
+
export interface AiLiveDeps {
|
|
40
|
+
callCp(method: string, params?: Record<string, unknown>): Promise<any>;
|
|
41
|
+
WebSocketImpl: any;
|
|
42
|
+
wsBase(): string;
|
|
43
|
+
token(): string | null;
|
|
44
|
+
tenant(): string | null;
|
|
45
|
+
jpegById(id: string, maxSide: number, quality: number): Promise<string>;
|
|
46
|
+
now(): number;
|
|
47
|
+
setInterval(fn: () => void, ms: number): any;
|
|
48
|
+
clearInterval(handle: any): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SESSION_UP = new Set(['starting', 'running', 'degraded']);
|
|
52
|
+
|
|
53
|
+
async function defaultCallCp(method: string, params: Record<string, unknown> = {}): Promise<any> {
|
|
54
|
+
const res = await mcpToolCall('cp.ai_models_api', { method, params });
|
|
55
|
+
if (res?.error) throw new Error(res.error.message || 'AI Models refused the request');
|
|
56
|
+
const out = unwrapMcpResult(res?.result);
|
|
57
|
+
if (res?.result?.isError) throw new Error(typeof out === 'string' ? out : out?.message || out?.error || 'AI Models refused the request');
|
|
58
|
+
if (out && typeof out === 'object' && out.ok === false) throw new Error(out.message || out.error || 'AI Models refused the request');
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const defaultAiLiveDeps = (): AiLiveDeps => ({
|
|
63
|
+
callCp: defaultCallCp,
|
|
64
|
+
WebSocketImpl: (globalThis as any).WebSocket,
|
|
65
|
+
wsBase: getCoreWsUrl,
|
|
66
|
+
token: getToken,
|
|
67
|
+
tenant: getTenant,
|
|
68
|
+
jpegById: nativeJpegById,
|
|
69
|
+
now: Date.now,
|
|
70
|
+
setInterval: (fn, ms) => setInterval(fn, ms),
|
|
71
|
+
clearInterval: (h) => clearInterval(h),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const idle = (): AiLiveSnapshot => ({ state: 'idle', camera: null, sent: 0, skipped: 0, lastEpisode: null, lastError: null });
|
|
75
|
+
|
|
76
|
+
export function createAiLiveProvider(overrides: Partial<AiLiveDeps> = {}): AiLiveProvider {
|
|
77
|
+
const deps: AiLiveDeps = { ...defaultAiLiveDeps(), ...overrides };
|
|
78
|
+
let owner: string | null = null; // the screen sending now
|
|
79
|
+
let snap: AiLiveSnapshot = idle();
|
|
80
|
+
let ws: any = null;
|
|
81
|
+
let loop: any = null;
|
|
82
|
+
let visual: Visual | null = null;
|
|
83
|
+
let startedSession = false; // this phone started the live session, so it stops it
|
|
84
|
+
let closingOnPurpose = false;
|
|
85
|
+
let seq = 0;
|
|
86
|
+
let busy = false;
|
|
87
|
+
// Which session a slot belongs to. A slot awaits the camera and the JPEG maker; when the screen
|
|
88
|
+
// stops (or another starts) meanwhile, that slot's picture must go nowhere, not into the next one.
|
|
89
|
+
let gen = 0;
|
|
90
|
+
let lastFrameId: string | null = null;
|
|
91
|
+
let camera = DEFAULT_CAMERA;
|
|
92
|
+
let lastOwner: string | null = null; // whose the current snapshot is (it outlives a stop)
|
|
93
|
+
// Each screen hears only its own session: callbacks are kept per holder.
|
|
94
|
+
const episodeCbs = new Map<string, Set<(ev: AiLiveEpisode) => void>>();
|
|
95
|
+
const snapCbs = new Map<string, Set<(s: AiLiveSnapshot) => void>>();
|
|
96
|
+
const sendingCbs = new Set<() => void>();
|
|
97
|
+
let wasSending = false;
|
|
98
|
+
const add = <T>(m: Map<string, Set<T>>, who: string, cb: T): Subscription => {
|
|
99
|
+
let set = m.get(who);
|
|
100
|
+
if (!set) { set = new Set(); m.set(who, set); }
|
|
101
|
+
set.add(cb);
|
|
102
|
+
return { unsubscribe: () => { set!.delete(cb); } };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const set = (patch: Partial<AiLiveSnapshot>) => {
|
|
106
|
+
snap = { ...snap, ...patch };
|
|
107
|
+
for (const cb of (lastOwner && snapCbs.get(lastOwner)) || []) { try { cb(snap); } catch { /* a listener's error is its own */ } }
|
|
108
|
+
const sending = snap.state === 'starting' || snap.state === 'sending';
|
|
109
|
+
if (sending !== wasSending) { wasSending = sending; for (const cb of sendingCbs) { try { cb(); } catch { /* idem */ } } }
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function stopTimers() {
|
|
113
|
+
if (loop != null) { deps.clearInterval(loop); loop = null; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function slot() {
|
|
117
|
+
const mySeq = seq++; // every slot takes a number, sent or not
|
|
118
|
+
const g = gen;
|
|
119
|
+
const sock = ws;
|
|
120
|
+
const current = () => g === gen && ws === sock && sock?.readyState === 1;
|
|
121
|
+
if (busy || !current()) { set({ skipped: snap.skipped + 1 }); return; }
|
|
122
|
+
if ((sock.bufferedAmount || 0) > MAX_BUFFERED) { set({ skipped: snap.skipped + 1 }); return; }
|
|
123
|
+
busy = true;
|
|
124
|
+
try {
|
|
125
|
+
const v = await visual!.getSnapshot();
|
|
126
|
+
if (!current()) return;
|
|
127
|
+
const f: any = v?.latest;
|
|
128
|
+
const id = f?.handle?.id;
|
|
129
|
+
const t = Number(f?.timestamp);
|
|
130
|
+
if (!id || id === lastFrameId || !(deps.now() - t < FRESH_MS)) { set({ skipped: snap.skipped + 1 }); return; }
|
|
131
|
+
const b64 = await deps.jpegById(id, camera.maxPx, 0.7);
|
|
132
|
+
if (!current()) return;
|
|
133
|
+
const frame = encodeFrame({ seq: mySeq, tCapture: t, payload: base64ToBytes(b64) });
|
|
134
|
+
sock.send(frame.buffer);
|
|
135
|
+
lastFrameId = id;
|
|
136
|
+
set({ sent: snap.sent + 1 });
|
|
137
|
+
} catch (e: any) {
|
|
138
|
+
if (g === gen) set({ skipped: snap.skipped + 1, lastError: String(e?.message || e) });
|
|
139
|
+
} finally {
|
|
140
|
+
if (g === gen) busy = false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function openSocket(): Promise<void> {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const token = deps.token();
|
|
147
|
+
const tenant = deps.tenant();
|
|
148
|
+
if (!token || !tenant) { reject(new Error('not signed in to a workspace')); return; }
|
|
149
|
+
const url = `${deps.wsBase()}${STREAM_PATH}?tenant=${encodeURIComponent(tenant)}`;
|
|
150
|
+
let opened = false;
|
|
151
|
+
const sock = new deps.WebSocketImpl(url, [SUBPROTOCOL, `adas.bearer.${token}`]);
|
|
152
|
+
ws = sock;
|
|
153
|
+
sock.binaryType = 'arraybuffer';
|
|
154
|
+
sock.onopen = () => { try { sock.send(openMessage(LIVE_KIND)); } catch (e) { reject(e); } };
|
|
155
|
+
sock.onmessage = (m: any) => {
|
|
156
|
+
const ev = parseServerEvent(m?.data);
|
|
157
|
+
if (!ev) return;
|
|
158
|
+
if (ev.event === 'opened' && !opened) { opened = true; resolve(); return; }
|
|
159
|
+
if (ev.event === 'episode' && ev.data && typeof ev.data === 'object') {
|
|
160
|
+
const episode = ev.data as AiLiveEpisode;
|
|
161
|
+
set({ lastEpisode: episode });
|
|
162
|
+
for (const cb of (owner && episodeCbs.get(owner)) || []) { try { cb(episode); } catch { /* a listener's error is its own */ } }
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
sock.onerror = () => { /* the close that follows says why */ };
|
|
166
|
+
sock.onclose = (c: any) => {
|
|
167
|
+
const why = describeClose(Number(c?.code), c?.reason);
|
|
168
|
+
if (!opened) { reject(new Error(why)); return; }
|
|
169
|
+
if (closingOnPurpose || ws !== sock) return;
|
|
170
|
+
void teardown('error', why);
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function teardown(state: 'stopped' | 'error', lastError: string | null) {
|
|
176
|
+
const hadSession = startedSession;
|
|
177
|
+
startedSession = false;
|
|
178
|
+
gen++;
|
|
179
|
+
stopTimers();
|
|
180
|
+
const sock = ws;
|
|
181
|
+
ws = null;
|
|
182
|
+
closingOnPurpose = true;
|
|
183
|
+
try { if (sock && sock.readyState === 1) sock.send(CLOSE_MESSAGE); } catch { /* closing anyway */ }
|
|
184
|
+
try { sock?.close?.(); } catch { /* already closed */ }
|
|
185
|
+
closingOnPurpose = false;
|
|
186
|
+
const v = visual;
|
|
187
|
+
visual = null;
|
|
188
|
+
try { await v?.stop?.(); } catch { /* the camera hold is released by the gate too */ }
|
|
189
|
+
if (hadSession) { try { await deps.callCp('liveStop'); } catch { /* the session ends by itself when no picture arrives */ } }
|
|
190
|
+
owner = null;
|
|
191
|
+
set({ state, lastError });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
async start(opts: AiLiveStartOptions, who: string, ctx: { visual: Visual }) {
|
|
196
|
+
if (owner && owner !== who) throw Object.assign(new Error('aiLive: another screen is sending to AI right now. Close it first.'), { code: 'CONFLICT' });
|
|
197
|
+
if (owner === who) return snap;
|
|
198
|
+
const cam = opts?.camera === 'front' ? 'front' : 'back';
|
|
199
|
+
if (opts?.camera && opts.camera !== 'front' && opts.camera !== 'back') throw new Error(`aiLive: unknown camera "${String(opts.camera)}" — use "front" or "back"`);
|
|
200
|
+
owner = who;
|
|
201
|
+
lastOwner = who;
|
|
202
|
+
visual = ctx.visual;
|
|
203
|
+
gen++; seq = 0; lastFrameId = null; busy = false;
|
|
204
|
+
snap = { ...idle(), camera: cam };
|
|
205
|
+
set({ state: 'starting' });
|
|
206
|
+
try {
|
|
207
|
+
let session = (await deps.callCp('liveStatus'))?.session;
|
|
208
|
+
if (!SESSION_UP.has(session?.state)) {
|
|
209
|
+
session = (await deps.callCp('liveStart', { source: 'phone', budgetSliceUsd: PHONE_SLICE_USD }))?.session;
|
|
210
|
+
startedSession = true;
|
|
211
|
+
}
|
|
212
|
+
const c = session?.camera;
|
|
213
|
+
camera = c && Number(c.fps) > 0 && Number(c.maxPx) > 0 ? { fps: Number(c.fps), maxPx: Number(c.maxPx) } : DEFAULT_CAMERA;
|
|
214
|
+
await visual.start({ intent: 'stream', facing: cam } as any);
|
|
215
|
+
await openSocket();
|
|
216
|
+
loop = deps.setInterval(() => { void slot(); }, Math.max(200, Math.round(1000 / camera.fps)));
|
|
217
|
+
set({ state: 'sending' });
|
|
218
|
+
return snap;
|
|
219
|
+
} catch (e: any) {
|
|
220
|
+
await teardown('error', String(e?.message || e));
|
|
221
|
+
throw e;
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
async stop(who: string) {
|
|
225
|
+
if (owner !== who) return;
|
|
226
|
+
await teardown('stopped', null);
|
|
227
|
+
},
|
|
228
|
+
getSnapshot: (who: string) => (lastOwner === who ? snap : idle()),
|
|
229
|
+
onEpisode: (cb, who) => add(episodeCbs, who, cb),
|
|
230
|
+
subscribe: (cb, who) => add(snapCbs, who, cb),
|
|
231
|
+
forget(who: string) {
|
|
232
|
+
episodeCbs.delete(who);
|
|
233
|
+
snapCbs.delete(who);
|
|
234
|
+
if (lastOwner === who && owner === null) { lastOwner = null; snap = idle(); }
|
|
235
|
+
},
|
|
236
|
+
isSending: () => snap.state === 'starting' || snap.state === 'sending',
|
|
237
|
+
subscribeSending(cb) {
|
|
238
|
+
sendingCbs.add(cb);
|
|
239
|
+
return { unsubscribe: () => { sendingCbs.delete(cb); } } as Subscription;
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire protocol of Core's binary stream endpoint (/ws/stream), for the AI live sender.
|
|
3
|
+
*
|
|
4
|
+
* A PORT of Core's `packages/media-stream/src/index.js` (@ateam-ai/media-stream, not on npm
|
|
5
|
+
* yet), limited to what a sender needs. The frame bytes are pinned by
|
|
6
|
+
* tests/aiLiveStreamProtocol.test.mjs against the layout that file documents, so a drift is
|
|
7
|
+
* a red test, not a silent mismatch. DEAD — replace with an import of @ateam-ai/media-stream
|
|
8
|
+
* once Core publishes it.
|
|
9
|
+
*
|
|
10
|
+
* Frame envelope (bytes): 0 version (1) · 1 flags · 2-5 seq uint32 BE (one per sampling slot
|
|
11
|
+
* of the SENDER, taken or skipped) · 6-13 tCapture float64 BE (ms since the epoch) · 14- payload.
|
|
12
|
+
*/
|
|
13
|
+
export const STREAM_PATH = '/ws/stream';
|
|
14
|
+
export const SUBPROTOCOL = 'adas.v1';
|
|
15
|
+
export const HEADER_BYTES = 14;
|
|
16
|
+
const ENVELOPE_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
export const CLOSE = Object.freeze({
|
|
19
|
+
AUTH_MISSING: 4001,
|
|
20
|
+
AUTH_FAILED: 4002,
|
|
21
|
+
NO_SUCH_WORKSPACE: 4003,
|
|
22
|
+
NOT_ENTITLED: 4004,
|
|
23
|
+
BAD_REQUEST: 4400,
|
|
24
|
+
UNKNOWN_KIND: 4404,
|
|
25
|
+
TIMEOUT: 4408,
|
|
26
|
+
OVER_LIMIT: 4429,
|
|
27
|
+
TOO_BIG: 1009,
|
|
28
|
+
CONSUMER_FAILING: 1011,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export function encodeFrame({ seq, tCapture, payload }: { seq: number; tCapture: number; payload: Uint8Array }): Uint8Array {
|
|
32
|
+
const out = new Uint8Array(HEADER_BYTES + payload.length);
|
|
33
|
+
const v = new DataView(out.buffer);
|
|
34
|
+
v.setUint8(0, ENVELOPE_VERSION);
|
|
35
|
+
v.setUint8(1, 0);
|
|
36
|
+
v.setUint32(2, seq >>> 0, false);
|
|
37
|
+
v.setFloat64(6, tCapture, false);
|
|
38
|
+
out.set(payload, HEADER_BYTES);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function openMessage(kind: string): string {
|
|
43
|
+
return JSON.stringify({ type: 'open', kind });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const CLOSE_MESSAGE = JSON.stringify({ type: 'close' });
|
|
47
|
+
|
|
48
|
+
/** A server text frame → { event, data }, or null for anything that is not an event. */
|
|
49
|
+
export function parseServerEvent(text: unknown): { event: string; data: any } | null {
|
|
50
|
+
if (typeof text !== 'string') return null;
|
|
51
|
+
try {
|
|
52
|
+
const m = JSON.parse(text);
|
|
53
|
+
return m && typeof m.event === 'string' ? { event: m.event, data: m.data } : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const WHY: Record<number, string> = {
|
|
60
|
+
[CLOSE.AUTH_MISSING]: 'The login or the workspace was missing. Sign in again and try once more.',
|
|
61
|
+
[CLOSE.AUTH_FAILED]: 'The login was refused. Sign in again and try once more.',
|
|
62
|
+
[CLOSE.NO_SUCH_WORKSPACE]: 'This workspace was not found.',
|
|
63
|
+
[CLOSE.NOT_ENTITLED]: 'This workspace may not stream right now.',
|
|
64
|
+
[CLOSE.TIMEOUT]: 'The connection to the AI timed out.',
|
|
65
|
+
[CLOSE.OVER_LIMIT]: 'Too many cameras are sending to this workspace. Stop another one first.',
|
|
66
|
+
[CLOSE.TOO_BIG]: 'A picture was too big to send.',
|
|
67
|
+
[CLOSE.CONSUMER_FAILING]: 'The AI stopped taking pictures. Check the live session in AI Models.',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** A close as one plain sentence: the server's own words for a refused open. */
|
|
71
|
+
export function describeClose(code: number, reason?: string): string {
|
|
72
|
+
if (code === CLOSE.BAD_REQUEST && reason) return reason;
|
|
73
|
+
return WHY[code] || (reason ? reason : `The connection to the AI closed (${code}).`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
77
|
+
const B64_INDEX = (() => { const t = new Int16Array(256).fill(-1); for (let i = 0; i < 64; i++) t[B64.charCodeAt(i)] = i; return t; })();
|
|
78
|
+
|
|
79
|
+
/** Base64 (the native module's JPEG) → bytes. Ignores padding and line breaks. */
|
|
80
|
+
export function base64ToBytes(b64: string): Uint8Array {
|
|
81
|
+
const clean = b64.replace(/[^A-Za-z0-9+/]/g, '');
|
|
82
|
+
const out = new Uint8Array(Math.floor((clean.length * 3) / 4));
|
|
83
|
+
let o = 0, acc = 0, bits = 0;
|
|
84
|
+
for (let i = 0; i < clean.length; i++) {
|
|
85
|
+
acc = (acc << 6) | B64_INDEX[clean.charCodeAt(i)];
|
|
86
|
+
bits += 6;
|
|
87
|
+
if (bits >= 8) { bits -= 8; out[o++] = (acc >> bits) & 0xff; }
|
|
88
|
+
}
|
|
89
|
+
return o === out.length ? out : out.subarray(0, o);
|
|
90
|
+
}
|
package/src/bootstrap.ts
CHANGED
|
@@ -29,12 +29,13 @@
|
|
|
29
29
|
* this module-load bootstrap's scope).
|
|
30
30
|
*/
|
|
31
31
|
import { initAdas, setAcsReachabilityReporter, type TokenProvider, type AdasUser, type AdasSession } from '@ateam-ai-mobile/core';
|
|
32
|
-
import { setDeviceStateProvider, registerNativePlugin, registerPlatformDeviceTools, type RegisteredPlugin } from '@ateam-ai-mobile/plugin-runtime';
|
|
32
|
+
import { setDeviceStateProvider, registerNativePlugin, registerPlatformDeviceTools, setAiLiveProvider, type RegisteredPlugin } from '@ateam-ai-mobile/plugin-runtime';
|
|
33
33
|
import { setOAuthProvider, setUIActionNavigator, setPluginWebViewExtraHosts, setCallAudioProvider, type UIActionRoute } from '@ateam-ai-mobile/ui';
|
|
34
34
|
import { createCallAudioProvider } from './audio';
|
|
35
35
|
|
|
36
36
|
import { setupExpoProviders, type ExpoAttachmentCache } from './index';
|
|
37
37
|
import { createDeviceProvider } from './device/createDeviceProvider';
|
|
38
|
+
import { createAiLiveProvider } from './aiLive/provider';
|
|
38
39
|
import { createDeviceState } from './device-state';
|
|
39
40
|
import { createExpoOAuthProvider } from './oauth/expoOAuth';
|
|
40
41
|
import * as deviceConnectivity from './device-bridge/collectors/connectivity';
|
|
@@ -154,6 +155,11 @@ export function bootstrapExpoAdas(opts: BootstrapExpoAdasOptions): { attachmentC
|
|
|
154
155
|
createDeviceState({ connectivity: deviceConnectivity, location: deviceLocation }),
|
|
155
156
|
);
|
|
156
157
|
|
|
158
|
+
// props.aiLive — this phone's camera to the workspace's chosen AI. Each use still
|
|
159
|
+
// needs the plugin's `picturesToAi` AND the person's "Camera pictures to AI"
|
|
160
|
+
// (the capabilities store installs that half), both checked at start().
|
|
161
|
+
setAiLiveProvider(createAiLiveProvider());
|
|
162
|
+
|
|
157
163
|
// LOCAL device tools — register the static, host-owned platform tools
|
|
158
164
|
// (device.echo / device.ping) at load, so the device path answers even before
|
|
159
165
|
// any plugin bundle or sign-in: the platform tier deliberately does not depend
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user's device-sharing choices as saved — the one reader, used by the
|
|
3
|
+
* capabilities store and by a silent-push background sync, which must share
|
|
4
|
+
* only what the user turned on (it loads this file, not the store).
|
|
5
|
+
*/
|
|
6
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
7
|
+
|
|
8
|
+
// The keys the app used before the SDK owned them — existing installs keep their choices.
|
|
9
|
+
export const STORAGE_KEY = 'ateam.capabilities';
|
|
10
|
+
export const SETUP_KEY = 'ateam.capabilities.setup';
|
|
11
|
+
|
|
12
|
+
// Default enabled state per capability (first-time only)
|
|
13
|
+
const DEFAULT_ENABLED: Record<string, boolean> = {
|
|
14
|
+
calendar: true,
|
|
15
|
+
contacts: true,
|
|
16
|
+
location: true,
|
|
17
|
+
battery: true,
|
|
18
|
+
connectivity: true,
|
|
19
|
+
health: true,
|
|
20
|
+
mood: true,
|
|
21
|
+
// Opt-IN: the camera is more invasive than the sync collectors and should not
|
|
22
|
+
// prompt every user for camera access on first launch. It lights up only when
|
|
23
|
+
// the user turns it on (which then requests the OS permission), so a solution
|
|
24
|
+
// that wants a camera preview asks the user to enable it.
|
|
25
|
+
camera: false,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Capabilities hidden from the user-facing list — the bridge never registers
|
|
29
|
+
// them. The host's push-notifications toggle is the single user control; the
|
|
30
|
+
// notifications collector is internal plumbing and shouldn't surface as a
|
|
31
|
+
// separate switch.
|
|
32
|
+
export const HIDDEN_FROM_UI = new Set(['notifications']);
|
|
33
|
+
|
|
34
|
+
export interface SavedChoices {
|
|
35
|
+
setupDone: boolean;
|
|
36
|
+
/** The user's saved toggles; a capability missing here has its default. */
|
|
37
|
+
prefs: Record<string, boolean>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read the saved choices. Throws on a corrupt save — callers decide what that means. */
|
|
41
|
+
export async function loadChoices(): Promise<SavedChoices> {
|
|
42
|
+
const setupDone = await AsyncStorage.getItem(SETUP_KEY);
|
|
43
|
+
const saved = await AsyncStorage.getItem(STORAGE_KEY);
|
|
44
|
+
return { setupDone: setupDone === 'true', prefs: saved ? JSON.parse(saved) : {} };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Whether the user's choices turn this capability on. */
|
|
48
|
+
export function isEnabled(id: string, prefs: Record<string, boolean>): boolean {
|
|
49
|
+
return prefs[id] ?? DEFAULT_ENABLED[id] ?? true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The domains among `domains` this device may sync now: setup done, shown to
|
|
54
|
+
* the user, and turned on. Anything unreadable → none.
|
|
55
|
+
*/
|
|
56
|
+
export async function sharedDomains(domains: string[]): Promise<string[]> {
|
|
57
|
+
let choices: SavedChoices;
|
|
58
|
+
try {
|
|
59
|
+
choices = await loadChoices();
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
if (!choices.setupDone) return [];
|
|
64
|
+
return domains.filter((d) => !HIDDEN_FROM_UI.has(d) && isEnabled(d, choices.prefs));
|
|
65
|
+
}
|
|
@@ -16,10 +16,11 @@ export interface DeviceSyncConfig {
|
|
|
16
16
|
|
|
17
17
|
// Module-private on purpose: plugin bundles run in the same JS context, so the
|
|
18
18
|
// relay key and the secure-storage reader must not sit anywhere they can reach.
|
|
19
|
-
// (The CJS build gives the bootstrap and
|
|
20
|
-
// copy of this file; a require() host calls configureDeviceSync from
|
|
21
|
-
// '@ateam-ai-mobile/expo/capabilities' instead of the bootstrap option
|
|
22
|
-
// uses the source and the ESM
|
|
19
|
+
// (The CJS build gives the bootstrap, capabilities and silent-push entries each
|
|
20
|
+
// their own copy of this file; a require() host calls configureDeviceSync from
|
|
21
|
+
// '@ateam-ai-mobile/expo/capabilities' instead of the bootstrap option, and its
|
|
22
|
+
// silent-push sync finds no config and skips. Metro uses the source and the ESM
|
|
23
|
+
// build shares one copy.)
|
|
23
24
|
let config: DeviceSyncConfig | null = null;
|
|
24
25
|
|
|
25
26
|
export function configureDeviceSync(next: DeviceSyncConfig): void {
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* because the device bridge statically pulls the optional expo modules.
|
|
14
14
|
*/
|
|
15
15
|
export { useCapabilitiesStore } from './store';
|
|
16
|
+
// "Camera pictures to AI": the person's half of the consent props.aiLive needs, and what to tell them first.
|
|
17
|
+
export { picturesToAiAllowed, PICTURES_TO_AI_ASK } from './picturesToAi';
|
|
16
18
|
export type { CapabilityState } from './store';
|
|
17
19
|
export { configureDeviceSync } from './config';
|
|
18
20
|
export type { DeviceSyncConfig } from './config';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Camera pictures to AI" — the phone owner's half of a two-sided consent
|
|
3
|
+
* (owner decision 2026-10-25, amending "frames never leave the phone").
|
|
4
|
+
*
|
|
5
|
+
* A camera picture may go to an AI service off the phone only when the
|
|
6
|
+
* solution's developer declared `picturesToAi` in its manifest AND the person on
|
|
7
|
+
* this phone allowed it here. plugin-runtime's mayPicturesLeavePhone() checks
|
|
8
|
+
* both; this half is installed as its consent provider by the capabilities store.
|
|
9
|
+
*
|
|
10
|
+
* Moved from ateam-mobile (branch pictures-to-ai-switch, 9541f22) into the SDK
|
|
11
|
+
* with the store it belongs to. Pure, so tests run it under plain node.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface PicturesToAiInputs {
|
|
15
|
+
/** The switch under Camera in the host's "What Ada can access". */
|
|
16
|
+
picturesToAi: boolean;
|
|
17
|
+
capabilities: ReadonlyArray<{ id: string; enabled: boolean; osPermission: string }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Derived at read time, never stored: the switch is on AND the camera itself is
|
|
21
|
+
* on and granted by the OS. Anything else answers no. */
|
|
22
|
+
export function picturesToAiAllowed(s: PicturesToAiInputs): boolean {
|
|
23
|
+
if (s.picturesToAi !== true) return false;
|
|
24
|
+
const camera = s.capabilities.find((c) => c.id === 'camera');
|
|
25
|
+
return !!camera && camera.enabled === true && camera.osPermission === 'granted';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What the person is told before allowing it. Apple guideline 5.1.2(i): say
|
|
29
|
+
* where personal data goes before sharing it with an outside AI, and get
|
|
30
|
+
* explicit permission first. */
|
|
31
|
+
export const PICTURES_TO_AI_ASK = {
|
|
32
|
+
title: 'Send camera pictures to AI?',
|
|
33
|
+
message:
|
|
34
|
+
'Solutions that ask for it may send pictures from your camera to an AI service ' +
|
|
35
|
+
'(OpenAI or Anthropic) through A-Team, to understand what the camera sees. ' +
|
|
36
|
+
'Only while such a solution is open, and "Sending to AI" shows while it does. ' +
|
|
37
|
+
'You can turn this off here at any time.',
|
|
38
|
+
allow: 'Allow',
|
|
39
|
+
cancel: 'Not now',
|
|
40
|
+
} as const;
|