@ateam-ai-mobile/expo 0.1.5 → 0.1.8
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/android/build.gradle +16 -0
- package/android/src/main/java/expo/modules/adavision/AdaVisionModule.kt +75 -0
- package/dist/attachmentCache-DhQTqi2E.d.mts +18 -0
- package/dist/attachmentCache-DhQTqi2E.d.ts +18 -0
- package/dist/bootstrap.d.mts +95 -0
- package/dist/bootstrap.d.ts +95 -0
- package/dist/bootstrap.js +2991 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/bootstrap.mjs +66 -0
- package/dist/bootstrap.mjs.map +1 -0
- package/dist/chunk-4BMWVN25.mjs +91 -0
- package/dist/chunk-4BMWVN25.mjs.map +1 -0
- package/dist/chunk-6LXF3JVX.mjs +96 -0
- package/dist/chunk-6LXF3JVX.mjs.map +1 -0
- package/dist/chunk-7FWYEHN5.mjs +203 -0
- package/dist/chunk-7FWYEHN5.mjs.map +1 -0
- package/dist/chunk-C7M5WAHA.mjs +1798 -0
- package/dist/chunk-C7M5WAHA.mjs.map +1 -0
- package/dist/chunk-FF6ZZZXH.mjs +71 -0
- package/dist/chunk-FF6ZZZXH.mjs.map +1 -0
- package/dist/chunk-GF2MPTAB.mjs +647 -0
- package/dist/chunk-GF2MPTAB.mjs.map +1 -0
- package/dist/chunk-J6XRNX7P.mjs +19 -0
- package/dist/chunk-J6XRNX7P.mjs.map +1 -0
- package/dist/chunk-KOXF2FZM.mjs +92 -0
- package/dist/chunk-KOXF2FZM.mjs.map +1 -0
- package/dist/chunk-L4V7OCJ3.mjs +71 -0
- package/dist/chunk-L4V7OCJ3.mjs.map +1 -0
- package/dist/chunk-PCAEUX2N.mjs +67 -0
- package/dist/chunk-PCAEUX2N.mjs.map +1 -0
- package/dist/chunk-WMJKH4XE.mjs +34 -0
- package/dist/chunk-WMJKH4XE.mjs.map +1 -0
- package/dist/chunk-XQSY6MHP.mjs +213 -0
- package/dist/chunk-XQSY6MHP.mjs.map +1 -0
- package/dist/chunk-Z5AI2SXX.mjs +1810 -0
- package/dist/chunk-Z5AI2SXX.mjs.map +1 -0
- package/dist/createBridge.js +41 -10
- package/dist/createBridge.js.map +1 -1
- package/dist/createBridge.mjs +7 -1
- package/dist/createBridge.mjs.map +1 -1
- package/dist/device/createDeviceProvider.d.mts +23 -0
- package/dist/device/createDeviceProvider.d.ts +23 -0
- package/dist/device/createDeviceProvider.js +1228 -0
- package/dist/device/createDeviceProvider.js.map +1 -0
- package/dist/device/createDeviceProvider.mjs +19 -0
- package/dist/device/createDeviceProvider.mjs.map +1 -0
- package/dist/device-bridge/collectors/appEngagement.d.mts +10 -0
- package/dist/device-bridge/collectors/appEngagement.d.ts +10 -0
- package/dist/device-bridge/collectors/appEngagement.js +128 -0
- package/dist/device-bridge/collectors/appEngagement.js.map +1 -0
- package/dist/device-bridge/collectors/appEngagement.mjs +12 -0
- package/dist/device-bridge/collectors/appEngagement.mjs.map +1 -0
- package/dist/device-bridge/collectors/health.d.mts +24 -0
- package/dist/device-bridge/collectors/health.d.ts +24 -0
- package/dist/device-bridge/collectors/health.js +238 -0
- package/dist/device-bridge/collectors/health.js.map +1 -0
- package/dist/device-bridge/collectors/health.mjs +18 -0
- package/dist/device-bridge/collectors/health.mjs.map +1 -0
- package/dist/device-bridge/cookies.d.mts +49 -0
- package/dist/device-bridge/cookies.d.ts +49 -0
- package/dist/device-bridge/cookies.js +90 -0
- package/dist/device-bridge/cookies.js.map +1 -0
- package/dist/device-bridge/cookies.mjs +14 -0
- package/dist/device-bridge/cookies.mjs.map +1 -0
- package/dist/device-bridge/index.d.mts +495 -0
- package/dist/device-bridge/index.d.ts +495 -0
- package/dist/device-bridge/index.js +2121 -0
- package/dist/device-bridge/index.js.map +1 -0
- package/dist/device-bridge/index.mjs +34 -0
- package/dist/device-bridge/index.mjs.map +1 -0
- package/dist/device-bridge/nfc.d.mts +30 -0
- package/dist/device-bridge/nfc.d.ts +30 -0
- package/dist/device-bridge/nfc.js +109 -0
- package/dist/device-bridge/nfc.js.map +1 -0
- package/dist/device-bridge/nfc.mjs +14 -0
- package/dist/device-bridge/nfc.mjs.map +1 -0
- package/dist/index.d.mts +509 -18
- package/dist/index.d.ts +509 -18
- package/dist/index.js +1706 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +24 -194
- package/dist/index.mjs.map +1 -1
- package/dist/maps/index.d.mts +56 -0
- package/dist/maps/index.d.ts +56 -0
- package/dist/maps/index.js +95 -0
- package/dist/maps/index.js.map +1 -0
- package/dist/maps/index.mjs +17 -0
- package/dist/maps/index.mjs.map +1 -0
- package/dist/oauth/expoOAuth.d.mts +18 -0
- package/dist/oauth/expoOAuth.d.ts +18 -0
- package/dist/oauth/expoOAuth.js +102 -0
- package/dist/oauth/expoOAuth.js.map +1 -0
- package/dist/oauth/expoOAuth.mjs +8 -0
- package/dist/oauth/expoOAuth.mjs.map +1 -0
- package/dist/types-BRanJYTn.d.mts +138 -0
- package/dist/types-BRanJYTn.d.ts +138 -0
- package/dist/vision/index.d.mts +83 -0
- package/dist/vision/index.d.ts +83 -0
- package/dist/vision/index.js +101 -0
- package/dist/vision/index.js.map +1 -0
- package/dist/vision/index.mjs +28 -0
- package/dist/vision/index.mjs.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AdaMaps/AdaMapsModule.swift +180 -0
- package/ios/AdaMaps.podspec +25 -0
- package/ios/AdaVision.podspec +32 -0
- package/ios/AdaVisionModule.swift +486 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/analytics/coremldata.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/coremldata.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/metadata.json +773 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/analytics/coremldata.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/coremldata.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/model.mil +720 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/weights/weight.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model1/coremldata.bin +0 -0
- package/package.json +113 -7
- package/src/args.ts +56 -0
- package/src/bootstrap.ts +159 -0
- package/src/camera/CameraTaskHost.tsx +175 -0
- package/src/camera/cameraTasks.ts +129 -0
- package/src/createBridge.ts +3 -1
- package/src/device/createDeviceProvider.ts +917 -0
- package/src/device-bridge/actions/calendar-actions.ts +217 -0
- package/src/device-bridge/actions/dnd.ts +74 -0
- package/src/device-bridge/actions/health-actions.ts +58 -0
- package/src/device-bridge/actions/index.ts +12 -0
- package/src/device-bridge/actions/messaging.ts +74 -0
- package/src/device-bridge/actions/navigation.ts +88 -0
- package/src/device-bridge/actions/queue-poller.ts +151 -0
- package/src/device-bridge/background.ts +137 -0
- package/src/device-bridge/biometrics.ts +64 -0
- package/src/device-bridge/capabilities.ts +218 -0
- package/src/device-bridge/collectors/appEngagement.ts +120 -0
- package/src/device-bridge/collectors/battery.ts +83 -0
- package/src/device-bridge/collectors/calendar.ts +162 -0
- package/src/device-bridge/collectors/camera.ts +27 -0
- package/src/device-bridge/collectors/connectivity.ts +60 -0
- package/src/device-bridge/collectors/contacts.ts +200 -0
- package/src/device-bridge/collectors/health.ts +299 -0
- package/src/device-bridge/collectors/index.ts +12 -0
- package/src/device-bridge/collectors/location.ts +236 -0
- package/src/device-bridge/collectors/mood.ts +141 -0
- package/src/device-bridge/collectors/notifications.ts +70 -0
- package/src/device-bridge/cookies.ts +89 -0
- package/src/device-bridge/dynamic-connector.ts +422 -0
- package/src/device-bridge/index.ts +467 -0
- package/src/device-bridge/nfc.ts +85 -0
- package/src/device-bridge/plugin.ts +71 -0
- package/src/device-bridge/relay-client.ts +237 -0
- package/src/device-bridge/scanner.ts +42 -0
- package/src/device-bridge/sharing.ts +35 -0
- package/src/device-bridge/types.ts +326 -0
- package/src/device-state/activityClassifier.ts +151 -0
- package/src/device-state/activityEngine.ts +252 -0
- package/src/device-state/collectors.ts +35 -0
- package/src/device-state/domains/activity.ts +93 -0
- package/src/device-state/domains/capability.ts +60 -0
- package/src/device-state/domains/connectivity.ts +60 -0
- package/src/device-state/domains/deviceContext.ts +57 -0
- package/src/device-state/domains/interaction.ts +60 -0
- package/src/device-state/domains/spatial.ts +367 -0
- package/src/device-state/domains/visual.ts +121 -0
- package/src/device-state/events.ts +49 -0
- package/src/device-state/headingSource.ts +119 -0
- package/src/device-state/index.ts +78 -0
- package/src/device-state/quality.ts +60 -0
- package/src/device-state/replay.ts +285 -0
- package/src/device-state/spatialEvents.ts +300 -0
- package/src/device-state/turnDetector.ts +121 -0
- package/src/device-state/visualEngine.ts +341 -0
- package/src/geo.ts +23 -0
- package/src/haptics.ts +4 -0
- package/src/index.ts +37 -0
- package/src/maps/index.ts +81 -0
- package/src/maps/normalize.ts +225 -0
- package/src/oauth/expoOAuth.tsx +113 -0
- package/src/vision/analysis.js +80 -0
- package/src/vision/index.ts +146 -0
- package/src/vision/ring.js +35 -0
|
@@ -0,0 +1,1798 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cameraError,
|
|
3
|
+
cameraTasks
|
|
4
|
+
} from "./chunk-6LXF3JVX.mjs";
|
|
5
|
+
import {
|
|
6
|
+
distanceOf
|
|
7
|
+
} from "./chunk-J6XRNX7P.mjs";
|
|
8
|
+
import {
|
|
9
|
+
__require
|
|
10
|
+
} from "./chunk-WMJKH4XE.mjs";
|
|
11
|
+
|
|
12
|
+
// src/index.ts
|
|
13
|
+
import * as ExpoWebBrowser from "expo-web-browser";
|
|
14
|
+
import * as ExpoLinking from "expo-linking";
|
|
15
|
+
import * as ExpoClipboard from "expo-clipboard";
|
|
16
|
+
import AsyncStorage3 from "@react-native-async-storage/async-storage";
|
|
17
|
+
import { setAcsCacheStorage } from "@ateam-ai-mobile/core";
|
|
18
|
+
import {
|
|
19
|
+
setHapticsProvider,
|
|
20
|
+
setBundleStorage,
|
|
21
|
+
registerBundleModules,
|
|
22
|
+
setPluginNativeProvider
|
|
23
|
+
} from "@ateam-ai-mobile/plugin-runtime";
|
|
24
|
+
import { setClipboardProvider } from "@ateam-ai-mobile/ui";
|
|
25
|
+
|
|
26
|
+
// src/haptics.ts
|
|
27
|
+
import * as Haptics from "expo-haptics";
|
|
28
|
+
var expoHaptics = {
|
|
29
|
+
impact(style) {
|
|
30
|
+
const map = {
|
|
31
|
+
light: Haptics.ImpactFeedbackStyle.Light,
|
|
32
|
+
medium: Haptics.ImpactFeedbackStyle.Medium,
|
|
33
|
+
heavy: Haptics.ImpactFeedbackStyle.Heavy
|
|
34
|
+
};
|
|
35
|
+
Haptics.impactAsync(map[style]).catch(() => {
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
notification(type) {
|
|
39
|
+
const map = {
|
|
40
|
+
success: Haptics.NotificationFeedbackType.Success,
|
|
41
|
+
warning: Haptics.NotificationFeedbackType.Warning,
|
|
42
|
+
error: Haptics.NotificationFeedbackType.Error
|
|
43
|
+
};
|
|
44
|
+
Haptics.notificationAsync(map[type]).catch(() => {
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
selection() {
|
|
48
|
+
Haptics.selectionAsync().catch(() => {
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
// Shorthand for notification('error') — the form plugins actually call.
|
|
52
|
+
error() {
|
|
53
|
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/bundleStorage.ts
|
|
59
|
+
import * as FileSystem from "expo-file-system/legacy";
|
|
60
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
61
|
+
var CACHE_DIR = `${FileSystem.cacheDirectory}plugin-bundles/`;
|
|
62
|
+
var DEFAULT_CACHE_INDEX_KEY = "adas-plugin-bundles-index-v1";
|
|
63
|
+
var ExpoBundleStorage = class {
|
|
64
|
+
/** Pass `indexKey` when migrating from an app that already persisted a
|
|
65
|
+
* bundle index under a different key — keeps the existing cache warm. */
|
|
66
|
+
constructor(opts) {
|
|
67
|
+
this.dirReady = false;
|
|
68
|
+
this.indexKey = opts?.indexKey || DEFAULT_CACHE_INDEX_KEY;
|
|
69
|
+
}
|
|
70
|
+
async ensureDir() {
|
|
71
|
+
if (this.dirReady) return;
|
|
72
|
+
try {
|
|
73
|
+
const info = await FileSystem.getInfoAsync(CACHE_DIR);
|
|
74
|
+
if (!info.exists) {
|
|
75
|
+
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.warn("[ExpoBundleStorage] cache dir error:", err);
|
|
79
|
+
}
|
|
80
|
+
this.dirReady = true;
|
|
81
|
+
}
|
|
82
|
+
async loadIndex() {
|
|
83
|
+
try {
|
|
84
|
+
const raw = await AsyncStorage.getItem(this.indexKey);
|
|
85
|
+
return raw ? JSON.parse(raw) : {};
|
|
86
|
+
} catch {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async saveIndex(index) {
|
|
91
|
+
try {
|
|
92
|
+
await AsyncStorage.setItem(this.indexKey, JSON.stringify(index));
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.warn("[ExpoBundleStorage] index save error:", err);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async readBundle(localPath) {
|
|
98
|
+
try {
|
|
99
|
+
const info = await FileSystem.getInfoAsync(localPath);
|
|
100
|
+
if (!info.exists) return null;
|
|
101
|
+
return await FileSystem.readAsStringAsync(localPath);
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async writeBundle(key, content) {
|
|
107
|
+
await this.ensureDir();
|
|
108
|
+
const localPath = `${CACHE_DIR}${key}`;
|
|
109
|
+
await FileSystem.writeAsStringAsync(localPath, content);
|
|
110
|
+
return localPath;
|
|
111
|
+
}
|
|
112
|
+
async deleteBundle(localPath) {
|
|
113
|
+
await FileSystem.deleteAsync(localPath, { idempotent: true }).catch(() => {
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
async clearAll() {
|
|
117
|
+
await FileSystem.deleteAsync(CACHE_DIR, { idempotent: true }).catch(() => {
|
|
118
|
+
});
|
|
119
|
+
await AsyncStorage.removeItem(this.indexKey).catch(() => {
|
|
120
|
+
});
|
|
121
|
+
this.dirReady = false;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// src/attachmentCache.ts
|
|
126
|
+
import * as FileSystem2 from "expo-file-system/legacy";
|
|
127
|
+
import AsyncStorage2 from "@react-native-async-storage/async-storage";
|
|
128
|
+
var DEFAULT_INDEX_KEY = "adas-chat-attachment-index-v1";
|
|
129
|
+
var DIR = `${FileSystem2.documentDirectory}chat-attachments/`;
|
|
130
|
+
var MAX_JOBS = 100;
|
|
131
|
+
var ExpoAttachmentCache = class {
|
|
132
|
+
/** Pass `indexKey` when migrating from an app that already persisted an
|
|
133
|
+
* index under a different AsyncStorage key — otherwise existing users
|
|
134
|
+
* lose every cached preview on upgrade. */
|
|
135
|
+
constructor(opts) {
|
|
136
|
+
this.index = {};
|
|
137
|
+
const indexKey = opts?.indexKey || DEFAULT_INDEX_KEY;
|
|
138
|
+
this.indexKey = indexKey;
|
|
139
|
+
this.loaded = (async () => {
|
|
140
|
+
try {
|
|
141
|
+
const info = await FileSystem2.getInfoAsync(DIR);
|
|
142
|
+
if (!info.exists) await FileSystem2.makeDirectoryAsync(DIR, { intermediates: true });
|
|
143
|
+
const raw = await AsyncStorage2.getItem(indexKey);
|
|
144
|
+
if (raw) this.index = JSON.parse(raw);
|
|
145
|
+
} catch {
|
|
146
|
+
}
|
|
147
|
+
})();
|
|
148
|
+
}
|
|
149
|
+
async ready() {
|
|
150
|
+
await this.loaded;
|
|
151
|
+
}
|
|
152
|
+
async save(jobId, attachments) {
|
|
153
|
+
await this.loaded;
|
|
154
|
+
const entries = [];
|
|
155
|
+
for (let i = 0; i < attachments.length; i++) {
|
|
156
|
+
const att = attachments[i];
|
|
157
|
+
const ext = att.mimeType.startsWith("image/") ? att.mimeType.split("/")[1] || "jpg" : "bin";
|
|
158
|
+
const filePath = `${DIR}${jobId}_${i}.${ext}`;
|
|
159
|
+
try {
|
|
160
|
+
await FileSystem2.writeAsStringAsync(filePath, att.data, { encoding: FileSystem2.EncodingType.Base64 });
|
|
161
|
+
entries.push({ mimeType: att.mimeType, name: att.name, filePath });
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.warn("[ExpoAttachmentCache] write failed:", err);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (entries.length === 0) return;
|
|
167
|
+
this.index[jobId] = entries;
|
|
168
|
+
const keys = Object.keys(this.index);
|
|
169
|
+
if (keys.length > MAX_JOBS) {
|
|
170
|
+
for (const k of keys.slice(0, keys.length - MAX_JOBS)) {
|
|
171
|
+
const old = this.index[k];
|
|
172
|
+
if (old) for (const o of old) FileSystem2.deleteAsync(o.filePath, { idempotent: true }).catch(() => {
|
|
173
|
+
});
|
|
174
|
+
delete this.index[k];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
await AsyncStorage2.setItem(this.indexKey, JSON.stringify(this.index));
|
|
179
|
+
} catch {
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
get(jobId) {
|
|
183
|
+
const entries = this.index[jobId];
|
|
184
|
+
if (!entries || entries.length === 0) return void 0;
|
|
185
|
+
return entries.map((e) => ({
|
|
186
|
+
mimeType: e.mimeType,
|
|
187
|
+
name: e.name,
|
|
188
|
+
previewUri: e.filePath
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// src/device-state/domains/deviceContext.ts
|
|
194
|
+
import * as ExpoBattery from "expo-battery";
|
|
195
|
+
|
|
196
|
+
// src/device-state/quality.ts
|
|
197
|
+
import { neverObserved } from "@ateam-ai-mobile/plugin-runtime";
|
|
198
|
+
var FRESH_MS = 1e4;
|
|
199
|
+
var AGING_MS = 6e4;
|
|
200
|
+
function qualityFrom(observedAt, confidence) {
|
|
201
|
+
const ageMs = Date.now() - observedAt;
|
|
202
|
+
return {
|
|
203
|
+
freshness: {
|
|
204
|
+
state: ageMs < FRESH_MS ? "fresh" : ageMs < AGING_MS ? "aging" : "stale",
|
|
205
|
+
observedAt,
|
|
206
|
+
ageMs
|
|
207
|
+
},
|
|
208
|
+
...confidence ? { confidence } : {}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function qualityUnknown() {
|
|
212
|
+
return { freshness: neverObserved() };
|
|
213
|
+
}
|
|
214
|
+
function unsupported(what) {
|
|
215
|
+
const err = new Error(
|
|
216
|
+
`${what} is not implemented in this build \u2014 the native engine behind it does not exist yet.`
|
|
217
|
+
);
|
|
218
|
+
err.code = "UNSUPPORTED";
|
|
219
|
+
return err;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// src/device-state/events.ts
|
|
223
|
+
function stateEvent(domain, name, payload, timestamp = Date.now()) {
|
|
224
|
+
return payload === void 0 ? { domain, name, timestamp } : { domain, name, timestamp, payload };
|
|
225
|
+
}
|
|
226
|
+
function listenerSet() {
|
|
227
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
228
|
+
return {
|
|
229
|
+
add(fn) {
|
|
230
|
+
listeners.add(fn);
|
|
231
|
+
return { unsubscribe: () => {
|
|
232
|
+
listeners.delete(fn);
|
|
233
|
+
} };
|
|
234
|
+
},
|
|
235
|
+
emit(event) {
|
|
236
|
+
for (const fn of listeners) {
|
|
237
|
+
try {
|
|
238
|
+
fn(event);
|
|
239
|
+
} catch {
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/device-state/domains/deviceContext.ts
|
|
247
|
+
function createDeviceContextDomain() {
|
|
248
|
+
return {
|
|
249
|
+
async getSnapshot() {
|
|
250
|
+
const observedAt = Date.now();
|
|
251
|
+
try {
|
|
252
|
+
const [level, state, lowPower] = await Promise.all([
|
|
253
|
+
ExpoBattery.getBatteryLevelAsync(),
|
|
254
|
+
ExpoBattery.getBatteryStateAsync(),
|
|
255
|
+
ExpoBattery.isLowPowerModeEnabledAsync()
|
|
256
|
+
]);
|
|
257
|
+
return {
|
|
258
|
+
power: {
|
|
259
|
+
// expo-battery returns -1 when it cannot tell. That is not a level.
|
|
260
|
+
batteryLevel: typeof level === "number" && level >= 0 ? level : null,
|
|
261
|
+
charging: state === ExpoBattery.BatteryState.CHARGING || state === ExpoBattery.BatteryState.FULL,
|
|
262
|
+
lowPowerMode: lowPower
|
|
263
|
+
},
|
|
264
|
+
// No Expo API for either. iOS needs ProcessInfo.thermalState, Android
|
|
265
|
+
// PowerManager.getThermalStatus — a native module that does not exist.
|
|
266
|
+
thermal: { state: "unknown" },
|
|
267
|
+
resources: { memoryPressure: "unknown", storagePressure: "unknown" },
|
|
268
|
+
quality: qualityFrom(observedAt)
|
|
269
|
+
};
|
|
270
|
+
} catch {
|
|
271
|
+
return {
|
|
272
|
+
power: { batteryLevel: null, charging: null, lowPowerMode: null },
|
|
273
|
+
thermal: { state: "unknown" },
|
|
274
|
+
resources: { memoryPressure: "unknown", storagePressure: "unknown" },
|
|
275
|
+
quality: qualityUnknown()
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
// Real events, not a placeholder: expo-battery exposes native listeners,
|
|
280
|
+
// so a plugin that subscribes genuinely hears about a change rather than
|
|
281
|
+
// sitting on a subscription that can never fire. Nudges without a payload:
|
|
282
|
+
// no Device Context payload schema is decided, so the subscriber re-reads.
|
|
283
|
+
subscribe(listener) {
|
|
284
|
+
const subs = [
|
|
285
|
+
ExpoBattery.addBatteryLevelListener(() => listener(stateEvent("deviceContext", "batteryLevelChanged"))),
|
|
286
|
+
ExpoBattery.addBatteryStateListener(() => listener(stateEvent("deviceContext", "chargingChanged")))
|
|
287
|
+
];
|
|
288
|
+
return { unsubscribe: () => subs.forEach((x) => x?.remove?.()) };
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/device-state/domains/connectivity.ts
|
|
294
|
+
function createConnectivityDomain(collectors) {
|
|
295
|
+
return {
|
|
296
|
+
async getSnapshot() {
|
|
297
|
+
const observedAt = Date.now();
|
|
298
|
+
try {
|
|
299
|
+
if (!collectors.connectivity) throw unsupported("connectivity collector");
|
|
300
|
+
const data = await collectors.connectivity.collectConnectivity();
|
|
301
|
+
const online = data?.is_connected === true;
|
|
302
|
+
const type = String(data?.type || "").toLowerCase();
|
|
303
|
+
return {
|
|
304
|
+
reachability: data?.is_connected == null ? "unknown" : online ? "online" : "offline",
|
|
305
|
+
transport: type === "wifi" ? "wifi" : type === "cellular" ? "cellular" : type === "ethernet" ? "ethernet" : online ? "other" : data?.is_connected === false ? "none" : "unknown",
|
|
306
|
+
// All four below need measurement nobody takes yet. `unknown` is the
|
|
307
|
+
// designed answer — "return unknown rather than stale precision".
|
|
308
|
+
qualityClass: "unknown",
|
|
309
|
+
stability: "unknown",
|
|
310
|
+
latencyMs: null,
|
|
311
|
+
bandwidthClass: "unknown",
|
|
312
|
+
metered: null,
|
|
313
|
+
constrained: null,
|
|
314
|
+
quality: qualityFrom(observedAt)
|
|
315
|
+
};
|
|
316
|
+
} catch {
|
|
317
|
+
return {
|
|
318
|
+
reachability: "unknown",
|
|
319
|
+
transport: "unknown",
|
|
320
|
+
qualityClass: "unknown",
|
|
321
|
+
stability: "unknown",
|
|
322
|
+
latencyMs: null,
|
|
323
|
+
bandwidthClass: "unknown",
|
|
324
|
+
metered: null,
|
|
325
|
+
constrained: null,
|
|
326
|
+
quality: qualityUnknown()
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
// Intent selects a native sampling strategy — latency probes, quality
|
|
331
|
+
// classification. Nothing here samples anything, so accepting an intent
|
|
332
|
+
// would be accepting an instruction we do not carry out.
|
|
333
|
+
async start() {
|
|
334
|
+
throw unsupported("connectivity.start");
|
|
335
|
+
},
|
|
336
|
+
async stop() {
|
|
337
|
+
throw unsupported("connectivity.stop");
|
|
338
|
+
},
|
|
339
|
+
// A subscription that can never fire is worse than none: the caller
|
|
340
|
+
// reasonably reads silence as "nothing changed".
|
|
341
|
+
subscribe() {
|
|
342
|
+
throw unsupported("connectivity.subscribe");
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// src/device-state/domains/capability.ts
|
|
348
|
+
function statusFrom(granted) {
|
|
349
|
+
return {
|
|
350
|
+
support: "unknown",
|
|
351
|
+
permission: granted === null ? "unknown" : granted ? "granted" : "denied",
|
|
352
|
+
availability: "unknown"
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
var UNKNOWN_STATUS = {
|
|
356
|
+
support: "unknown",
|
|
357
|
+
permission: "unknown",
|
|
358
|
+
availability: "unknown"
|
|
359
|
+
};
|
|
360
|
+
function createCapabilityDomain(collectors) {
|
|
361
|
+
return {
|
|
362
|
+
async getSnapshot() {
|
|
363
|
+
const observedAt = Date.now();
|
|
364
|
+
const locationGranted = collectors.location ? await collectors.location.hasPermission().catch(() => null) : null;
|
|
365
|
+
return {
|
|
366
|
+
// Only location has a real reader today. The rest are declared by the
|
|
367
|
+
// contract and answered honestly rather than omitted.
|
|
368
|
+
camera: UNKNOWN_STATUS,
|
|
369
|
+
microphone: UNKNOWN_STATUS,
|
|
370
|
+
location: {
|
|
371
|
+
...statusFrom(locationGranted),
|
|
372
|
+
// iOS 14+ distinguishes precise from approximate; nothing reads it.
|
|
373
|
+
precision: "unknown",
|
|
374
|
+
backgroundPermission: "unknown"
|
|
375
|
+
},
|
|
376
|
+
motion: UNKNOWN_STATUS,
|
|
377
|
+
bluetooth: UNKNOWN_STATUS,
|
|
378
|
+
uwb: UNKNOWN_STATUS,
|
|
379
|
+
nfc: UNKNOWN_STATUS,
|
|
380
|
+
quality: qualityFrom(observedAt)
|
|
381
|
+
};
|
|
382
|
+
},
|
|
383
|
+
// No OS permission-change stream is wired. Same reasoning as connectivity:
|
|
384
|
+
// silence would be indistinguishable from stability.
|
|
385
|
+
subscribe() {
|
|
386
|
+
throw unsupported("capability.subscribe");
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// src/device-state/domains/interaction.ts
|
|
392
|
+
import { AppState } from "react-native";
|
|
393
|
+
function createInteractionDomain() {
|
|
394
|
+
const mapState = (s) => s === "active" ? "foreground" : s === "background" ? "background" : s === "inactive" ? "inactive" : "unknown";
|
|
395
|
+
return {
|
|
396
|
+
async getSnapshot() {
|
|
397
|
+
const observedAt = Date.now();
|
|
398
|
+
return {
|
|
399
|
+
lifecycle: {
|
|
400
|
+
appState: mapState(AppState.currentState),
|
|
401
|
+
screenOn: null,
|
|
402
|
+
userActive: null,
|
|
403
|
+
idleDurationMs: null
|
|
404
|
+
},
|
|
405
|
+
audio: {
|
|
406
|
+
captureActive: null,
|
|
407
|
+
playbackActive: null,
|
|
408
|
+
route: "unknown",
|
|
409
|
+
interruption: "unknown"
|
|
410
|
+
},
|
|
411
|
+
// The voice block is OMITTED rather than filled with nulls: the design
|
|
412
|
+
// says a platform that cannot report voice session state leaves the
|
|
413
|
+
// group out instead of guessing `false` for each field. The app's own
|
|
414
|
+
// voice store knows this, but it is not reachable from the SDK.
|
|
415
|
+
quality: qualityFrom(observedAt)
|
|
416
|
+
};
|
|
417
|
+
},
|
|
418
|
+
// Real: AppState genuinely emits on every foreground/background change,
|
|
419
|
+
// which is the one part of this domain that works.
|
|
420
|
+
subscribe(listener) {
|
|
421
|
+
const sub2 = AppState.addEventListener("change", (next) => {
|
|
422
|
+
listener(stateEvent("interaction", "appStateChanged", { appState: mapState(next) }));
|
|
423
|
+
});
|
|
424
|
+
return { unsubscribe: () => sub2.remove() };
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/device-state/headingSource.ts
|
|
430
|
+
import * as ExpoLocation from "expo-location";
|
|
431
|
+
|
|
432
|
+
// src/device-state/turnDetector.ts
|
|
433
|
+
function signedDelta(a, b) {
|
|
434
|
+
return (b - a + 540) % 360 - 180;
|
|
435
|
+
}
|
|
436
|
+
var TurnDetector = class {
|
|
437
|
+
constructor(opts = {}) {
|
|
438
|
+
/** The heading currently treated as "straight ahead". */
|
|
439
|
+
this.refDeg = null;
|
|
440
|
+
this.stableSince = 0;
|
|
441
|
+
this.lastTurn = null;
|
|
442
|
+
this.minTurnDeg = opts.minTurnDeg ?? 35;
|
|
443
|
+
this.stableDeg = opts.stableDeg ?? 12;
|
|
444
|
+
this.stableMs = opts.stableMs ?? 2500;
|
|
445
|
+
}
|
|
446
|
+
/** Feed one compass reading. `null` degrees means the compass said nothing. */
|
|
447
|
+
push(deg, now) {
|
|
448
|
+
if (deg === null) return null;
|
|
449
|
+
if (this.refDeg === null) {
|
|
450
|
+
this.refDeg = deg;
|
|
451
|
+
this.stableSince = now;
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
const delta = signedDelta(this.refDeg, deg);
|
|
455
|
+
if (Math.abs(delta) >= this.minTurnDeg) {
|
|
456
|
+
this.lastTurn = { deg: Math.round(delta), timestamp: now };
|
|
457
|
+
this.refDeg = deg;
|
|
458
|
+
this.stableSince = now;
|
|
459
|
+
return this.lastTurn;
|
|
460
|
+
}
|
|
461
|
+
if (Math.abs(delta) > this.stableDeg) {
|
|
462
|
+
this.stableSince = now;
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
if (now - this.stableSince >= this.stableMs) {
|
|
466
|
+
this.refDeg = deg;
|
|
467
|
+
this.stableSince = now;
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* The most recent completed turn, or null if none this session.
|
|
473
|
+
*
|
|
474
|
+
* It does NOT expire on its own: the age is derivable from `timestamp`, and
|
|
475
|
+
* "recent enough to mention" is a product judgement this layer must not make
|
|
476
|
+
* on the caller's behalf.
|
|
477
|
+
*/
|
|
478
|
+
last() {
|
|
479
|
+
return this.lastTurn;
|
|
480
|
+
}
|
|
481
|
+
reset() {
|
|
482
|
+
this.refDeg = null;
|
|
483
|
+
this.stableSince = 0;
|
|
484
|
+
this.lastTurn = null;
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// src/device-state/headingSource.ts
|
|
489
|
+
var latest = null;
|
|
490
|
+
var sub = null;
|
|
491
|
+
var opening = null;
|
|
492
|
+
var owners = /* @__PURE__ */ new Set();
|
|
493
|
+
var turns = new TurnDetector();
|
|
494
|
+
var turnListeners = listenerSet();
|
|
495
|
+
function startHeading(owner) {
|
|
496
|
+
owners.add(owner);
|
|
497
|
+
if (sub) return Promise.resolve();
|
|
498
|
+
opening ?? (opening = open().finally(() => {
|
|
499
|
+
opening = null;
|
|
500
|
+
}));
|
|
501
|
+
return opening;
|
|
502
|
+
}
|
|
503
|
+
async function open() {
|
|
504
|
+
try {
|
|
505
|
+
const s = await ExpoLocation.watchHeadingAsync((h) => {
|
|
506
|
+
if (!owners.size) return;
|
|
507
|
+
const raw = h.trueHeading >= 0 ? h.trueHeading : h.magHeading;
|
|
508
|
+
const deg = typeof raw === "number" && raw >= 0 ? raw : null;
|
|
509
|
+
const cal = typeof h.accuracy === "number" && h.accuracy >= 0 ? h.accuracy : null;
|
|
510
|
+
const now = Date.now();
|
|
511
|
+
latest = { headingDeg: deg, calibration: cal, timestamp: now };
|
|
512
|
+
const turn = turns.push(deg, now);
|
|
513
|
+
if (turn) turnListeners.emit(turn);
|
|
514
|
+
});
|
|
515
|
+
if (!owners.size) {
|
|
516
|
+
s.remove();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
sub = s;
|
|
520
|
+
} catch {
|
|
521
|
+
sub = null;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function stopHeading(owner) {
|
|
525
|
+
owners.delete(owner);
|
|
526
|
+
if (owners.size) return;
|
|
527
|
+
sub?.remove();
|
|
528
|
+
sub = null;
|
|
529
|
+
latest = null;
|
|
530
|
+
turns.reset();
|
|
531
|
+
}
|
|
532
|
+
function onTurn(fn) {
|
|
533
|
+
return turnListeners.add(fn);
|
|
534
|
+
}
|
|
535
|
+
function currentHeading() {
|
|
536
|
+
return latest ? { headingDeg: latest.headingDeg } : null;
|
|
537
|
+
}
|
|
538
|
+
function currentHeadingDetail() {
|
|
539
|
+
return latest;
|
|
540
|
+
}
|
|
541
|
+
function lastMeaningfulTurn() {
|
|
542
|
+
return turns.last();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// src/device-state/spatialEvents.ts
|
|
546
|
+
var STATIONARY_MPS = 0.4;
|
|
547
|
+
function movementStateOf(speedMps) {
|
|
548
|
+
return speedMps === null ? "unknown" : speedMps < STATIONARY_MPS ? "stationary" : "moving";
|
|
549
|
+
}
|
|
550
|
+
function fixConfidence(accuracyM) {
|
|
551
|
+
return accuracyM === null ? "unknown" : accuracyM <= 10 ? "high" : accuracyM <= 50 ? "medium" : "low";
|
|
552
|
+
}
|
|
553
|
+
var SPATIAL_EVENT_PROFILES = {
|
|
554
|
+
navigation: { movingHoldMs: 3e3, stoppedHoldMs: 5e3, confidenceHoldMs: 3e3, minMoveM: 10 },
|
|
555
|
+
fitness: { movingHoldMs: 3e3, stoppedHoldMs: 5e3, confidenceHoldMs: 5e3, minMoveM: 10 },
|
|
556
|
+
preciseLocate: { movingHoldMs: 2e3, stoppedHoldMs: 3e3, confidenceHoldMs: 2e3, minMoveM: 3 },
|
|
557
|
+
geofence: { movingHoldMs: 1e4, stoppedHoldMs: 3e4, confidenceHoldMs: 1e4, minMoveM: 25 },
|
|
558
|
+
lowPowerTracking: { movingHoldMs: 1e4, stoppedHoldMs: 3e4, confidenceHoldMs: 1e4, minMoveM: 50 }
|
|
559
|
+
};
|
|
560
|
+
function moveThresholdM(anchor, fix) {
|
|
561
|
+
const a = anchor.accuracyM ?? 0, b = fix.accuracyM ?? 0;
|
|
562
|
+
return 2 * Math.sqrt(a * a + b * b);
|
|
563
|
+
}
|
|
564
|
+
function spatialEventProfile(intent) {
|
|
565
|
+
return SPATIAL_EVENT_PROFILES[intent] ?? SPATIAL_EVENT_PROFILES.navigation;
|
|
566
|
+
}
|
|
567
|
+
var RANK = { high: 3, medium: 2, low: 1 };
|
|
568
|
+
var MIN_FIXES = 2;
|
|
569
|
+
function extend(run, state, fixAt2, now) {
|
|
570
|
+
if (!run || run.state !== state) return { state, since: now, fixAt: fixAt2, fixes: fixAt2 === null ? 0 : 1 };
|
|
571
|
+
if (fixAt2 !== null && fixAt2 !== run.fixAt) {
|
|
572
|
+
run.fixAt = fixAt2;
|
|
573
|
+
run.fixes++;
|
|
574
|
+
}
|
|
575
|
+
return run;
|
|
576
|
+
}
|
|
577
|
+
var settled = (run, now, holdMs) => run.fixes >= MIN_FIXES && now - run.since >= holdMs;
|
|
578
|
+
var fixAt = (s) => s.position?.timestamp ?? null;
|
|
579
|
+
var SpatialEventDetector = class {
|
|
580
|
+
constructor(profile = SPATIAL_EVENT_PROFILES.navigation) {
|
|
581
|
+
/** The movement state last settled on. Only MEASURED states settle. */
|
|
582
|
+
this.movement = "unknown";
|
|
583
|
+
this.run = null;
|
|
584
|
+
/** The completed turn already on record — reported turns, and the one the
|
|
585
|
+
* session opened onto. Only a turn with a new timestamp is news. */
|
|
586
|
+
this.lastTurnAt = null;
|
|
587
|
+
/** The GPS band last settled on; null until the session's first band. */
|
|
588
|
+
this.band = null;
|
|
589
|
+
this.bandRun = null;
|
|
590
|
+
/** The last position REPORTED — distance is measured from here, not from
|
|
591
|
+
* the previous fix, so a slow walk still adds up to a change. */
|
|
592
|
+
this.anchor = null;
|
|
593
|
+
this.staleArmed = true;
|
|
594
|
+
this.profile = profile;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Forget everything — a new session, or replay seeking backwards. Emits
|
|
598
|
+
* nothing. `turnOnRecordAt` is the timestamp of the turn already completed
|
|
599
|
+
* when the session opens (a visual session shares the compass): history, not
|
|
600
|
+
* news. Seeded from the turn itself rather than compared against a clock, so
|
|
601
|
+
* it holds whichever clock the turn was stamped with — epoch live, the
|
|
602
|
+
* recording's epoch in replay, where `now` is virtual.
|
|
603
|
+
*/
|
|
604
|
+
reset(profile, turnOnRecordAt = null) {
|
|
605
|
+
if (profile) this.profile = profile;
|
|
606
|
+
this.movement = "unknown";
|
|
607
|
+
this.run = null;
|
|
608
|
+
this.lastTurnAt = turnOnRecordAt;
|
|
609
|
+
this.band = null;
|
|
610
|
+
this.bandRun = null;
|
|
611
|
+
this.anchor = null;
|
|
612
|
+
this.staleArmed = true;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Feed the newest snapshot. Returns what changed since the last one, in this
|
|
616
|
+
* order: movement, turn, position, confidence, staleness. Fields are read
|
|
617
|
+
* defensively — replay hands over recorded snapshots verbatim, and a recording
|
|
618
|
+
* may lack a block the live engine always fills.
|
|
619
|
+
*/
|
|
620
|
+
push(snapshot, now) {
|
|
621
|
+
const out = [];
|
|
622
|
+
const emit = (name, payload) => {
|
|
623
|
+
out.push(stateEvent("spatial", name, payload, now));
|
|
624
|
+
};
|
|
625
|
+
this.movementFrom(snapshot, now, emit);
|
|
626
|
+
this.turnFrom(snapshot, emit);
|
|
627
|
+
this.positionFrom(snapshot, emit);
|
|
628
|
+
this.confidenceFrom(snapshot, now, emit);
|
|
629
|
+
this.staleFrom(snapshot, emit);
|
|
630
|
+
return out;
|
|
631
|
+
}
|
|
632
|
+
movementFrom(s, now, emit) {
|
|
633
|
+
const state = s.movement?.state ?? "unknown";
|
|
634
|
+
this.run = extend(this.run, state, fixAt(s), now);
|
|
635
|
+
if (state === this.movement) return;
|
|
636
|
+
if (state === "moving" && settled(this.run, now, this.profile.movingHoldMs)) {
|
|
637
|
+
emit("movementStarted", {
|
|
638
|
+
previous: this.movement === "stationary" ? "stationary" : "unknown",
|
|
639
|
+
speedMps: s.movement?.speedMps ?? null,
|
|
640
|
+
bearingDeg: s.movement?.bearingDeg ?? null,
|
|
641
|
+
since: this.run.since
|
|
642
|
+
});
|
|
643
|
+
this.movement = "moving";
|
|
644
|
+
} else if (state === "stationary" && settled(this.run, now, this.profile.stoppedHoldMs)) {
|
|
645
|
+
if (this.movement === "moving") emit("movementStopped", { previous: "moving", since: this.run.since });
|
|
646
|
+
this.movement = "stationary";
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
turnFrom(s, emit) {
|
|
650
|
+
const at = s.changes?.timestamp ?? null;
|
|
651
|
+
const deg = s.changes?.lastMeaningfulTurnDeg ?? null;
|
|
652
|
+
if (at === null || deg === null || at === this.lastTurnAt) return;
|
|
653
|
+
this.lastTurnAt = at;
|
|
654
|
+
emit("lastMeaningfulTurnChanged", { lastMeaningfulTurnDeg: deg, timestamp: at });
|
|
655
|
+
}
|
|
656
|
+
positionFrom(s, emit) {
|
|
657
|
+
const p = s.position ?? null;
|
|
658
|
+
if (!p) return;
|
|
659
|
+
if (!this.anchor) {
|
|
660
|
+
emit("positionChanged", { position: { ...p }, previous: null, distanceM: null });
|
|
661
|
+
this.anchor = p;
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
const d = distanceOf([this.anchor, p]);
|
|
665
|
+
if (d < Math.max(this.profile.minMoveM, moveThresholdM(this.anchor, p))) {
|
|
666
|
+
const aNew = p.accuracyM ?? null, aOld = this.anchor.accuracyM ?? null;
|
|
667
|
+
if (aNew != null && aOld != null && aNew * 2 < aOld) this.anchor = p;
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
emit("positionChanged", { position: { ...p }, previous: { ...this.anchor }, distanceM: d });
|
|
671
|
+
this.anchor = p;
|
|
672
|
+
}
|
|
673
|
+
confidenceFrom(s, now, emit) {
|
|
674
|
+
const p = s.position ?? null;
|
|
675
|
+
if (!p) return;
|
|
676
|
+
const level = fixConfidence(p.accuracyM ?? null);
|
|
677
|
+
if (level === "unknown") return;
|
|
678
|
+
if (this.band === null) {
|
|
679
|
+
this.band = level;
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (level === this.band) {
|
|
683
|
+
this.bandRun = null;
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
this.bandRun = extend(this.bandRun, level, fixAt(s), now);
|
|
687
|
+
if (!settled(this.bandRun, now, this.profile.confidenceHoldMs)) return;
|
|
688
|
+
emit("positionConfidenceChanged", {
|
|
689
|
+
level,
|
|
690
|
+
previous: this.band,
|
|
691
|
+
accuracyM: p.accuracyM ?? null,
|
|
692
|
+
direction: RANK[level] < RANK[this.band] ? "dropped" : "recovered"
|
|
693
|
+
});
|
|
694
|
+
this.band = level;
|
|
695
|
+
this.bandRun = null;
|
|
696
|
+
}
|
|
697
|
+
staleFrom(s, emit) {
|
|
698
|
+
const f = s.quality?.freshness;
|
|
699
|
+
if (!f) return;
|
|
700
|
+
if (f.state === "fresh") {
|
|
701
|
+
this.staleArmed = true;
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (f.state !== "stale" || !this.staleArmed) return;
|
|
705
|
+
emit("spatialStateStale", { observedAt: f.observedAt, ageMs: f.ageMs });
|
|
706
|
+
this.staleArmed = false;
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
// src/device-state/domains/spatial.ts
|
|
711
|
+
var WATCH_ACCURACY = {
|
|
712
|
+
navigation: "navigation",
|
|
713
|
+
preciseLocate: "high",
|
|
714
|
+
fitness: "high",
|
|
715
|
+
geofence: "balanced",
|
|
716
|
+
lowPowerTracking: "low"
|
|
717
|
+
};
|
|
718
|
+
function createSpatialDomain(collectors, speedRef) {
|
|
719
|
+
const RECENT_PATH_MAX = 60;
|
|
720
|
+
let recentPath = [];
|
|
721
|
+
let latest2 = null;
|
|
722
|
+
let latestSpeed = null;
|
|
723
|
+
let latestCourse = null;
|
|
724
|
+
let stopWatch = null;
|
|
725
|
+
let session = null;
|
|
726
|
+
const detector = new SpatialEventDetector();
|
|
727
|
+
const listeners = listenerSet();
|
|
728
|
+
let ticker = null;
|
|
729
|
+
let turnSub = null;
|
|
730
|
+
const nonNegative = (v) => typeof v === "number" && v >= 0 ? v : null;
|
|
731
|
+
const derivedSpeed = () => {
|
|
732
|
+
if (recentPath.length < 2) return null;
|
|
733
|
+
const b = recentPath[recentPath.length - 1];
|
|
734
|
+
const a = recentPath[recentPath.length - 2];
|
|
735
|
+
const dtS = (b.timestamp - a.timestamp) / 1e3;
|
|
736
|
+
if (dtS <= 0 || dtS > 10) return null;
|
|
737
|
+
return distanceOf([a, b]) / dtS;
|
|
738
|
+
};
|
|
739
|
+
const derivedCourse = () => {
|
|
740
|
+
if (recentPath.length < 2) return null;
|
|
741
|
+
const b = recentPath[recentPath.length - 1];
|
|
742
|
+
const a = recentPath[recentPath.length - 2];
|
|
743
|
+
if (b.timestamp - a.timestamp > 1e4) return null;
|
|
744
|
+
if (distanceOf([a, b]) < 3) return null;
|
|
745
|
+
return bearingBetween(a, b);
|
|
746
|
+
};
|
|
747
|
+
const snapshotFrom = (point, measuredSpeed, measuredCourse) => {
|
|
748
|
+
let speedMps = measuredSpeed;
|
|
749
|
+
let courseDeg = measuredCourse;
|
|
750
|
+
let derived = false;
|
|
751
|
+
if (speedMps === null) {
|
|
752
|
+
const d = derivedSpeed();
|
|
753
|
+
if (d !== null) {
|
|
754
|
+
speedMps = d;
|
|
755
|
+
derived = true;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (courseDeg === null) {
|
|
759
|
+
const d = derivedCourse();
|
|
760
|
+
if (d !== null) {
|
|
761
|
+
courseDeg = d;
|
|
762
|
+
derived = true;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const heading = currentHeadingDetail();
|
|
766
|
+
const turn = lastMeaningfulTurn();
|
|
767
|
+
return {
|
|
768
|
+
position: point,
|
|
769
|
+
movement: {
|
|
770
|
+
speedMps,
|
|
771
|
+
bearingDeg: courseDeg,
|
|
772
|
+
// Needs two points from the SAME session; without a stream there is
|
|
773
|
+
// nothing to measure against, and a distance of 0 would read as
|
|
774
|
+
// "the user has not moved".
|
|
775
|
+
distanceRecentM: recentPath.length >= 2 ? distanceOf(recentPath) : null,
|
|
776
|
+
state: movementStateOf(speedMps)
|
|
777
|
+
},
|
|
778
|
+
orientation: {
|
|
779
|
+
headingDeg: heading?.headingDeg ?? null,
|
|
780
|
+
// NOT the calibration rank. iOS reports 0–3 (none…high), which is not
|
|
781
|
+
// a number of degrees, and converting one to the other would be an
|
|
782
|
+
// invented error bar. The rank drives `confidence` below instead.
|
|
783
|
+
headingAccuracyDeg: null
|
|
784
|
+
},
|
|
785
|
+
changes: {
|
|
786
|
+
lastMeaningfulTurnDeg: turn ? turn.deg : null,
|
|
787
|
+
timestamp: turn ? turn.timestamp : null
|
|
788
|
+
},
|
|
789
|
+
recentPath: [...recentPath],
|
|
790
|
+
quality: qualityFrom(
|
|
791
|
+
point.timestamp,
|
|
792
|
+
spatialConfidence(point.accuracyM ?? null, heading?.calibration ?? null, derived)
|
|
793
|
+
)
|
|
794
|
+
};
|
|
795
|
+
};
|
|
796
|
+
const noFix = () => ({
|
|
797
|
+
position: null,
|
|
798
|
+
movement: { speedMps: null, bearingDeg: null, distanceRecentM: null, state: "unknown" },
|
|
799
|
+
orientation: { headingDeg: currentHeadingDetail()?.headingDeg ?? null },
|
|
800
|
+
recentPath: [],
|
|
801
|
+
quality: qualityUnknown()
|
|
802
|
+
});
|
|
803
|
+
const warmSnapshot = () => latest2 ? snapshotFrom(latest2, latestSpeed, latestCourse) : null;
|
|
804
|
+
const observe = () => {
|
|
805
|
+
const snapshot = warmSnapshot();
|
|
806
|
+
if (!snapshot) return;
|
|
807
|
+
for (const event of detector.push(snapshot, Date.now())) listeners.emit(event);
|
|
808
|
+
};
|
|
809
|
+
const startTicking = () => {
|
|
810
|
+
ticker = setInterval(observe, 1e3);
|
|
811
|
+
};
|
|
812
|
+
const stopTicking = () => {
|
|
813
|
+
if (ticker) clearInterval(ticker);
|
|
814
|
+
ticker = null;
|
|
815
|
+
};
|
|
816
|
+
return {
|
|
817
|
+
async getSnapshot() {
|
|
818
|
+
const warm = warmSnapshot();
|
|
819
|
+
if (warm) return warm;
|
|
820
|
+
if (session) return noFix();
|
|
821
|
+
let point;
|
|
822
|
+
let speedMps;
|
|
823
|
+
let courseDeg;
|
|
824
|
+
try {
|
|
825
|
+
if (!collectors.location) throw unsupported("location collector");
|
|
826
|
+
const fix = await collectors.location.getPosition({ accuracy: "low" });
|
|
827
|
+
point = {
|
|
828
|
+
latitude: fix.latitude,
|
|
829
|
+
longitude: fix.longitude,
|
|
830
|
+
altitude: fix.altitude ?? null,
|
|
831
|
+
accuracyM: nonNegative(fix.accuracy),
|
|
832
|
+
timestamp: fix.timestamp ?? Date.now()
|
|
833
|
+
};
|
|
834
|
+
speedMps = nonNegative(fix.speed);
|
|
835
|
+
courseDeg = nonNegative(fix.course);
|
|
836
|
+
} catch {
|
|
837
|
+
return noFix();
|
|
838
|
+
}
|
|
839
|
+
return snapshotFrom(point, speedMps, courseDeg);
|
|
840
|
+
},
|
|
841
|
+
/**
|
|
842
|
+
* Open the warm window: position stream + compass + event feed.
|
|
843
|
+
*
|
|
844
|
+
* The intent selects how eager the EVENTS are (spatialEvents.ts profiles)
|
|
845
|
+
* and the GPS TIER the stream runs at (WATCH_ACCURACY): navigation pays the
|
|
846
|
+
* battery for BestForNavigation; low-power tracking does not.
|
|
847
|
+
*
|
|
848
|
+
* A second `start()` while one is open joins the running session and is
|
|
849
|
+
* told ITS intent, not handed back the one it asked for: answering with the
|
|
850
|
+
* caller's intent would claim a profile that is not in force.
|
|
851
|
+
*/
|
|
852
|
+
async start(options) {
|
|
853
|
+
if (session) return { ...session };
|
|
854
|
+
if (!collectors.location) throw unsupported("location collector");
|
|
855
|
+
const startedAt = Date.now();
|
|
856
|
+
session = { id: `spatial_${startedAt}`, intent: options.intent, startedAt };
|
|
857
|
+
detector.reset(spatialEventProfile(options.intent), lastMeaningfulTurn()?.timestamp ?? null);
|
|
858
|
+
void startHeading("spatial");
|
|
859
|
+
turnSub = onTurn(observe);
|
|
860
|
+
stopWatch = collectors.location.watchPosition((pos) => {
|
|
861
|
+
const p = {
|
|
862
|
+
latitude: pos.latitude,
|
|
863
|
+
longitude: pos.longitude,
|
|
864
|
+
altitude: pos.altitude ?? null,
|
|
865
|
+
accuracyM: nonNegative(pos.accuracy),
|
|
866
|
+
timestamp: pos.timestamp ?? Date.now()
|
|
867
|
+
};
|
|
868
|
+
latest2 = p;
|
|
869
|
+
latestSpeed = nonNegative(pos.speed);
|
|
870
|
+
latestCourse = nonNegative(pos.course);
|
|
871
|
+
speedRef.mps = latestSpeed;
|
|
872
|
+
speedRef.at = p.timestamp;
|
|
873
|
+
recentPath = [...recentPath, p].slice(-RECENT_PATH_MAX);
|
|
874
|
+
observe();
|
|
875
|
+
}, { accuracy: WATCH_ACCURACY[options.intent] ?? "balanced" });
|
|
876
|
+
startTicking();
|
|
877
|
+
return { ...session };
|
|
878
|
+
},
|
|
879
|
+
async stop() {
|
|
880
|
+
stopWatch?.();
|
|
881
|
+
stopWatch = null;
|
|
882
|
+
session = null;
|
|
883
|
+
stopTicking();
|
|
884
|
+
turnSub?.unsubscribe();
|
|
885
|
+
turnSub = null;
|
|
886
|
+
detector.reset();
|
|
887
|
+
recentPath = [];
|
|
888
|
+
latest2 = null;
|
|
889
|
+
latestSpeed = null;
|
|
890
|
+
latestCourse = null;
|
|
891
|
+
speedRef.mps = null;
|
|
892
|
+
speedRef.at = 0;
|
|
893
|
+
stopHeading("spatial");
|
|
894
|
+
},
|
|
895
|
+
/**
|
|
896
|
+
* The six spatial events (see `SpatialEvent`). Subscribing opens no sensor:
|
|
897
|
+
* events flow only while a session is open, and a subscription made before
|
|
898
|
+
* `start()` hears that session from its first fix.
|
|
899
|
+
*/
|
|
900
|
+
subscribe(listener) {
|
|
901
|
+
return listeners.add(listener);
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
function spatialConfidence(accuracyM, calibration, derived) {
|
|
906
|
+
const rank = (v) => v === "high" ? 3 : v === "medium" ? 2 : v === "low" ? 1 : 0;
|
|
907
|
+
const fromFix = fixConfidence(accuracyM);
|
|
908
|
+
const fromCompass = calibration === null ? "unknown" : calibration >= 3 ? "high" : calibration >= 2 ? "medium" : "low";
|
|
909
|
+
const parts = [fromFix, fromCompass, ...derived ? ["low"] : []];
|
|
910
|
+
const known = parts.filter((p) => p !== "unknown");
|
|
911
|
+
if (!known.length) return { level: "unknown" };
|
|
912
|
+
const worst = known.reduce((a, b) => rank(a) <= rank(b) ? a : b);
|
|
913
|
+
return { level: worst };
|
|
914
|
+
}
|
|
915
|
+
function bearingBetween(a, b) {
|
|
916
|
+
const toRad = (d) => d * Math.PI / 180;
|
|
917
|
+
const la1 = toRad(a.latitude), la2 = toRad(b.latitude);
|
|
918
|
+
const dLon = toRad(b.longitude - a.longitude);
|
|
919
|
+
const y = Math.sin(dLon) * Math.cos(la2);
|
|
920
|
+
const x = Math.cos(la1) * Math.sin(la2) - Math.sin(la1) * Math.cos(la2) * Math.cos(dLon);
|
|
921
|
+
return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// src/device-state/visualEngine.ts
|
|
925
|
+
var PROFILES = {
|
|
926
|
+
navigation: { intervalMs: 1e3, windowMs: 5e3 },
|
|
927
|
+
accessibility: { intervalMs: 1e3, windowMs: 5e3 },
|
|
928
|
+
stream: { intervalMs: 500, windowMs: 5e3 },
|
|
929
|
+
inspection: { intervalMs: 2e3, windowMs: 8e3 },
|
|
930
|
+
shopping: { intervalMs: 2e3, windowMs: 5e3 },
|
|
931
|
+
document: { intervalMs: 3e3, windowMs: 3e3 },
|
|
932
|
+
ocr: { intervalMs: 3e3, windowMs: 3e3 },
|
|
933
|
+
highQuality: { intervalMs: 5e3, windowMs: 5e3 }
|
|
934
|
+
};
|
|
935
|
+
var VisualEngine = class {
|
|
936
|
+
constructor() {
|
|
937
|
+
this.frames = [];
|
|
938
|
+
this.session = null;
|
|
939
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
940
|
+
/** Set by the host component while a camera is actually mounted and ready. */
|
|
941
|
+
this.cameraReady = false;
|
|
942
|
+
/** Optional: supplies heading/position stamped onto each frame. */
|
|
943
|
+
this.context = null;
|
|
944
|
+
// The native ring's actual retention (ms). The engine's effective window is
|
|
945
|
+
// min(intent window, retention) — set by whoever owns the clamp semantics; this
|
|
946
|
+
// just stores the input so that logic has one source of truth to read.
|
|
947
|
+
this._retentionMs = null;
|
|
948
|
+
}
|
|
949
|
+
/** Host DI. Without it frames carry no orientation, and say so with nulls. */
|
|
950
|
+
setContextProvider(p) {
|
|
951
|
+
this.context = p;
|
|
952
|
+
}
|
|
953
|
+
// ── session ──
|
|
954
|
+
startSession(intent) {
|
|
955
|
+
if (this.session) return this.session;
|
|
956
|
+
this.session = {
|
|
957
|
+
id: `visual_${Date.now()}`,
|
|
958
|
+
intent: PROFILES[intent] ? intent : "navigation",
|
|
959
|
+
startedAt: Date.now(),
|
|
960
|
+
suspended: false
|
|
961
|
+
};
|
|
962
|
+
this.notify();
|
|
963
|
+
return this.session;
|
|
964
|
+
}
|
|
965
|
+
stopSession() {
|
|
966
|
+
this.session = null;
|
|
967
|
+
this.frames = [];
|
|
968
|
+
this.cameraReady = false;
|
|
969
|
+
this.notify();
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Background: suspend, do not end.
|
|
973
|
+
*
|
|
974
|
+
* iOS tears the camera down when the app leaves the foreground. The session
|
|
975
|
+
* is a STATE that survives that; what does not survive is the claim that the
|
|
976
|
+
* camera is live. While suspended the ring is kept but ages — a read gets
|
|
977
|
+
* old frames clearly marked old, rather than a fresh-looking lie.
|
|
978
|
+
*/
|
|
979
|
+
suspend() {
|
|
980
|
+
if (!this.session || this.session.suspended) return;
|
|
981
|
+
this.session.suspended = true;
|
|
982
|
+
this.cameraReady = false;
|
|
983
|
+
this.notify();
|
|
984
|
+
}
|
|
985
|
+
resume() {
|
|
986
|
+
if (!this.session?.suspended) return;
|
|
987
|
+
this.session.suspended = false;
|
|
988
|
+
this.notify();
|
|
989
|
+
}
|
|
990
|
+
getSession() {
|
|
991
|
+
return this.session;
|
|
992
|
+
}
|
|
993
|
+
/** Is a camera mounted and ready right now? Only the host knows. */
|
|
994
|
+
setCameraReady(ready) {
|
|
995
|
+
this.cameraReady = ready;
|
|
996
|
+
this.notify();
|
|
997
|
+
}
|
|
998
|
+
isCameraReady() {
|
|
999
|
+
return this.cameraReady && !this.session?.suspended;
|
|
1000
|
+
}
|
|
1001
|
+
// ── the ring ──
|
|
1002
|
+
profile() {
|
|
1003
|
+
const intent = this.session?.intent || "navigation";
|
|
1004
|
+
return PROFILES[intent];
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Feed from the native vision session (VisionSession, iOS) — the FAST frame
|
|
1008
|
+
* source that replaces the takePictureAsync loop. The frame is IN-MEMORY: the
|
|
1009
|
+
* native ring holds the pixels keyed by `id`, and native.vision resolves that
|
|
1010
|
+
* id inside the trusted layer. No `uri` crosses here; the quality is a REAL
|
|
1011
|
+
* measurement (not `unknown`), and `expiresAt` is the ring's real eviction time.
|
|
1012
|
+
* The public VisualFrameRef strip to id-only is owned by the device-state session.
|
|
1013
|
+
*/
|
|
1014
|
+
pushFrameMeta(meta) {
|
|
1015
|
+
let heading = null;
|
|
1016
|
+
try {
|
|
1017
|
+
heading = this.context?.heading() ?? null;
|
|
1018
|
+
} catch {
|
|
1019
|
+
}
|
|
1020
|
+
this.frames.push({
|
|
1021
|
+
// In-memory: no file. native.vision resolves the id inside the native ring.
|
|
1022
|
+
handle: { id: meta.id, mimeType: "image/none", expiresAt: meta.expiresAt ?? null },
|
|
1023
|
+
timestamp: meta.timestamp,
|
|
1024
|
+
orientation: { headingDeg: heading?.headingDeg ?? null, pitchDeg: null, rollDeg: null },
|
|
1025
|
+
quality: meta.quality ?? { blur: "unknown", exposure: "unknown", obstructed: null }
|
|
1026
|
+
});
|
|
1027
|
+
this.evict();
|
|
1028
|
+
this.notify();
|
|
1029
|
+
}
|
|
1030
|
+
setRetentionMs(ms) {
|
|
1031
|
+
this._retentionMs = ms && ms > 0 ? ms : null;
|
|
1032
|
+
}
|
|
1033
|
+
retentionMs() {
|
|
1034
|
+
return this._retentionMs;
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* How far back the timeline ACTUALLY reaches: `min(intent window, native
|
|
1038
|
+
* retention)`.
|
|
1039
|
+
*
|
|
1040
|
+
* The intent asks for a window; native decides what it can afford to keep.
|
|
1041
|
+
* Full-res luma at ~24fps is tens of megabytes for a few seconds, so the ring
|
|
1042
|
+
* is memory-bounded and shrinks under pressure — `retentionMs` is native
|
|
1043
|
+
* publishing what it is really holding.
|
|
1044
|
+
*
|
|
1045
|
+
* EVERY FRAME IN THE TIMELINE CAN BE ANALYSED. That is the one sentence a
|
|
1046
|
+
* plugin author has to hold, and this clamp is what makes it true. The
|
|
1047
|
+
* alternative — listing frames past retention marked "not analysable" — was
|
|
1048
|
+
* considered and rejected: a caller would have to check a flag before every
|
|
1049
|
+
* use, and forgetting throws mid-guidance on a frame the model can see
|
|
1050
|
+
* listed. Present-but-unusable is the same trap as an empty rowset reading
|
|
1051
|
+
* as "no rows matched".
|
|
1052
|
+
*
|
|
1053
|
+
* If per-frame history beyond retention is ever needed, the shape is an
|
|
1054
|
+
* AGGREGATE on the snapshot ("the view has been unusable for N ms"), never a
|
|
1055
|
+
* per-entry flag.
|
|
1056
|
+
*/
|
|
1057
|
+
effectiveWindowMs() {
|
|
1058
|
+
const intent = this.profile().windowMs;
|
|
1059
|
+
const retention = this._retentionMs;
|
|
1060
|
+
return retention === null ? intent : Math.min(intent, retention);
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Time-based, not count-based: the design specifies a rolling WINDOW.
|
|
1064
|
+
*
|
|
1065
|
+
* Evicts on the EFFECTIVE window, so a frame whose pixels native has already
|
|
1066
|
+
* dropped leaves the timeline at the same moment. Without this, `frameById`
|
|
1067
|
+
* would start returning null for frames still being listed.
|
|
1068
|
+
*/
|
|
1069
|
+
evict() {
|
|
1070
|
+
const cutoff = Date.now() - this.effectiveWindowMs();
|
|
1071
|
+
this.frames = this.frames.filter((f) => f.timestamp >= cutoff);
|
|
1072
|
+
}
|
|
1073
|
+
timeline() {
|
|
1074
|
+
this.evict();
|
|
1075
|
+
return { windowMs: this.effectiveWindowMs(), frames: [...this.frames] };
|
|
1076
|
+
}
|
|
1077
|
+
latest() {
|
|
1078
|
+
this.evict();
|
|
1079
|
+
return this.frames.length ? this.frames[this.frames.length - 1] : null;
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Resolve a frame handle id (from a VisualFrameRef) back to the live frame so
|
|
1083
|
+
* the mechanical layer — native.vision — can read its on-device file. The
|
|
1084
|
+
* whole contract of that layer is that it takes an id, never pixels; this is
|
|
1085
|
+
* where an id becomes a `handle.uri` it can open. Returns null when the id is
|
|
1086
|
+
* unknown OR its frame has aged out of the rolling window (the cache file may
|
|
1087
|
+
* also be gone by then) — a real "I can't look at that frame" answer, which
|
|
1088
|
+
* the caller must treat as distinct from "I looked and found nothing".
|
|
1089
|
+
*/
|
|
1090
|
+
frameById(id) {
|
|
1091
|
+
this.evict();
|
|
1092
|
+
return this.frames.find((f) => f.handle.id === id) ?? null;
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Record a MEASURED quality back onto a frame (native.vision.assessQuality).
|
|
1096
|
+
* Frames are minted with `quality: unknown` because nothing looks at the
|
|
1097
|
+
* pixels at capture time; this is how a real measurement replaces that so a
|
|
1098
|
+
* later read of VisualFrameRef.quality reflects it. No-op if the frame has
|
|
1099
|
+
* aged out — we never resurrect an evicted frame just to label it.
|
|
1100
|
+
*/
|
|
1101
|
+
setFrameQuality(id, quality) {
|
|
1102
|
+
const f = this.frames.find((fr) => fr.handle.id === id);
|
|
1103
|
+
if (!f) return;
|
|
1104
|
+
f.quality = quality;
|
|
1105
|
+
this.notify();
|
|
1106
|
+
}
|
|
1107
|
+
// ── change notification ──
|
|
1108
|
+
subscribe(fn) {
|
|
1109
|
+
this.listeners.add(fn);
|
|
1110
|
+
return () => this.listeners.delete(fn);
|
|
1111
|
+
}
|
|
1112
|
+
notify() {
|
|
1113
|
+
for (const fn of this.listeners) {
|
|
1114
|
+
try {
|
|
1115
|
+
fn();
|
|
1116
|
+
} catch {
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
var visualEngine = new VisualEngine();
|
|
1122
|
+
function frameQuality(frame) {
|
|
1123
|
+
if (!frame) return { freshness: { state: "unknown", observedAt: null, ageMs: null } };
|
|
1124
|
+
const ageMs = Date.now() - frame.timestamp;
|
|
1125
|
+
return {
|
|
1126
|
+
freshness: {
|
|
1127
|
+
// Tighter than the other domains on purpose: a two-second-old frame of a
|
|
1128
|
+
// moving scene is not "fresh" in any useful sense.
|
|
1129
|
+
state: ageMs < 2e3 ? "fresh" : ageMs < 1e4 ? "aging" : "stale",
|
|
1130
|
+
observedAt: frame.timestamp,
|
|
1131
|
+
ageMs
|
|
1132
|
+
},
|
|
1133
|
+
confidence: { level: "unknown" }
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// src/device-state/domains/visual.ts
|
|
1138
|
+
function cameraState() {
|
|
1139
|
+
const session = visualEngine.getSession();
|
|
1140
|
+
return {
|
|
1141
|
+
// `available` = the app could use a camera at all. `active` = one is
|
|
1142
|
+
// running RIGHT NOW. They differ exactly when it matters: a pocketed
|
|
1143
|
+
// phone is available but not active, and answering `true` to both
|
|
1144
|
+
// would let a guide speak about a scene nothing is watching.
|
|
1145
|
+
available: !!session,
|
|
1146
|
+
active: visualEngine.isCameraReady(),
|
|
1147
|
+
facing: session ? "back" : "unknown"
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
function createVisualDomain() {
|
|
1151
|
+
return {
|
|
1152
|
+
async getSnapshot() {
|
|
1153
|
+
const latest2 = visualEngine.latest();
|
|
1154
|
+
const timeline = visualEngine.timeline();
|
|
1155
|
+
return {
|
|
1156
|
+
camera: cameraState(),
|
|
1157
|
+
// null, not a stale frame dressed as current. A caller with no usable
|
|
1158
|
+
// frame must see nothing rather than something old.
|
|
1159
|
+
latest: latest2,
|
|
1160
|
+
recentTimeline: timeline,
|
|
1161
|
+
quality: frameQuality(latest2)
|
|
1162
|
+
};
|
|
1163
|
+
},
|
|
1164
|
+
async start(options) {
|
|
1165
|
+
const s = visualEngine.startSession(options?.intent || "navigation");
|
|
1166
|
+
visualEngine.setContextProvider({ heading: currentHeading });
|
|
1167
|
+
void startHeading("visual");
|
|
1168
|
+
return { id: s.id, intent: s.intent, startedAt: s.startedAt };
|
|
1169
|
+
},
|
|
1170
|
+
async stop() {
|
|
1171
|
+
visualEngine.stopSession();
|
|
1172
|
+
visualEngine.setContextProvider(null);
|
|
1173
|
+
stopHeading("visual");
|
|
1174
|
+
},
|
|
1175
|
+
/**
|
|
1176
|
+
* A deliberate one-shot. Distinct from getSnapshot because it is an
|
|
1177
|
+
* OPERATION: it waits for a frame rather than serving the newest one.
|
|
1178
|
+
*
|
|
1179
|
+
* Throws when it cannot, rather than returning an empty result — the
|
|
1180
|
+
* design is explicit that capture is an operation and reads are state.
|
|
1181
|
+
*/
|
|
1182
|
+
async capture() {
|
|
1183
|
+
if (!visualEngine.getSession()) {
|
|
1184
|
+
throw unsupported("visual.capture without an open session \u2014 call start({ intent }) first");
|
|
1185
|
+
}
|
|
1186
|
+
if (!visualEngine.isCameraReady()) {
|
|
1187
|
+
const err2 = new Error(
|
|
1188
|
+
"The camera is not running right now \u2014 the app may be in the background."
|
|
1189
|
+
);
|
|
1190
|
+
err2.code = "TEMPORARILY_UNAVAILABLE";
|
|
1191
|
+
throw err2;
|
|
1192
|
+
}
|
|
1193
|
+
const before = visualEngine.latest()?.timestamp ?? 0;
|
|
1194
|
+
const deadline = Date.now() + 4e3;
|
|
1195
|
+
while (Date.now() < deadline) {
|
|
1196
|
+
const f = visualEngine.latest();
|
|
1197
|
+
if (f && f.timestamp > before) return { frame: f };
|
|
1198
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1199
|
+
}
|
|
1200
|
+
const err = new Error("No frame arrived within 4s.");
|
|
1201
|
+
err.code = "TIMEOUT";
|
|
1202
|
+
throw err;
|
|
1203
|
+
},
|
|
1204
|
+
/**
|
|
1205
|
+
* Fires when the CAMERA block changes — a session opening or closing, the
|
|
1206
|
+
* camera coming up or going down. The engine notifies on every frame too,
|
|
1207
|
+
* and forwarding that made this a raw frame stream, which the freeze's core
|
|
1208
|
+
* rule 4 forbids. A frame arriving is what `getSnapshot()` is for.
|
|
1209
|
+
*/
|
|
1210
|
+
subscribe(listener) {
|
|
1211
|
+
let last = JSON.stringify(cameraState());
|
|
1212
|
+
const off = visualEngine.subscribe(() => {
|
|
1213
|
+
const camera = cameraState();
|
|
1214
|
+
const key = JSON.stringify(camera);
|
|
1215
|
+
if (key === last) return;
|
|
1216
|
+
last = key;
|
|
1217
|
+
listener(stateEvent("visual", "visualStateChanged", { camera }));
|
|
1218
|
+
});
|
|
1219
|
+
return { unsubscribe: off };
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// src/device-state/activityEngine.ts
|
|
1225
|
+
var sensors = null;
|
|
1226
|
+
var sensorsMissing = false;
|
|
1227
|
+
function loadSensors() {
|
|
1228
|
+
if (sensors || sensorsMissing) return sensors;
|
|
1229
|
+
try {
|
|
1230
|
+
sensors = __require("expo-sensors");
|
|
1231
|
+
} catch {
|
|
1232
|
+
sensorsMissing = true;
|
|
1233
|
+
sensors = null;
|
|
1234
|
+
}
|
|
1235
|
+
return sensors;
|
|
1236
|
+
}
|
|
1237
|
+
var ACCEL_INTERVAL_MS = 200;
|
|
1238
|
+
var MOTION_WINDOW_MS = 3e3;
|
|
1239
|
+
var STEP_WINDOW_MS = 2e4;
|
|
1240
|
+
var GRAVITY_ALPHA = 0.1;
|
|
1241
|
+
var ActivityEngine = class {
|
|
1242
|
+
constructor() {
|
|
1243
|
+
this.accelSub = null;
|
|
1244
|
+
this.stepSub = null;
|
|
1245
|
+
this.magnitudes = [];
|
|
1246
|
+
this.gravity = null;
|
|
1247
|
+
this.steps = [];
|
|
1248
|
+
this.sessionId = null;
|
|
1249
|
+
this.startedAt = 0;
|
|
1250
|
+
/** When the CURRENT reading last changed — feeds `human.since`. */
|
|
1251
|
+
this.pedometerAvailable = null;
|
|
1252
|
+
// ── transitions ──
|
|
1253
|
+
//
|
|
1254
|
+
// Tracked on a timer inside the engine rather than computed when someone
|
|
1255
|
+
// reads, because a transition is an EVENT: "you started walking" happens
|
|
1256
|
+
// between two reads, and a domain that only classifies on demand reports the
|
|
1257
|
+
// state and loses the moment it changed. `since` has the same problem.
|
|
1258
|
+
this.current = "unknown";
|
|
1259
|
+
this.since = null;
|
|
1260
|
+
this.transitions = [];
|
|
1261
|
+
this.ticker = null;
|
|
1262
|
+
}
|
|
1263
|
+
isRunning() {
|
|
1264
|
+
return this.sessionId !== null;
|
|
1265
|
+
}
|
|
1266
|
+
session() {
|
|
1267
|
+
return this.sessionId ? { id: this.sessionId, startedAt: this.startedAt } : null;
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* @param classify how to name the current activity — injected so the engine
|
|
1271
|
+
* keeps no opinion of its own and the classifier stays pure and testable.
|
|
1272
|
+
*/
|
|
1273
|
+
async start(classify) {
|
|
1274
|
+
if (this.sessionId) return this.session();
|
|
1275
|
+
this.sessionId = `activity_${Date.now()}`;
|
|
1276
|
+
this.startedAt = Date.now();
|
|
1277
|
+
const s = loadSensors();
|
|
1278
|
+
if (!s) {
|
|
1279
|
+
this.pedometerAvailable = false;
|
|
1280
|
+
this.startTicking(classify);
|
|
1281
|
+
return this.session();
|
|
1282
|
+
}
|
|
1283
|
+
const { Accelerometer, Pedometer } = s;
|
|
1284
|
+
Accelerometer.setUpdateInterval(ACCEL_INTERVAL_MS);
|
|
1285
|
+
this.accelSub = Accelerometer.addListener(({ x, y, z }) => {
|
|
1286
|
+
const now = Date.now();
|
|
1287
|
+
this.magnitudes.push({ m: Math.sqrt(x * x + y * y + z * z), t: now });
|
|
1288
|
+
const cutoff = now - MOTION_WINDOW_MS;
|
|
1289
|
+
if (this.magnitudes.length > 8 && this.magnitudes[0].t < cutoff) {
|
|
1290
|
+
this.magnitudes = this.magnitudes.filter((s2) => s2.t >= cutoff);
|
|
1291
|
+
}
|
|
1292
|
+
this.gravity = this.gravity ? {
|
|
1293
|
+
x: this.gravity.x + GRAVITY_ALPHA * (x - this.gravity.x),
|
|
1294
|
+
y: this.gravity.y + GRAVITY_ALPHA * (y - this.gravity.y),
|
|
1295
|
+
z: this.gravity.z + GRAVITY_ALPHA * (z - this.gravity.z)
|
|
1296
|
+
} : { x, y, z };
|
|
1297
|
+
});
|
|
1298
|
+
try {
|
|
1299
|
+
this.pedometerAvailable = await Pedometer.isAvailableAsync();
|
|
1300
|
+
} catch {
|
|
1301
|
+
this.pedometerAvailable = false;
|
|
1302
|
+
}
|
|
1303
|
+
if (this.pedometerAvailable) {
|
|
1304
|
+
try {
|
|
1305
|
+
this.stepSub = Pedometer.watchStepCount(({ steps }) => {
|
|
1306
|
+
this.steps.push({ steps, t: Date.now() });
|
|
1307
|
+
const cutoff = Date.now() - STEP_WINDOW_MS;
|
|
1308
|
+
if (this.steps.length > 2 && this.steps[0].t < cutoff) {
|
|
1309
|
+
this.steps = this.steps.filter((s2) => s2.t >= cutoff);
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
} catch {
|
|
1313
|
+
this.stepSub = null;
|
|
1314
|
+
this.pedometerAvailable = false;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
this.startTicking(classify);
|
|
1318
|
+
return this.session();
|
|
1319
|
+
}
|
|
1320
|
+
stop() {
|
|
1321
|
+
this.accelSub?.remove();
|
|
1322
|
+
this.stepSub?.remove();
|
|
1323
|
+
this.accelSub = null;
|
|
1324
|
+
this.stepSub = null;
|
|
1325
|
+
this.sessionId = null;
|
|
1326
|
+
this.stopTicking();
|
|
1327
|
+
this.magnitudes = [];
|
|
1328
|
+
this.steps = [];
|
|
1329
|
+
this.gravity = null;
|
|
1330
|
+
}
|
|
1331
|
+
/** Variance of accelerometer magnitude over the motion window, in g². */
|
|
1332
|
+
accelVariance() {
|
|
1333
|
+
const cutoff = Date.now() - MOTION_WINDOW_MS;
|
|
1334
|
+
const window = this.magnitudes.filter((s) => s.t >= cutoff);
|
|
1335
|
+
if (window.length < 5) return null;
|
|
1336
|
+
const mean = window.reduce((a, s) => a + s.m, 0) / window.length;
|
|
1337
|
+
return window.reduce((a, s) => a + (s.m - mean) ** 2, 0) / window.length;
|
|
1338
|
+
}
|
|
1339
|
+
currentGravity() {
|
|
1340
|
+
return this.gravity;
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Steps per minute over the step window.
|
|
1344
|
+
*
|
|
1345
|
+
* Computed from the DIFFERENCE between the first and last cumulative counts,
|
|
1346
|
+
* so it does not matter whether the platform counts from subscription start
|
|
1347
|
+
* or from midnight.
|
|
1348
|
+
*
|
|
1349
|
+
* Returns null — not 0 — when the window is too short to divide by. Zero
|
|
1350
|
+
* steps per minute is a real claim ("standing still") and must only be made
|
|
1351
|
+
* once there is a window long enough to support it.
|
|
1352
|
+
*/
|
|
1353
|
+
cadenceSpm() {
|
|
1354
|
+
const cutoff = Date.now() - STEP_WINDOW_MS;
|
|
1355
|
+
const window = this.steps.filter((s) => s.t >= cutoff);
|
|
1356
|
+
if (window.length < 2) {
|
|
1357
|
+
if (this.pedometerAvailable && Date.now() - this.startedAt > STEP_WINDOW_MS) return 0;
|
|
1358
|
+
return null;
|
|
1359
|
+
}
|
|
1360
|
+
const first = window[0], last = window[window.length - 1];
|
|
1361
|
+
const minutes = (last.t - first.t) / 6e4;
|
|
1362
|
+
if (minutes <= 0) return null;
|
|
1363
|
+
return (last.steps - first.steps) / minutes;
|
|
1364
|
+
}
|
|
1365
|
+
/** Whether a pedometer exists and is permitted. Null before `start()`. */
|
|
1366
|
+
hasPedometer() {
|
|
1367
|
+
return this.pedometerAvailable;
|
|
1368
|
+
}
|
|
1369
|
+
/** When the newest sample arrived — what freshness is actually about. */
|
|
1370
|
+
observedAt() {
|
|
1371
|
+
const ts = [
|
|
1372
|
+
this.magnitudes[this.magnitudes.length - 1]?.t,
|
|
1373
|
+
this.steps[this.steps.length - 1]?.t
|
|
1374
|
+
].filter((t) => typeof t === "number");
|
|
1375
|
+
return ts.length ? Math.max(...ts) : null;
|
|
1376
|
+
}
|
|
1377
|
+
/** Feed the engine its own classification once a second. */
|
|
1378
|
+
startTicking(classify) {
|
|
1379
|
+
this.ticker = setInterval(() => {
|
|
1380
|
+
const next = classify();
|
|
1381
|
+
if (next === this.current) return;
|
|
1382
|
+
this.current = next;
|
|
1383
|
+
this.since = Date.now();
|
|
1384
|
+
this.transitions = [...this.transitions, { name: next, timestamp: this.since }].slice(-10);
|
|
1385
|
+
}, 1e3);
|
|
1386
|
+
}
|
|
1387
|
+
stopTicking() {
|
|
1388
|
+
if (this.ticker) clearInterval(this.ticker);
|
|
1389
|
+
this.ticker = null;
|
|
1390
|
+
this.current = "unknown";
|
|
1391
|
+
this.since = null;
|
|
1392
|
+
this.transitions = [];
|
|
1393
|
+
}
|
|
1394
|
+
/** When the current activity began, or null if it has not been established. */
|
|
1395
|
+
currentSince() {
|
|
1396
|
+
return this.since;
|
|
1397
|
+
}
|
|
1398
|
+
/** Newest last, bounded. Empty is a real answer: nothing has changed yet. */
|
|
1399
|
+
recentTransitions() {
|
|
1400
|
+
return [...this.transitions];
|
|
1401
|
+
}
|
|
1402
|
+
};
|
|
1403
|
+
var activityEngine = new ActivityEngine();
|
|
1404
|
+
|
|
1405
|
+
// src/device-state/activityClassifier.ts
|
|
1406
|
+
var RUN_SPM = 140;
|
|
1407
|
+
var WALK_SPM = 20;
|
|
1408
|
+
var STILL_VAR = 2e-3;
|
|
1409
|
+
var ACTIVE_VAR = 0.02;
|
|
1410
|
+
var VEHICLE_MPS = 3;
|
|
1411
|
+
var AXIS_DOMINANT = 0.75;
|
|
1412
|
+
function classifyActivity(input) {
|
|
1413
|
+
return { human: classifyHuman(input), pose: classifyPose(input) };
|
|
1414
|
+
}
|
|
1415
|
+
function classifyHuman({ cadenceSpm, accelVariance, speedMps }) {
|
|
1416
|
+
if (cadenceSpm !== null && cadenceSpm >= RUN_SPM) {
|
|
1417
|
+
return { state: "running", confidence: { level: "high" } };
|
|
1418
|
+
}
|
|
1419
|
+
if (cadenceSpm !== null && cadenceSpm >= WALK_SPM) {
|
|
1420
|
+
return { state: "walking", confidence: { level: "high" } };
|
|
1421
|
+
}
|
|
1422
|
+
if (speedMps !== null && speedMps >= VEHICLE_MPS) {
|
|
1423
|
+
return { state: "unknown", confidence: { level: "low" } };
|
|
1424
|
+
}
|
|
1425
|
+
if (accelVariance !== null && accelVariance < STILL_VAR && (cadenceSpm === null || cadenceSpm < WALK_SPM)) {
|
|
1426
|
+
return { state: "stationary", confidence: { level: "medium" } };
|
|
1427
|
+
}
|
|
1428
|
+
return { state: "unknown", confidence: { level: "unknown" } };
|
|
1429
|
+
}
|
|
1430
|
+
function classifyPose({ gravity, accelVariance }) {
|
|
1431
|
+
if (!gravity) {
|
|
1432
|
+
return { placement: "unknown", orientation: "unknown", confidence: { level: "unknown" } };
|
|
1433
|
+
}
|
|
1434
|
+
const { x, y, z } = gravity;
|
|
1435
|
+
const orientation = Math.abs(z) >= AXIS_DOMINANT ? "flat" : Math.abs(y) >= AXIS_DOMINANT ? "portrait" : Math.abs(x) >= AXIS_DOMINANT ? "landscape" : "unknown";
|
|
1436
|
+
if (orientation === "flat" && accelVariance !== null && accelVariance < STILL_VAR) {
|
|
1437
|
+
return { placement: "table", orientation, confidence: { level: "high" } };
|
|
1438
|
+
}
|
|
1439
|
+
if (orientation !== "flat" && accelVariance !== null && accelVariance >= ACTIVE_VAR) {
|
|
1440
|
+
return { placement: "handheld", orientation, confidence: { level: "medium" } };
|
|
1441
|
+
}
|
|
1442
|
+
return { placement: "unknown", orientation, confidence: { level: "unknown" } };
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// src/device-state/domains/activity.ts
|
|
1446
|
+
function createActivityDomain(speedRef) {
|
|
1447
|
+
const SPEED_MAX_AGE_MS = 15e3;
|
|
1448
|
+
const input = () => ({
|
|
1449
|
+
cadenceSpm: activityEngine.cadenceSpm(),
|
|
1450
|
+
accelVariance: activityEngine.accelVariance(),
|
|
1451
|
+
gravity: activityEngine.currentGravity(),
|
|
1452
|
+
speedMps: speedRef.at && Date.now() - speedRef.at < SPEED_MAX_AGE_MS ? speedRef.mps : null
|
|
1453
|
+
});
|
|
1454
|
+
return {
|
|
1455
|
+
async getSnapshot() {
|
|
1456
|
+
if (!activityEngine.isRunning()) {
|
|
1457
|
+
return {
|
|
1458
|
+
human: { state: "unknown", since: null, confidence: { level: "unknown" } },
|
|
1459
|
+
devicePose: { placement: "unknown", orientation: "unknown", confidence: { level: "unknown" } },
|
|
1460
|
+
recentTransitions: [],
|
|
1461
|
+
quality: qualityUnknown()
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
const verdict = classifyActivity(input());
|
|
1465
|
+
const observedAt = activityEngine.observedAt();
|
|
1466
|
+
const transitions = activityEngine.recentTransitions();
|
|
1467
|
+
return {
|
|
1468
|
+
human: {
|
|
1469
|
+
state: verdict.human.state,
|
|
1470
|
+
since: activityEngine.currentSince(),
|
|
1471
|
+
confidence: verdict.human.confidence
|
|
1472
|
+
},
|
|
1473
|
+
devicePose: {
|
|
1474
|
+
placement: verdict.pose.placement,
|
|
1475
|
+
orientation: verdict.pose.orientation,
|
|
1476
|
+
confidence: verdict.pose.confidence
|
|
1477
|
+
},
|
|
1478
|
+
transition: {
|
|
1479
|
+
current: verdict.human.state,
|
|
1480
|
+
// A hint, and we have no basis for one. The freeze calls `likelyNext`
|
|
1481
|
+
// advisory; advisory is not licence to invent it.
|
|
1482
|
+
likelyNext: null
|
|
1483
|
+
},
|
|
1484
|
+
recentTransitions: transitions,
|
|
1485
|
+
quality: observedAt === null ? qualityUnknown() : qualityFrom(observedAt, verdict.human.confidence)
|
|
1486
|
+
};
|
|
1487
|
+
},
|
|
1488
|
+
/**
|
|
1489
|
+
* Start sampling. The intent is recorded but does not yet change strategy —
|
|
1490
|
+
* there is no native power-tuning layer for it to reach.
|
|
1491
|
+
*/
|
|
1492
|
+
async start(options) {
|
|
1493
|
+
const s = await activityEngine.start(() => classifyActivity(input()).human.state);
|
|
1494
|
+
return { id: s.id, intent: options.intent, startedAt: s.startedAt };
|
|
1495
|
+
},
|
|
1496
|
+
async stop() {
|
|
1497
|
+
activityEngine.stop();
|
|
1498
|
+
},
|
|
1499
|
+
// No activity event vocabulary is frozen — the design notes there is no
|
|
1500
|
+
// "Initial Activity Events" list at all — so naming events here would put
|
|
1501
|
+
// words in plugins that the freeze may contradict.
|
|
1502
|
+
subscribe() {
|
|
1503
|
+
throw unsupported("activity.subscribe");
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// src/device-state/index.ts
|
|
1509
|
+
function createDeviceState(collectors = {}) {
|
|
1510
|
+
const speedRef = { mps: null, at: 0 };
|
|
1511
|
+
return {
|
|
1512
|
+
deviceContext: createDeviceContextDomain(),
|
|
1513
|
+
connectivity: createConnectivityDomain(collectors),
|
|
1514
|
+
capability: createCapabilityDomain(collectors),
|
|
1515
|
+
interaction: createInteractionDomain(),
|
|
1516
|
+
spatial: createSpatialDomain(collectors, speedRef),
|
|
1517
|
+
visual: createVisualDomain(),
|
|
1518
|
+
activity: createActivityDomain(speedRef)
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
var IMPLEMENTED_DOMAINS = [
|
|
1522
|
+
"deviceContext",
|
|
1523
|
+
"connectivity",
|
|
1524
|
+
"capability",
|
|
1525
|
+
"interaction",
|
|
1526
|
+
"spatial",
|
|
1527
|
+
"visual",
|
|
1528
|
+
"activity"
|
|
1529
|
+
];
|
|
1530
|
+
|
|
1531
|
+
// src/camera/CameraTaskHost.tsx
|
|
1532
|
+
import { useEffect, useRef, useState } from "react";
|
|
1533
|
+
import { Pressable, StyleSheet, Text, View } from "react-native";
|
|
1534
|
+
import { CameraView, useCameraPermissions, useMicrophonePermissions } from "expo-camera";
|
|
1535
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
1536
|
+
var SCAN_TIMEOUT_MS = 3e4;
|
|
1537
|
+
var MAX_RECORD_MS = 6e4;
|
|
1538
|
+
function CameraTaskHost() {
|
|
1539
|
+
const [task, setTask] = useState(cameraTasks.current());
|
|
1540
|
+
const [camPermission, requestCamPermission] = useCameraPermissions();
|
|
1541
|
+
const [micPermission, requestMicPermission] = useMicrophonePermissions();
|
|
1542
|
+
const cameraRef = useRef(null);
|
|
1543
|
+
const settling = useRef(false);
|
|
1544
|
+
useEffect(() => {
|
|
1545
|
+
cameraTasks.setHostMounted(true);
|
|
1546
|
+
return () => cameraTasks.setHostMounted(false);
|
|
1547
|
+
}, []);
|
|
1548
|
+
useEffect(() => cameraTasks.subscribe(() => {
|
|
1549
|
+
settling.current = false;
|
|
1550
|
+
setTask(cameraTasks.current());
|
|
1551
|
+
}), []);
|
|
1552
|
+
useEffect(() => {
|
|
1553
|
+
if (!task) return;
|
|
1554
|
+
let cancelled = false;
|
|
1555
|
+
void (async () => {
|
|
1556
|
+
const cam = camPermission?.granted ? camPermission : await requestCamPermission();
|
|
1557
|
+
if (cancelled) return;
|
|
1558
|
+
if (!cam?.granted) {
|
|
1559
|
+
cameraTasks.fail(cameraError(
|
|
1560
|
+
"PERMISSION_DENIED",
|
|
1561
|
+
"Camera permission was not granted. Enable it for this app in Settings > Privacy > Camera."
|
|
1562
|
+
));
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
if (task.kind === "record" && task.opts.withAudio) {
|
|
1566
|
+
const mic = micPermission?.granted ? micPermission : await requestMicPermission();
|
|
1567
|
+
if (cancelled) return;
|
|
1568
|
+
if (!mic?.granted) {
|
|
1569
|
+
cameraTasks.fail(cameraError(
|
|
1570
|
+
"PERMISSION_DENIED",
|
|
1571
|
+
"Microphone permission was not granted, so a video with sound cannot be recorded."
|
|
1572
|
+
));
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
})();
|
|
1576
|
+
return () => {
|
|
1577
|
+
cancelled = true;
|
|
1578
|
+
};
|
|
1579
|
+
}, [task]);
|
|
1580
|
+
useEffect(() => {
|
|
1581
|
+
if (task?.kind !== "scan") return;
|
|
1582
|
+
const ms = task.opts.timeoutMs ?? SCAN_TIMEOUT_MS;
|
|
1583
|
+
const t = setTimeout(() => {
|
|
1584
|
+
cameraTasks.fail(cameraError("TIMEOUT", `No barcode was found within ${Math.round(ms / 1e3)}s.`));
|
|
1585
|
+
}, ms);
|
|
1586
|
+
return () => clearTimeout(t);
|
|
1587
|
+
}, [task]);
|
|
1588
|
+
useEffect(() => {
|
|
1589
|
+
if (task?.kind !== "record" || !camPermission?.granted) return;
|
|
1590
|
+
let cancelled = false;
|
|
1591
|
+
const maxMs = Math.min(task.opts.maxDurationMs ?? 15e3, MAX_RECORD_MS);
|
|
1592
|
+
const startedAt = Date.now();
|
|
1593
|
+
void (async () => {
|
|
1594
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1595
|
+
if (cancelled || !cameraRef.current) return;
|
|
1596
|
+
try {
|
|
1597
|
+
const video = await cameraRef.current.recordAsync({ maxDuration: Math.ceil(maxMs / 1e3) });
|
|
1598
|
+
if (cancelled) return;
|
|
1599
|
+
if (!video?.uri) {
|
|
1600
|
+
cameraTasks.fail(cameraError("INTERNAL", "Recording finished but produced no file."));
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
cameraTasks.complete({ uri: video.uri, durationMs: Date.now() - startedAt });
|
|
1604
|
+
} catch (err) {
|
|
1605
|
+
if (!cancelled) cameraTasks.fail(cameraError("INTERNAL", err?.message || "Recording failed."));
|
|
1606
|
+
}
|
|
1607
|
+
})();
|
|
1608
|
+
return () => {
|
|
1609
|
+
cancelled = true;
|
|
1610
|
+
try {
|
|
1611
|
+
cameraRef.current?.stopRecording();
|
|
1612
|
+
} catch {
|
|
1613
|
+
}
|
|
1614
|
+
};
|
|
1615
|
+
}, [task, camPermission?.granted]);
|
|
1616
|
+
if (!task || !camPermission?.granted) return null;
|
|
1617
|
+
const isScan = task.kind === "scan";
|
|
1618
|
+
return /* @__PURE__ */ jsxs(View, { style: styles.overlay, children: [
|
|
1619
|
+
/* @__PURE__ */ jsx(
|
|
1620
|
+
CameraView,
|
|
1621
|
+
{
|
|
1622
|
+
ref: cameraRef,
|
|
1623
|
+
style: StyleSheet.absoluteFill,
|
|
1624
|
+
facing: "back",
|
|
1625
|
+
mode: isScan ? "picture" : "video",
|
|
1626
|
+
barcodeScannerSettings: isScan && task.opts.formats?.length ? { barcodeTypes: task.opts.formats } : void 0,
|
|
1627
|
+
onBarcodeScanned: isScan ? ({ data, type }) => {
|
|
1628
|
+
if (settling.current) return;
|
|
1629
|
+
settling.current = true;
|
|
1630
|
+
cameraTasks.complete({ data, format: type });
|
|
1631
|
+
} : void 0
|
|
1632
|
+
}
|
|
1633
|
+
),
|
|
1634
|
+
/* @__PURE__ */ jsxs(View, { style: styles.hud, pointerEvents: "box-none", children: [
|
|
1635
|
+
/* @__PURE__ */ jsx(Text, { style: styles.hint, children: isScan ? "Point the camera at the code" : "Recording\u2026" }),
|
|
1636
|
+
/* @__PURE__ */ jsx(Pressable, { style: styles.cancel, onPress: () => cameraTasks.cancel(), accessibilityRole: "button", children: /* @__PURE__ */ jsx(Text, { style: styles.cancelText, children: "Cancel" }) })
|
|
1637
|
+
] })
|
|
1638
|
+
] });
|
|
1639
|
+
}
|
|
1640
|
+
var styles = StyleSheet.create({
|
|
1641
|
+
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: "#000", zIndex: 9999 },
|
|
1642
|
+
hud: { flex: 1, justifyContent: "space-between", paddingVertical: 64, alignItems: "center" },
|
|
1643
|
+
hint: { color: "#fff", fontSize: 16, textAlign: "center", paddingHorizontal: 24 },
|
|
1644
|
+
cancel: { paddingHorizontal: 28, paddingVertical: 14, borderRadius: 24, backgroundColor: "rgba(255,255,255,0.18)" },
|
|
1645
|
+
cancelText: { color: "#fff", fontSize: 16, fontWeight: "600" }
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
// src/device-state/replay.ts
|
|
1649
|
+
var DOMAINS = [
|
|
1650
|
+
"spatial",
|
|
1651
|
+
"visual",
|
|
1652
|
+
"activity",
|
|
1653
|
+
"deviceContext",
|
|
1654
|
+
"connectivity",
|
|
1655
|
+
"capability",
|
|
1656
|
+
"environment",
|
|
1657
|
+
"interaction"
|
|
1658
|
+
];
|
|
1659
|
+
function unknownSnapshot(domain) {
|
|
1660
|
+
const quality = { freshness: { state: "unknown", observedAt: null, ageMs: null }, confidence: { level: "unknown" } };
|
|
1661
|
+
switch (domain) {
|
|
1662
|
+
case "spatial":
|
|
1663
|
+
return { ...{ quality }, position: null, movement: { speedMps: null, bearingDeg: null, distanceRecentM: null, state: "unknown" }, orientation: { headingDeg: null }, recentPath: [] };
|
|
1664
|
+
case "visual":
|
|
1665
|
+
return { quality, camera: { available: false, active: false, facing: "unknown" }, latest: null, recentTimeline: { windowMs: 0, frames: [] } };
|
|
1666
|
+
case "activity":
|
|
1667
|
+
return { quality, human: { state: "unknown", since: null, confidence: { level: "unknown" } }, devicePose: { placement: "unknown", orientation: "unknown", confidence: { level: "unknown" } }, recentTransitions: [] };
|
|
1668
|
+
default:
|
|
1669
|
+
return { quality };
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
function refusal(domain, code, message) {
|
|
1673
|
+
const err = new Error(message || `deviceState.${domain}: refused during recording (${code}).`);
|
|
1674
|
+
err.code = code;
|
|
1675
|
+
err.domain = domain;
|
|
1676
|
+
return err;
|
|
1677
|
+
}
|
|
1678
|
+
function createReplayDeviceState(fixture) {
|
|
1679
|
+
const samples = [...fixture.samples || []].sort((a, b) => a.t - b.t);
|
|
1680
|
+
const duration = samples.length ? samples[samples.length - 1].t : 0;
|
|
1681
|
+
const refusals = new Map((fixture.refusals || []).map((r) => [r.domain, r]));
|
|
1682
|
+
const openSessions = /* @__PURE__ */ new Set();
|
|
1683
|
+
let cursor = 0;
|
|
1684
|
+
const spatialDetector = new SpatialEventDetector();
|
|
1685
|
+
const spatialListeners = listenerSet();
|
|
1686
|
+
const moveTo = (target) => {
|
|
1687
|
+
const next = Math.min(duration, Math.max(0, target));
|
|
1688
|
+
if (!openSessions.has("spatial")) return cursor = next;
|
|
1689
|
+
if (next < cursor) {
|
|
1690
|
+
cursor = next;
|
|
1691
|
+
spatialDetector.reset(void 0, turnOnRecordAt());
|
|
1692
|
+
return cursor;
|
|
1693
|
+
}
|
|
1694
|
+
const from = cursor;
|
|
1695
|
+
for (const s of samples) {
|
|
1696
|
+
if (s.t <= from || !s.spatial) continue;
|
|
1697
|
+
if (s.t > next) break;
|
|
1698
|
+
cursor = s.t;
|
|
1699
|
+
for (const event of spatialDetector.push(s.spatial, s.t)) spatialListeners.emit(event);
|
|
1700
|
+
}
|
|
1701
|
+
return cursor = next;
|
|
1702
|
+
};
|
|
1703
|
+
const clock = {
|
|
1704
|
+
now: () => cursor,
|
|
1705
|
+
advance: (ms) => moveTo(cursor + ms),
|
|
1706
|
+
seek: (ms) => moveTo(ms),
|
|
1707
|
+
end: () => moveTo(duration),
|
|
1708
|
+
durationMs: () => duration
|
|
1709
|
+
};
|
|
1710
|
+
const sampleAt = () => {
|
|
1711
|
+
let found = null;
|
|
1712
|
+
for (const s of samples) {
|
|
1713
|
+
if (s.t > cursor) break;
|
|
1714
|
+
found = s;
|
|
1715
|
+
}
|
|
1716
|
+
return found;
|
|
1717
|
+
};
|
|
1718
|
+
const turnOnRecordAt = () => sampleAt()?.spatial?.changes?.timestamp ?? null;
|
|
1719
|
+
const domainFor = (name) => ({
|
|
1720
|
+
async getSnapshot() {
|
|
1721
|
+
const s = sampleAt();
|
|
1722
|
+
const recorded = s ? s[name] : void 0;
|
|
1723
|
+
return recorded ?? unknownSnapshot(name);
|
|
1724
|
+
},
|
|
1725
|
+
async start(options) {
|
|
1726
|
+
const r = refusals.get(name);
|
|
1727
|
+
if (r) throw refusal(name, r.code, r.message);
|
|
1728
|
+
if (name === "spatial" && !openSessions.has(name)) {
|
|
1729
|
+
spatialDetector.reset(spatialEventProfile(options?.intent), turnOnRecordAt());
|
|
1730
|
+
}
|
|
1731
|
+
openSessions.add(name);
|
|
1732
|
+
return { id: `replay_${name}`, intent: "replay", startedAt: 0 };
|
|
1733
|
+
},
|
|
1734
|
+
async stop() {
|
|
1735
|
+
openSessions.delete(name);
|
|
1736
|
+
if (name === "spatial") spatialDetector.reset();
|
|
1737
|
+
},
|
|
1738
|
+
subscribe(listener) {
|
|
1739
|
+
if (name === "spatial") return spatialListeners.add(listener);
|
|
1740
|
+
throw refusal(name, "UNSUPPORTED", `deviceState.${name}.subscribe is not replayable \u2014 advance the clock and read.`);
|
|
1741
|
+
}
|
|
1742
|
+
});
|
|
1743
|
+
const deviceState = {};
|
|
1744
|
+
for (const d of DOMAINS) deviceState[d] = domainFor(d);
|
|
1745
|
+
return { deviceState, clock, started: () => [...openSessions] };
|
|
1746
|
+
}
|
|
1747
|
+
function parseWalkJsonl(text) {
|
|
1748
|
+
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1749
|
+
if (!lines.length) throw new Error("Empty recording \u2014 no header line.");
|
|
1750
|
+
const first = JSON.parse(lines[0]);
|
|
1751
|
+
const header = first.header ?? first;
|
|
1752
|
+
const refusals = first.refusals ?? [];
|
|
1753
|
+
const samples = [];
|
|
1754
|
+
for (const line of lines.slice(1)) {
|
|
1755
|
+
const rec = JSON.parse(line);
|
|
1756
|
+
if (rec.refusal) {
|
|
1757
|
+
refusals.push(rec.refusal);
|
|
1758
|
+
continue;
|
|
1759
|
+
}
|
|
1760
|
+
if (typeof rec.t === "number") samples.push(rec);
|
|
1761
|
+
}
|
|
1762
|
+
return { header, refusals, samples };
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// src/index.ts
|
|
1766
|
+
function setupExpoProviders(opts) {
|
|
1767
|
+
setHapticsProvider(expoHaptics);
|
|
1768
|
+
if (opts?.deviceProvider) setPluginNativeProvider(opts.deviceProvider);
|
|
1769
|
+
setBundleStorage(new ExpoBundleStorage({ indexKey: opts?.bundleIndexKey }));
|
|
1770
|
+
setClipboardProvider({
|
|
1771
|
+
setString: (text) => ExpoClipboard.setStringAsync(text).then(() => {
|
|
1772
|
+
})
|
|
1773
|
+
});
|
|
1774
|
+
registerBundleModules({
|
|
1775
|
+
"expo-web-browser": ExpoWebBrowser,
|
|
1776
|
+
"expo-linking": ExpoLinking,
|
|
1777
|
+
...opts?.bundleModules || {}
|
|
1778
|
+
});
|
|
1779
|
+
setAcsCacheStorage({
|
|
1780
|
+
getItem: (k) => AsyncStorage3.getItem(k),
|
|
1781
|
+
setItem: (k, v) => AsyncStorage3.setItem(k, v)
|
|
1782
|
+
});
|
|
1783
|
+
return { attachmentCache: new ExpoAttachmentCache({ indexKey: opts?.attachmentIndexKey }) };
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
export {
|
|
1787
|
+
expoHaptics,
|
|
1788
|
+
ExpoBundleStorage,
|
|
1789
|
+
ExpoAttachmentCache,
|
|
1790
|
+
visualEngine,
|
|
1791
|
+
createDeviceState,
|
|
1792
|
+
IMPLEMENTED_DOMAINS,
|
|
1793
|
+
CameraTaskHost,
|
|
1794
|
+
createReplayDeviceState,
|
|
1795
|
+
parseWalkJsonl,
|
|
1796
|
+
setupExpoProviders
|
|
1797
|
+
};
|
|
1798
|
+
//# sourceMappingURL=chunk-C7M5WAHA.mjs.map
|