@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
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { create } from 'zustand';
|
|
2
2
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
3
3
|
import { getTenant, getUser, onAppForeground } from '@ateam-ai-mobile/core';
|
|
4
|
+
import { setPicturesToAiConsentProvider } from '@ateam-ai-mobile/plugin-runtime';
|
|
4
5
|
import { DeviceBridge, capabilities as capFactories } from '../device-bridge';
|
|
5
6
|
import type { Capability, PermissionState } from '../device-bridge/types';
|
|
6
7
|
import { getOrCreateDeviceId } from '../device-bridge/deviceId';
|
|
7
8
|
import { getDeviceSyncConfig } from './config';
|
|
9
|
+
import { STORAGE_KEY, SETUP_KEY, HIDDEN_FROM_UI, loadChoices, isEnabled } from './choices';
|
|
10
|
+
import { picturesToAiAllowed } from './picturesToAi';
|
|
11
|
+
|
|
12
|
+
// The key the app used for this switch before the SDK owned it (branch pictures-to-ai-switch).
|
|
13
|
+
const PICTURES_TO_AI_KEY = 'ateam.capabilities.picturesToAi';
|
|
8
14
|
|
|
9
15
|
/**
|
|
10
16
|
* Device permissions — the one source of truth for what this device shares.
|
|
@@ -45,6 +51,9 @@ export interface CapabilityState {
|
|
|
45
51
|
|
|
46
52
|
interface CapabilitiesState {
|
|
47
53
|
capabilities: CapabilityState[];
|
|
54
|
+
/** The "Camera pictures to AI" switch under Camera: the person's half of the
|
|
55
|
+
* consent props.aiLive needs. Off until they turn it on (default no). */
|
|
56
|
+
picturesToAi: boolean;
|
|
48
57
|
hasCompletedSetup: boolean;
|
|
49
58
|
isLoading: boolean;
|
|
50
59
|
bridgeRunning: boolean;
|
|
@@ -53,6 +62,8 @@ interface CapabilitiesState {
|
|
|
53
62
|
initialize: () => Promise<void>;
|
|
54
63
|
/** Toggle a capability on/off, persist, and update bridge if running */
|
|
55
64
|
toggleCapability: (id: string, enabled: boolean) => Promise<void>;
|
|
65
|
+
/** Set and persist "Camera pictures to AI". The host asks first (PICTURES_TO_AI_ASK). */
|
|
66
|
+
setPicturesToAi: (on: boolean) => Promise<void>;
|
|
56
67
|
/** Update OS permission state for a capability */
|
|
57
68
|
setOsPermission: (id: string, state: PermissionState) => void;
|
|
58
69
|
/** Mark initial setup as complete */
|
|
@@ -70,31 +81,6 @@ interface CapabilitiesState {
|
|
|
70
81
|
syncNow: () => Promise<{ ok: boolean; error?: string }>;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
// The keys the app used before the SDK owned them — existing installs keep their choices.
|
|
74
|
-
const STORAGE_KEY = 'ateam.capabilities';
|
|
75
|
-
const SETUP_KEY = 'ateam.capabilities.setup';
|
|
76
|
-
|
|
77
|
-
// Default enabled state per capability (first-time only)
|
|
78
|
-
const DEFAULT_ENABLED: Record<string, boolean> = {
|
|
79
|
-
calendar: true,
|
|
80
|
-
contacts: true,
|
|
81
|
-
location: true,
|
|
82
|
-
battery: true,
|
|
83
|
-
connectivity: true,
|
|
84
|
-
health: true,
|
|
85
|
-
mood: true,
|
|
86
|
-
// Opt-IN: the camera is more invasive than the sync collectors and should not
|
|
87
|
-
// prompt every user for camera access on first launch. It lights up only when
|
|
88
|
-
// the user turns it on (which then requests the OS permission), so a solution
|
|
89
|
-
// that wants a camera preview asks the user to enable it.
|
|
90
|
-
camera: false,
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
// Capabilities hidden from the user-facing list. The host's push-notifications
|
|
94
|
-
// toggle is the single user control; the notifications collector is internal
|
|
95
|
-
// plumbing and shouldn't surface as a separate switch.
|
|
96
|
-
const HIDDEN_FROM_UI = new Set(['notifications']);
|
|
97
|
-
|
|
98
84
|
// Module-level bridge singleton — NOT in Zustand (it's a mutable object with timers)
|
|
99
85
|
let bridge: DeviceBridge | null = null;
|
|
100
86
|
|
|
@@ -242,6 +228,7 @@ export const useCapabilitiesStore = create<CapabilitiesState>((set, get) => {
|
|
|
242
228
|
|
|
243
229
|
return {
|
|
244
230
|
capabilities: [],
|
|
231
|
+
picturesToAi: false,
|
|
245
232
|
hasCompletedSetup: false,
|
|
246
233
|
isLoading: true,
|
|
247
234
|
bridgeRunning: false,
|
|
@@ -253,6 +240,10 @@ export const useCapabilitiesStore = create<CapabilitiesState>((set, get) => {
|
|
|
253
240
|
// (e.g. notifications → the host's notification settings).
|
|
254
241
|
builtInCapabilities = capFactories.allBuiltIn().filter(c => !HIDDEN_FROM_UI.has(c.id));
|
|
255
242
|
|
|
243
|
+
// Saved user choices (override the defaults for returning users)
|
|
244
|
+
const { setupDone, prefs } = await loadChoices();
|
|
245
|
+
const picturesToAi = (await AsyncStorage.getItem(PICTURES_TO_AI_KEY)) === 'true';
|
|
246
|
+
|
|
256
247
|
// Map to UI state (no functions, just data + enabled flag)
|
|
257
248
|
const capStates: CapabilityState[] = builtInCapabilities.map(cap => ({
|
|
258
249
|
id: cap.id,
|
|
@@ -261,25 +252,18 @@ export const useCapabilitiesStore = create<CapabilitiesState>((set, get) => {
|
|
|
261
252
|
reason: cap.reason,
|
|
262
253
|
icon: cap.icon || cap.id,
|
|
263
254
|
requiresPermission: cap.requiresPermission,
|
|
264
|
-
enabled:
|
|
255
|
+
enabled: isEnabled(cap.id, prefs),
|
|
265
256
|
osPermission: cap.permissionState,
|
|
266
257
|
}));
|
|
267
258
|
|
|
268
|
-
// Load saved user preferences (overrides defaults for returning users)
|
|
269
|
-
const setupDone = await AsyncStorage.getItem(SETUP_KEY);
|
|
270
|
-
const saved = await AsyncStorage.getItem(STORAGE_KEY);
|
|
271
|
-
const prefs: Record<string, boolean> = saved ? JSON.parse(saved) : {};
|
|
272
|
-
|
|
273
259
|
set({
|
|
274
|
-
capabilities: capStates
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
})),
|
|
278
|
-
hasCompletedSetup: setupDone === 'true',
|
|
260
|
+
capabilities: capStates,
|
|
261
|
+
picturesToAi,
|
|
262
|
+
hasCompletedSetup: setupDone,
|
|
279
263
|
isLoading: false,
|
|
280
264
|
});
|
|
281
265
|
|
|
282
|
-
console.log(`[Capabilities] Initialized ${capStates.length} capabilities from device-bridge (setup ${setupDone
|
|
266
|
+
console.log(`[Capabilities] Initialized ${capStates.length} capabilities from device-bridge (setup ${setupDone ? 'done' : 'pending'})`);
|
|
283
267
|
} catch (err) {
|
|
284
268
|
console.error('[Capabilities] Initialize failed:', err);
|
|
285
269
|
set({ isLoading: false });
|
|
@@ -301,6 +285,13 @@ export const useCapabilitiesStore = create<CapabilitiesState>((set, get) => {
|
|
|
301
285
|
}
|
|
302
286
|
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
|
303
287
|
|
|
288
|
+
// Turning the camera off also clears "Camera pictures to AI", so turning the
|
|
289
|
+
// camera back on later never silently re-opens that door: the person is asked
|
|
290
|
+
// again. (While the camera is off the consent already reads as no.)
|
|
291
|
+
if (id === 'camera' && !enabled && get().picturesToAi) {
|
|
292
|
+
await get().setPicturesToAi(false);
|
|
293
|
+
}
|
|
294
|
+
|
|
304
295
|
// If bridge is running, dynamically update
|
|
305
296
|
if (bridge && get().bridgeRunning) {
|
|
306
297
|
if (enabled) {
|
|
@@ -324,6 +315,11 @@ export const useCapabilitiesStore = create<CapabilitiesState>((set, get) => {
|
|
|
324
315
|
}
|
|
325
316
|
},
|
|
326
317
|
|
|
318
|
+
setPicturesToAi: async (on: boolean) => {
|
|
319
|
+
set({ picturesToAi: on });
|
|
320
|
+
await AsyncStorage.setItem(PICTURES_TO_AI_KEY, on ? 'true' : 'false');
|
|
321
|
+
},
|
|
322
|
+
|
|
327
323
|
setOsPermission: (id: string, permState: PermissionState) => {
|
|
328
324
|
set(s => ({
|
|
329
325
|
capabilities: s.capabilities.map(cap =>
|
|
@@ -385,3 +381,8 @@ export const useCapabilitiesStore = create<CapabilitiesState>((set, get) => {
|
|
|
385
381
|
},
|
|
386
382
|
};
|
|
387
383
|
});
|
|
384
|
+
|
|
385
|
+
// The person's half of "camera pictures to AI", for plugin-runtime's mayPicturesLeavePhone():
|
|
386
|
+
// read from this store at CALL time (derive, never store). A host that never loads this store
|
|
387
|
+
// has no consent provider, and the answer is no.
|
|
388
|
+
setPicturesToAiConsentProvider(() => picturesToAiAllowed(useCapabilitiesStore.getState()));
|
package/src/index.ts
CHANGED
|
@@ -82,6 +82,9 @@ export function setupExpoProviders(opts?: {
|
|
|
82
82
|
// The host passes this to setDeviceStateProvider(); per-plugin gating by
|
|
83
83
|
// manifest.stateDomains happens in the SDK.
|
|
84
84
|
export { createDeviceState, IMPLEMENTED_DOMAINS } from './device-state';
|
|
85
|
+
|
|
86
|
+
// AI live (props.aiLive) lives on its own subpath, '@ateam-ai-mobile/expo/ai-live', like vision:
|
|
87
|
+
// it uses the native vision module, which this entry must not import.
|
|
85
88
|
export type { DeviceStateCollectors } from './device-state';
|
|
86
89
|
// The mounted half of the Visual engine. The app renders this ONCE; it draws
|
|
87
90
|
// nothing until a visual session is open. Required for deviceState.visual to
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push notifications on this device (FCM via @react-native-firebase/messaging,
|
|
3
|
+
* an optional peer — without it push is off and nothing breaks).
|
|
4
|
+
*
|
|
5
|
+
* For a host app embedding A-Team:
|
|
6
|
+
* - usePushNotifications({ enabled, onOpenApp }) — or startPushNotifications()
|
|
7
|
+
* outside React — while signed in: permission, token → Core, incoming pushes;
|
|
8
|
+
* - pushRegistration — the current token (getToken) and unregister() for your
|
|
9
|
+
* sign-out teardown, run while the session token still exists;
|
|
10
|
+
* - <PushBanner/> from @ateam-ai-mobile/ui — the drop-in in-app banner, or your
|
|
11
|
+
* own UI on usePushBannerStore + routePushTap.
|
|
12
|
+
* The background silent sync is its own subpath, @ateam-ai-mobile/expo/silent-push.
|
|
13
|
+
*
|
|
14
|
+
* A SUBPATH because it statically pulls expo-notifications and expo-device.
|
|
15
|
+
*/
|
|
16
|
+
export { usePushNotifications, startPushNotifications } from './usePushNotifications';
|
|
17
|
+
export type { UsePushNotificationsOptions, PushNotificationsOptions } from './usePushNotifications';
|
|
18
|
+
export { pushRegistration } from './registration';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Firebase Cloud Messaging, if this build has it.
|
|
3
|
+
*
|
|
4
|
+
* @react-native-firebase/messaging is an optional peer: in Expo Go (or a host
|
|
5
|
+
* without Firebase) the JS module may exist but its native module does not, so
|
|
6
|
+
* we probe by calling messaging() once. No native module → null, and push is
|
|
7
|
+
* off (the hook and the background sync become no-ops). Probed on first use,
|
|
8
|
+
* not at import.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The slice of the RNFB messaging API the SDK uses. */
|
|
12
|
+
export interface RemoteMessage {
|
|
13
|
+
notification?: { title?: string; body?: string };
|
|
14
|
+
data?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MessagingInstance {
|
|
18
|
+
requestPermission(): Promise<number>;
|
|
19
|
+
isDeviceRegisteredForRemoteMessages: boolean;
|
|
20
|
+
registerDeviceForRemoteMessages(): Promise<void>;
|
|
21
|
+
getAPNSToken(): Promise<string | null>;
|
|
22
|
+
getToken(): Promise<string>;
|
|
23
|
+
onTokenRefresh(cb: (token: string) => void): () => void;
|
|
24
|
+
onMessage(cb: (msg: RemoteMessage) => void | Promise<void>): () => void;
|
|
25
|
+
onNotificationOpenedApp(cb: (msg: RemoteMessage) => void): () => void;
|
|
26
|
+
getInitialNotification(): Promise<RemoteMessage | null>;
|
|
27
|
+
setBackgroundMessageHandler(cb: (msg: RemoteMessage) => Promise<void>): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Messaging {
|
|
31
|
+
(): MessagingInstance;
|
|
32
|
+
AuthorizationStatus: { AUTHORIZED: number; PROVISIONAL: number };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let probed = false;
|
|
36
|
+
let messaging: Messaging | null = null;
|
|
37
|
+
|
|
38
|
+
export function getMessaging(): Messaging | null {
|
|
39
|
+
if (probed) return messaging;
|
|
40
|
+
probed = true;
|
|
41
|
+
try {
|
|
42
|
+
const candidate = require('@react-native-firebase/messaging').default as Messaging;
|
|
43
|
+
candidate(); // throws "Native module RNFBAppModule not found" in Expo Go
|
|
44
|
+
messaging = candidate;
|
|
45
|
+
} catch {
|
|
46
|
+
console.warn('[adas/push] @react-native-firebase/messaging native module unavailable — push disabled (Expo Go?)');
|
|
47
|
+
}
|
|
48
|
+
return messaging;
|
|
49
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This device's push registration with Core — the one owner of the current
|
|
3
|
+
* push token. The lifecycle (register, re-register on refresh, unregister on
|
|
4
|
+
* sign-out, the sign-out count) is core's createPushRegistration; this wires it
|
|
5
|
+
* to Core's notif-api with the platform. usePushNotifications feeds it tokens;
|
|
6
|
+
* the host's sign-out teardown calls pushRegistration.unregister() while the
|
|
7
|
+
* session token still exists. getToken() answers "is push registered on this
|
|
8
|
+
* device?".
|
|
9
|
+
*/
|
|
10
|
+
import { Platform } from 'react-native';
|
|
11
|
+
import * as Notifications from 'expo-notifications';
|
|
12
|
+
import { createPushRegistration, registerPushDevice, unregisterPushDevice, type PushRegistration } from '@ateam-ai-mobile/core';
|
|
13
|
+
|
|
14
|
+
const lifecycle = createPushRegistration({
|
|
15
|
+
// Adapt to the lifecycle's void contract — the notif-api returns a body it doesn't need.
|
|
16
|
+
registerDevice: async (token) => { await registerPushDevice(token, Platform.OS === 'ios' ? 'ios' : 'android'); },
|
|
17
|
+
unregisterDevice: async (token) => { await unregisterPushDevice(token); },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const pushRegistration: PushRegistration = {
|
|
21
|
+
...lifecycle,
|
|
22
|
+
async unregister() {
|
|
23
|
+
await lifecycle.unregister();
|
|
24
|
+
// Notifications already in the list were for the user who is leaving, and
|
|
25
|
+
// a push carries no recipient: tapped later, one would act in the next
|
|
26
|
+
// user's chat. Best-effort, never blocks sign-out.
|
|
27
|
+
Promise.resolve().then(() => Notifications.dismissAllNotificationsAsync()).catch((e: any) =>
|
|
28
|
+
console.warn('[adas/push] clearing delivered notifications failed:', e?.message || e),
|
|
29
|
+
);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silent-push device sync — the server wakes the phone to refresh device data.
|
|
3
|
+
*
|
|
4
|
+
* Call registerSilentPushSync({ loadToken }) once in your app's entry file,
|
|
5
|
+
* before the app component registers: Firebase wants its background handler
|
|
6
|
+
* set at the top level, outside React. It also needs device sync configured
|
|
7
|
+
* (bootstrapExpoAdas({ deviceSync }) or configureDeviceSync) — read when a push
|
|
8
|
+
* arrives, so the order of the two calls doesn't matter.
|
|
9
|
+
*
|
|
10
|
+
* Contract for a silent (data-only) push:
|
|
11
|
+
* data: { action: "sync", domains: '["location","calendar"]' } (JSON string array)
|
|
12
|
+
* Anything else is ignored here — visible pushes are usePushNotifications'.
|
|
13
|
+
*
|
|
14
|
+
* Syncs as the signed-in user and team from the SAVED session token (the app
|
|
15
|
+
* may be woken with no session restored) and under this install's device id;
|
|
16
|
+
* no token, no user, no team or no device id → nothing is sent. Only domains
|
|
17
|
+
* the user shares are synced (their saved choices), as the foreground bridge does.
|
|
18
|
+
*
|
|
19
|
+
* Moved from ateam-mobile's pushBackgroundHandler (2026-10-29). Changed on the
|
|
20
|
+
* way: it syncs only the domains the user turned on (it synced any domain the
|
|
21
|
+
* push named), and never without a user (it would sync as 'default').
|
|
22
|
+
*/
|
|
23
|
+
import { syncDomains } from '../device-bridge/background';
|
|
24
|
+
import { readDeviceId } from '../device-bridge/deviceId';
|
|
25
|
+
import { getDeviceSyncConfig } from '../capabilities/config';
|
|
26
|
+
import { sharedDomains } from '../capabilities/choices';
|
|
27
|
+
import { tenantFromToken, userFromJwt } from '@ateam-ai-mobile/core';
|
|
28
|
+
import { getMessaging, type RemoteMessage } from './messaging';
|
|
29
|
+
|
|
30
|
+
export interface SilentPushSyncOptions {
|
|
31
|
+
/** The saved session token — the same loader your auth session's persistence uses. */
|
|
32
|
+
loadToken: () => Promise<string | null>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Register the background handler. No Firebase in this build → nothing to do. */
|
|
36
|
+
export function registerSilentPushSync(opts: SilentPushSyncOptions): void {
|
|
37
|
+
const messaging = getMessaging();
|
|
38
|
+
if (!messaging) return;
|
|
39
|
+
messaging().setBackgroundMessageHandler((msg) => handleSilentPush(msg, opts));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function handleSilentPush(msg: RemoteMessage, opts: SilentPushSyncOptions): Promise<void> {
|
|
43
|
+
const data = msg?.data || {};
|
|
44
|
+
if (data.action !== 'sync') return;
|
|
45
|
+
|
|
46
|
+
let requested: unknown = data.domains;
|
|
47
|
+
try {
|
|
48
|
+
if (typeof requested === 'string') requested = JSON.parse(requested);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.warn('[adas/push] silent sync: bad domains:', err);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!Array.isArray(requested)) return;
|
|
54
|
+
const domains = await sharedDomains(requested.filter((d): d is string => typeof d === 'string'));
|
|
55
|
+
if (domains.length === 0) return;
|
|
56
|
+
|
|
57
|
+
const deviceSync = getDeviceSyncConfig();
|
|
58
|
+
if (!deviceSync) {
|
|
59
|
+
console.warn('[adas/push] silent sync skipped: device sync is not configured');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// No id = nothing has synced from this install yet, so nothing to refresh.
|
|
63
|
+
const deviceId = await readDeviceId(deviceSync.secureStorage);
|
|
64
|
+
if (!deviceId) return;
|
|
65
|
+
|
|
66
|
+
// Identity AND team from the session token's own claims — never a stored
|
|
67
|
+
// copy, never a default. Signed out → no token → nothing to sync as.
|
|
68
|
+
const jwt = await opts.loadToken();
|
|
69
|
+
if (!jwt) return;
|
|
70
|
+
const tenant = tenantFromToken(jwt);
|
|
71
|
+
const actorId = userFromJwt(jwt)?.actorId;
|
|
72
|
+
if (!tenant || !actorId) return;
|
|
73
|
+
|
|
74
|
+
console.log(`[adas/push] silent sync: ${domains.join(', ')}`);
|
|
75
|
+
try {
|
|
76
|
+
await syncDomains(
|
|
77
|
+
{ relayUrl: deviceSync.relayUrl(), deviceId, tenant, actorId, apiKey: deviceSync.relayKey },
|
|
78
|
+
domains,
|
|
79
|
+
);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.warn('[adas/push] silent sync failed:', err);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push notifications on this device — the headless half.
|
|
3
|
+
*
|
|
4
|
+
* startPushNotifications() (or the usePushNotifications hook around it) asks
|
|
5
|
+
* for permission, gets this device's FCM token, registers it with Core through
|
|
6
|
+
* pushRegistration, re-registers when Firebase refreshes it, and handles what
|
|
7
|
+
* arrives:
|
|
8
|
+
* - app open: a push with a visible notification goes to the in-app banner
|
|
9
|
+
* (usePushBannerStore → <PushBanner/> from @ateam-ai-mobile/ui); data-only
|
|
10
|
+
* pushes (silent syncs) are not shown;
|
|
11
|
+
* - tapped in the background, or the tap that cold-started the app:
|
|
12
|
+
* routePushTap (surface → prompt → the host's onOpenApp).
|
|
13
|
+
* Run it only while signed in — registering needs the session token. Sign-out
|
|
14
|
+
* unregisters through pushRegistration.unregister() in the host's teardown;
|
|
15
|
+
* from that moment this start adopts no token and routes no tap, even before
|
|
16
|
+
* the host's UI stops it. One push owner per app: a second start replaces the
|
|
17
|
+
* first.
|
|
18
|
+
*
|
|
19
|
+
* Payload from Core's notif-router → FCM:
|
|
20
|
+
* notification: { title, body }
|
|
21
|
+
* data: { jobId, skillSlug, messageId, surfaceId?, pluginId?, prompt? }
|
|
22
|
+
* In the background the OS shows the system banner itself.
|
|
23
|
+
*
|
|
24
|
+
* Moved from ateam-mobile's usePushNotifications (2026-10-29). Changed on the
|
|
25
|
+
* way: the expo-notifications foreground handler is set on start, not at import
|
|
26
|
+
* (an embedding app with its own passes foregroundHandler: false); a sign-out
|
|
27
|
+
* while the token was being fetched or registered no longer registers the
|
|
28
|
+
* signed-out phone or leaves a refresh listener behind; stopping clears the
|
|
29
|
+
* in-app banner; data-only pushes no longer pop an empty banner; the tap that
|
|
30
|
+
* opened the app is routed once, not again after every sign-in.
|
|
31
|
+
*/
|
|
32
|
+
import { useEffect, useRef } from 'react';
|
|
33
|
+
import { Platform } from 'react-native';
|
|
34
|
+
import * as Notifications from 'expo-notifications';
|
|
35
|
+
import * as Device from 'expo-device';
|
|
36
|
+
import { ada } from '@ateam-ai-mobile/core';
|
|
37
|
+
import { routePushTap, usePushBannerStore } from '@ateam-ai-mobile/ui';
|
|
38
|
+
import { getMessaging, type Messaging, type RemoteMessage } from './messaging';
|
|
39
|
+
import { pushRegistration } from './registration';
|
|
40
|
+
|
|
41
|
+
export interface PushNotificationsOptions {
|
|
42
|
+
/** A tapped push that names no surface or prompt — e.g. show your chat screen. */
|
|
43
|
+
onOpenApp?: () => void;
|
|
44
|
+
/**
|
|
45
|
+
* Set the expo-notifications handler so notifications also show while the app
|
|
46
|
+
* is open (banner, list, sound, badge) — this covers the local notifications
|
|
47
|
+
* plugins schedule. Default true; pass false if your app sets its own.
|
|
48
|
+
*/
|
|
49
|
+
foregroundHandler?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface UsePushNotificationsOptions extends PushNotificationsOptions {
|
|
53
|
+
/** Push runs while true — pass "signed in". */
|
|
54
|
+
enabled: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Push while `enabled`; stops (listeners off) when it turns false or on unmount. */
|
|
58
|
+
export function usePushNotifications({ enabled, onOpenApp, foregroundHandler }: UsePushNotificationsOptions): void {
|
|
59
|
+
const onOpenAppRef = useRef(onOpenApp);
|
|
60
|
+
onOpenAppRef.current = onOpenApp;
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!enabled) return;
|
|
64
|
+
return startPushNotifications({ onOpenApp: () => onOpenAppRef.current?.(), foregroundHandler });
|
|
65
|
+
}, [enabled, foregroundHandler]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The tap that cold-started the app is routed once per launch — Firebase keeps
|
|
69
|
+
// returning it, and a sign-out + sign-in starts push again — and never after a
|
|
70
|
+
// sign-out: it was a push for the user signed in at launch.
|
|
71
|
+
let initialTapHandled = false;
|
|
72
|
+
const launchGeneration = pushRegistration.generation();
|
|
73
|
+
let handlerSet = false;
|
|
74
|
+
// The running start's stop — one push owner per app.
|
|
75
|
+
let activeStop: (() => void) | null = null;
|
|
76
|
+
|
|
77
|
+
/** Start push on this device; returns stop(). Call while signed in. No Firebase → no-op. */
|
|
78
|
+
export function startPushNotifications(opts: PushNotificationsOptions = {}): () => void {
|
|
79
|
+
if (Platform.OS === 'web') return () => {};
|
|
80
|
+
// Before the Firebase check: it also shows the local notifications plugins
|
|
81
|
+
// schedule, which work without Firebase.
|
|
82
|
+
if (opts.foregroundHandler !== false) setForegroundHandler();
|
|
83
|
+
const messaging = getMessaging();
|
|
84
|
+
if (!messaging) return () => {};
|
|
85
|
+
activeStop?.();
|
|
86
|
+
|
|
87
|
+
// Live = not stopped AND no sign-out since this start began. The host's
|
|
88
|
+
// sign-out unregisters first and stops us only when its UI catches up; a
|
|
89
|
+
// token or tap arriving in between belongs to the user who left.
|
|
90
|
+
const startedIn = pushRegistration.generation();
|
|
91
|
+
let stopped = false;
|
|
92
|
+
const live = () => !stopped && pushRegistration.generation() === startedIn;
|
|
93
|
+
let unsubRefresh: (() => void) | null = null;
|
|
94
|
+
const routeTap = (msg: RemoteMessage | null) => {
|
|
95
|
+
if (live()) routePushTap(msg?.data, opts.onOpenApp);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
(async () => {
|
|
99
|
+
const token = await registerForPush(messaging);
|
|
100
|
+
if (!token || !live()) return;
|
|
101
|
+
await pushRegistration.setToken(token);
|
|
102
|
+
// Signed out while registering: the teardown already unregistered this
|
|
103
|
+
// token — don't listen for refreshes as a signed-out user.
|
|
104
|
+
if (!live()) return;
|
|
105
|
+
unsubRefresh = messaging().onTokenRefresh((next) => {
|
|
106
|
+
if (live()) void pushRegistration.setToken(next);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const initial = await messaging().getInitialNotification();
|
|
110
|
+
if (initial && !initialTapHandled && live() && startedIn === launchGeneration) {
|
|
111
|
+
initialTapHandled = true;
|
|
112
|
+
routeTap(initial);
|
|
113
|
+
}
|
|
114
|
+
})().catch((err) => console.warn('[adas/push] start failed:', err?.message || err));
|
|
115
|
+
|
|
116
|
+
// App open: Firebase delivers but the OS shows nothing — use the in-app banner.
|
|
117
|
+
const unsubMessage = messaging().onMessage((msg) => {
|
|
118
|
+
const n = msg.notification;
|
|
119
|
+
if (!live()) return;
|
|
120
|
+
if (!n?.title && !n?.body) return; // data-only (e.g. a silent sync) — nothing to show
|
|
121
|
+
usePushBannerStore.getState().show({ title: n.title || 'Ada', body: n.body || undefined, data: msg.data || {} });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// App brought back from the background by a tap.
|
|
125
|
+
const unsubOpened = messaging().onNotificationOpenedApp(routeTap);
|
|
126
|
+
|
|
127
|
+
const stop = () => {
|
|
128
|
+
if (stopped) return;
|
|
129
|
+
stopped = true;
|
|
130
|
+
unsubRefresh?.();
|
|
131
|
+
unsubMessage();
|
|
132
|
+
unsubOpened();
|
|
133
|
+
// A banner shown for the user who is leaving must not stay tappable.
|
|
134
|
+
usePushBannerStore.getState().dismiss();
|
|
135
|
+
if (activeStop === stop) activeStop = null;
|
|
136
|
+
};
|
|
137
|
+
activeStop = stop;
|
|
138
|
+
return stop;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function setForegroundHandler(): void {
|
|
142
|
+
if (handlerSet) return;
|
|
143
|
+
handlerSet = true;
|
|
144
|
+
Notifications.setNotificationHandler({
|
|
145
|
+
handleNotification: async () => ({
|
|
146
|
+
shouldShowAlert: true,
|
|
147
|
+
shouldPlaySound: true,
|
|
148
|
+
shouldSetBadge: true,
|
|
149
|
+
shouldShowBanner: true,
|
|
150
|
+
shouldShowList: true,
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function registerForPush(messaging: Messaging): Promise<string | null> {
|
|
156
|
+
if (!Device.isDevice) {
|
|
157
|
+
console.warn('[adas/push] requires a physical device');
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
if (Platform.OS === 'ios') {
|
|
163
|
+
const status = await messaging().requestPermission();
|
|
164
|
+
const granted =
|
|
165
|
+
status === messaging.AuthorizationStatus.AUTHORIZED ||
|
|
166
|
+
status === messaging.AuthorizationStatus.PROVISIONAL;
|
|
167
|
+
if (!granted) {
|
|
168
|
+
console.warn(`[adas/push] iOS permission not granted (status ${status})`);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
} else if (Platform.OS === 'android') {
|
|
172
|
+
const { status: existing } = await Notifications.getPermissionsAsync();
|
|
173
|
+
let final = existing;
|
|
174
|
+
if (existing !== 'granted') {
|
|
175
|
+
const { status } = await Notifications.requestPermissionsAsync();
|
|
176
|
+
final = status;
|
|
177
|
+
}
|
|
178
|
+
if (final !== 'granted') {
|
|
179
|
+
console.warn(`[adas/push] Android permission not granted (${final})`);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
await Notifications.setNotificationChannelAsync('default', {
|
|
183
|
+
name: 'Default',
|
|
184
|
+
importance: Notifications.AndroidImportance.MAX,
|
|
185
|
+
vibrationPattern: [0, 250, 250, 250],
|
|
186
|
+
lightColor: ada.orange,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// iOS: register with APNs and WAIT for the APNs token before asking
|
|
191
|
+
// Firebase for an FCM token. Calling getToken() before the APNs token
|
|
192
|
+
// is available can return a stale/non-bridged token that the server
|
|
193
|
+
// can't deliver to.
|
|
194
|
+
if (Platform.OS === 'ios') {
|
|
195
|
+
if (!messaging().isDeviceRegisteredForRemoteMessages) {
|
|
196
|
+
await messaging().registerDeviceForRemoteMessages();
|
|
197
|
+
}
|
|
198
|
+
if (!(await waitForApnsToken(messaging))) {
|
|
199
|
+
console.warn(
|
|
200
|
+
'[adas/push] APNs token never arrived — Firebase cannot mint an FCM token. Check the aps-environment entitlement and the APNs key in Firebase Console → Cloud Messaging → Apple app config.',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const token = await messaging().getToken();
|
|
206
|
+
console.log(`[adas/push] FCM token ${token ? 'acquired' : 'missing'}`);
|
|
207
|
+
return token || null;
|
|
208
|
+
} catch (err) {
|
|
209
|
+
console.error('[adas/push] register failed:', err);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Polls getAPNSToken() up to ~5s. iOS sometimes needs a tick after
|
|
215
|
+
* registerDeviceForRemoteMessages() returns before the token is available. */
|
|
216
|
+
async function waitForApnsToken(messaging: Messaging, maxMs = 5000): Promise<string | null> {
|
|
217
|
+
const deadline = Date.now() + maxMs;
|
|
218
|
+
let token = await messaging().getAPNSToken();
|
|
219
|
+
while (!token && Date.now() < deadline) {
|
|
220
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
221
|
+
token = await messaging().getAPNSToken();
|
|
222
|
+
}
|
|
223
|
+
return token || null;
|
|
224
|
+
}
|
package/src/vision/index.ts
CHANGED
|
@@ -76,6 +76,8 @@ interface AdaVisionNative {
|
|
|
76
76
|
startVision(intervalMs: number): Promise<void>;
|
|
77
77
|
/** Absent from binaries built before the front camera existed. */
|
|
78
78
|
startVisionCamera?(intervalMs: number, facing: VisionFacing): Promise<void>;
|
|
79
|
+
/** Absent from binaries built before AI live existed. A base64 JPEG. */
|
|
80
|
+
jpegById?(id: string, maxSide: number, quality: number): Promise<string>;
|
|
79
81
|
stopVision(): void;
|
|
80
82
|
visionRetentionMs(): number;
|
|
81
83
|
qualityById(id: string): RawFrameQuality | null;
|
|
@@ -168,3 +170,15 @@ export async function describe(id: string, maxLabels = 5): Promise<VisionLabel[]
|
|
|
168
170
|
export async function detectObjects(id: string, maxItems = 10, minConfidence = 0.25): Promise<VisionObject[]> {
|
|
169
171
|
return verb('detectObjects')(id, maxItems, minConfidence);
|
|
170
172
|
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A published frame as a base64 JPEG, its longer side at most `maxSide`. FOR THE SDK'S AI LIVE SENDER ONLY
|
|
176
|
+
* (aiLive/provider.ts), which calls it after the two-sided "Pictures to AI" consent: this is the one way a
|
|
177
|
+
* picture leaves native, and no plugin-facing API reaches it. An older binary refuses by name.
|
|
178
|
+
*/
|
|
179
|
+
export async function jpegById(id: string, maxSide = 768, quality = 0.7): Promise<string> {
|
|
180
|
+
if (native && typeof native.jpegById !== 'function') {
|
|
181
|
+
throw new Error('sending pictures to AI needs a newer app build — this one cannot make a picture from a camera frame');
|
|
182
|
+
}
|
|
183
|
+
return verb('jpegById')!(id, maxSide, quality);
|
|
184
|
+
}
|