@ateam-ai-mobile/expo 0.1.4 → 0.1.7

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.
Files changed (158) hide show
  1. package/android/build.gradle +16 -0
  2. package/android/src/main/java/expo/modules/adavision/AdaVisionModule.kt +75 -0
  3. package/dist/attachmentCache-DhQTqi2E.d.mts +18 -0
  4. package/dist/attachmentCache-DhQTqi2E.d.ts +18 -0
  5. package/dist/bootstrap.d.mts +95 -0
  6. package/dist/bootstrap.d.ts +95 -0
  7. package/dist/bootstrap.js +2473 -0
  8. package/dist/bootstrap.js.map +1 -0
  9. package/dist/bootstrap.mjs +64 -0
  10. package/dist/bootstrap.mjs.map +1 -0
  11. package/dist/chunk-4BMWVN25.mjs +91 -0
  12. package/dist/chunk-4BMWVN25.mjs.map +1 -0
  13. package/dist/chunk-4XYXJYN5.mjs +1513 -0
  14. package/dist/chunk-4XYXJYN5.mjs.map +1 -0
  15. package/dist/chunk-5HM6UA2K.mjs +571 -0
  16. package/dist/chunk-5HM6UA2K.mjs.map +1 -0
  17. package/dist/chunk-6LXF3JVX.mjs +96 -0
  18. package/dist/chunk-6LXF3JVX.mjs.map +1 -0
  19. package/dist/chunk-FF6ZZZXH.mjs +71 -0
  20. package/dist/chunk-FF6ZZZXH.mjs.map +1 -0
  21. package/dist/chunk-KOXF2FZM.mjs +92 -0
  22. package/dist/chunk-KOXF2FZM.mjs.map +1 -0
  23. package/dist/chunk-L4V7OCJ3.mjs +71 -0
  24. package/dist/chunk-L4V7OCJ3.mjs.map +1 -0
  25. package/dist/chunk-TZXWVYYR.mjs +67 -0
  26. package/dist/chunk-TZXWVYYR.mjs.map +1 -0
  27. package/dist/chunk-VQJPSLHP.mjs +1801 -0
  28. package/dist/chunk-VQJPSLHP.mjs.map +1 -0
  29. package/dist/chunk-WMJKH4XE.mjs +34 -0
  30. package/dist/chunk-WMJKH4XE.mjs.map +1 -0
  31. package/dist/chunk-XQSY6MHP.mjs +213 -0
  32. package/dist/chunk-XQSY6MHP.mjs.map +1 -0
  33. package/dist/createBridge.mjs +2 -0
  34. package/dist/createBridge.mjs.map +1 -1
  35. package/dist/device/createDeviceProvider.d.mts +23 -0
  36. package/dist/device/createDeviceProvider.d.ts +23 -0
  37. package/dist/device/createDeviceProvider.js +967 -0
  38. package/dist/device/createDeviceProvider.js.map +1 -0
  39. package/dist/device/createDeviceProvider.mjs +17 -0
  40. package/dist/device/createDeviceProvider.mjs.map +1 -0
  41. package/dist/device-bridge/collectors/appEngagement.d.mts +10 -0
  42. package/dist/device-bridge/collectors/appEngagement.d.ts +10 -0
  43. package/dist/device-bridge/collectors/appEngagement.js +128 -0
  44. package/dist/device-bridge/collectors/appEngagement.js.map +1 -0
  45. package/dist/device-bridge/collectors/appEngagement.mjs +12 -0
  46. package/dist/device-bridge/collectors/appEngagement.mjs.map +1 -0
  47. package/dist/device-bridge/collectors/health.d.mts +24 -0
  48. package/dist/device-bridge/collectors/health.d.ts +24 -0
  49. package/dist/device-bridge/collectors/health.js +238 -0
  50. package/dist/device-bridge/collectors/health.js.map +1 -0
  51. package/dist/device-bridge/collectors/health.mjs +18 -0
  52. package/dist/device-bridge/collectors/health.mjs.map +1 -0
  53. package/dist/device-bridge/cookies.d.mts +49 -0
  54. package/dist/device-bridge/cookies.d.ts +49 -0
  55. package/dist/device-bridge/cookies.js +90 -0
  56. package/dist/device-bridge/cookies.js.map +1 -0
  57. package/dist/device-bridge/cookies.mjs +14 -0
  58. package/dist/device-bridge/cookies.mjs.map +1 -0
  59. package/dist/device-bridge/index.d.mts +483 -0
  60. package/dist/device-bridge/index.d.ts +483 -0
  61. package/dist/device-bridge/index.js +2084 -0
  62. package/dist/device-bridge/index.js.map +1 -0
  63. package/dist/device-bridge/index.mjs +32 -0
  64. package/dist/device-bridge/index.mjs.map +1 -0
  65. package/dist/device-bridge/nfc.d.mts +30 -0
  66. package/dist/device-bridge/nfc.d.ts +30 -0
  67. package/dist/device-bridge/nfc.js +109 -0
  68. package/dist/device-bridge/nfc.js.map +1 -0
  69. package/dist/device-bridge/nfc.mjs +14 -0
  70. package/dist/device-bridge/nfc.mjs.map +1 -0
  71. package/dist/index.d.mts +493 -18
  72. package/dist/index.d.ts +493 -18
  73. package/dist/index.js +1409 -6
  74. package/dist/index.js.map +1 -1
  75. package/dist/index.mjs +23 -194
  76. package/dist/index.mjs.map +1 -1
  77. package/dist/oauth/expoOAuth.d.mts +18 -0
  78. package/dist/oauth/expoOAuth.d.ts +18 -0
  79. package/dist/oauth/expoOAuth.js +102 -0
  80. package/dist/oauth/expoOAuth.js.map +1 -0
  81. package/dist/oauth/expoOAuth.mjs +8 -0
  82. package/dist/oauth/expoOAuth.mjs.map +1 -0
  83. package/dist/types-BRanJYTn.d.mts +138 -0
  84. package/dist/types-BRanJYTn.d.ts +138 -0
  85. package/dist/vision/index.d.mts +81 -0
  86. package/dist/vision/index.d.ts +81 -0
  87. package/dist/vision/index.js +101 -0
  88. package/dist/vision/index.js.map +1 -0
  89. package/dist/vision/index.mjs +28 -0
  90. package/dist/vision/index.mjs.map +1 -0
  91. package/expo-module.config.json +9 -0
  92. package/ios/AdaVision.podspec +28 -0
  93. package/ios/AdaVisionModule.swift +432 -0
  94. package/ios/Resources/yolov8n-oiv7.mlmodelc/analytics/coremldata.bin +0 -0
  95. package/ios/Resources/yolov8n-oiv7.mlmodelc/coremldata.bin +0 -0
  96. package/ios/Resources/yolov8n-oiv7.mlmodelc/metadata.json +773 -0
  97. package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/analytics/coremldata.bin +0 -0
  98. package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/coremldata.bin +0 -0
  99. package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/model.mil +720 -0
  100. package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/weights/weight.bin +0 -0
  101. package/ios/Resources/yolov8n-oiv7.mlmodelc/model1/coremldata.bin +0 -0
  102. package/package.json +107 -7
  103. package/src/bootstrap.ts +159 -0
  104. package/src/camera/CameraTaskHost.tsx +175 -0
  105. package/src/camera/cameraTasks.ts +129 -0
  106. package/src/device/createDeviceProvider.ts +832 -0
  107. package/src/device-bridge/actions/calendar-actions.ts +217 -0
  108. package/src/device-bridge/actions/dnd.ts +74 -0
  109. package/src/device-bridge/actions/health-actions.ts +58 -0
  110. package/src/device-bridge/actions/index.ts +12 -0
  111. package/src/device-bridge/actions/messaging.ts +74 -0
  112. package/src/device-bridge/actions/navigation.ts +88 -0
  113. package/src/device-bridge/actions/queue-poller.ts +151 -0
  114. package/src/device-bridge/background.ts +137 -0
  115. package/src/device-bridge/biometrics.ts +64 -0
  116. package/src/device-bridge/capabilities.ts +218 -0
  117. package/src/device-bridge/collectors/appEngagement.ts +120 -0
  118. package/src/device-bridge/collectors/battery.ts +83 -0
  119. package/src/device-bridge/collectors/calendar.ts +162 -0
  120. package/src/device-bridge/collectors/camera.ts +27 -0
  121. package/src/device-bridge/collectors/connectivity.ts +60 -0
  122. package/src/device-bridge/collectors/contacts.ts +200 -0
  123. package/src/device-bridge/collectors/health.ts +299 -0
  124. package/src/device-bridge/collectors/index.ts +12 -0
  125. package/src/device-bridge/collectors/location.ts +224 -0
  126. package/src/device-bridge/collectors/mood.ts +141 -0
  127. package/src/device-bridge/collectors/notifications.ts +70 -0
  128. package/src/device-bridge/cookies.ts +89 -0
  129. package/src/device-bridge/dynamic-connector.ts +422 -0
  130. package/src/device-bridge/index.ts +467 -0
  131. package/src/device-bridge/nfc.ts +85 -0
  132. package/src/device-bridge/plugin.ts +71 -0
  133. package/src/device-bridge/relay-client.ts +237 -0
  134. package/src/device-bridge/scanner.ts +42 -0
  135. package/src/device-bridge/sharing.ts +35 -0
  136. package/src/device-bridge/types.ts +326 -0
  137. package/src/device-state/activityClassifier.ts +151 -0
  138. package/src/device-state/activityEngine.ts +252 -0
  139. package/src/device-state/collectors.ts +31 -0
  140. package/src/device-state/domains/activity.ts +93 -0
  141. package/src/device-state/domains/capability.ts +60 -0
  142. package/src/device-state/domains/connectivity.ts +60 -0
  143. package/src/device-state/domains/deviceContext.ts +55 -0
  144. package/src/device-state/domains/interaction.ts +59 -0
  145. package/src/device-state/domains/spatial.ts +283 -0
  146. package/src/device-state/domains/visual.ts +100 -0
  147. package/src/device-state/headingSource.ts +84 -0
  148. package/src/device-state/index.ts +77 -0
  149. package/src/device-state/quality.ts +60 -0
  150. package/src/device-state/replay.ts +220 -0
  151. package/src/device-state/turnDetector.ts +121 -0
  152. package/src/device-state/visualEngine.ts +341 -0
  153. package/src/haptics.ts +4 -0
  154. package/src/index.ts +37 -0
  155. package/src/oauth/expoOAuth.tsx +113 -0
  156. package/src/vision/analysis.js +80 -0
  157. package/src/vision/index.ts +144 -0
  158. package/src/vision/ring.js +35 -0
@@ -0,0 +1,55 @@
1
+ // ── deviceContext ──────────────────────────────────────────────────────────
2
+ // power ✓ thermal ✗ (needs a native module) memory/storage ✗
3
+ import * as ExpoBattery from 'expo-battery';
4
+ import type { DeviceState, DeviceContextSnapshot } from '@ateam-ai-mobile/plugin-runtime';
5
+ import { qualityFrom, qualityUnknown } from '../quality';
6
+
7
+ export function createDeviceContextDomain(): DeviceState['deviceContext'] {
8
+ return {
9
+ async getSnapshot(): Promise<DeviceContextSnapshot> {
10
+ const observedAt = Date.now();
11
+ try {
12
+ const [level, state, lowPower] = await Promise.all([
13
+ ExpoBattery.getBatteryLevelAsync(),
14
+ ExpoBattery.getBatteryStateAsync(),
15
+ ExpoBattery.isLowPowerModeEnabledAsync(),
16
+ ]);
17
+ return {
18
+ power: {
19
+ // expo-battery returns -1 when it cannot tell. That is not a level.
20
+ batteryLevel: typeof level === 'number' && level >= 0 ? level : null,
21
+ charging:
22
+ state === ExpoBattery.BatteryState.CHARGING ||
23
+ state === ExpoBattery.BatteryState.FULL,
24
+ lowPowerMode: lowPower,
25
+ },
26
+ // No Expo API for either. iOS needs ProcessInfo.thermalState, Android
27
+ // PowerManager.getThermalStatus — a native module that does not exist.
28
+ thermal: { state: 'unknown' },
29
+ resources: { memoryPressure: 'unknown', storagePressure: 'unknown' },
30
+ quality: qualityFrom(observedAt),
31
+ };
32
+ } catch {
33
+ // The battery read failing is rare and uninteresting; reporting it as
34
+ // 0% would not be.
35
+ return {
36
+ power: { batteryLevel: null, charging: null, lowPowerMode: null },
37
+ thermal: { state: 'unknown' },
38
+ resources: { memoryPressure: 'unknown', storagePressure: 'unknown' },
39
+ quality: qualityUnknown(),
40
+ };
41
+ }
42
+ },
43
+
44
+ // Real events, not a placeholder: expo-battery exposes native listeners,
45
+ // so a plugin that subscribes genuinely hears about a change rather than
46
+ // sitting on a subscription that can never fire.
47
+ subscribe(listener) {
48
+ const subs = [
49
+ ExpoBattery.addBatteryLevelListener(() => listener({ type: 'batteryLevelChanged' } as any)),
50
+ ExpoBattery.addBatteryStateListener(() => listener({ type: 'chargingChanged' } as any)),
51
+ ];
52
+ return { unsubscribe: () => subs.forEach(x => x?.remove?.()) };
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,59 @@
1
+ // ── interaction ──────────────────────────────────────────────────────────
2
+ // app lifecycle ✓ screenOn ✗ audio ✗
3
+ import { AppState } from 'react-native';
4
+ import type { DeviceState, InteractionSnapshot } from '@ateam-ai-mobile/plugin-runtime';
5
+ import { qualityFrom } from '../quality';
6
+
7
+ /**
8
+ * Lifecycle only, and honest about the rest.
9
+ *
10
+ * `screenOn` is NOT derivable from AppState: RN reports `inactive` for a
11
+ * notification shade, a control centre swipe, an incoming call AND a locked
12
+ * screen. Mapping `inactive` to "screen off" would be wrong most of the time
13
+ * it fired — so it stays null until a native module can answer it.
14
+ *
15
+ * The whole `audio` block reports unknown for the same reason. `interruption`
16
+ * carries the distinction that matters: `none` means "checked, nothing is
17
+ * interrupting", `unknown` means "no knowledge" — and we have the latter.
18
+ */
19
+ export function createInteractionDomain(): DeviceState['interaction'] {
20
+ const mapState = (s: string): InteractionSnapshot['lifecycle']['appState'] =>
21
+ s === 'active' ? 'foreground'
22
+ : s === 'background' ? 'background'
23
+ : s === 'inactive' ? 'inactive'
24
+ : 'unknown';
25
+
26
+ return {
27
+ async getSnapshot(): Promise<InteractionSnapshot> {
28
+ const observedAt = Date.now();
29
+ return {
30
+ lifecycle: {
31
+ appState: mapState(AppState.currentState as string),
32
+ screenOn: null,
33
+ userActive: null,
34
+ idleDurationMs: null,
35
+ },
36
+ audio: {
37
+ captureActive: null,
38
+ playbackActive: null,
39
+ route: 'unknown',
40
+ interruption: 'unknown',
41
+ },
42
+ // The voice block is OMITTED rather than filled with nulls: the design
43
+ // says a platform that cannot report voice session state leaves the
44
+ // group out instead of guessing `false` for each field. The app's own
45
+ // voice store knows this, but it is not reachable from the SDK.
46
+ quality: qualityFrom(observedAt),
47
+ };
48
+ },
49
+
50
+ // Real: AppState genuinely emits on every foreground/background change,
51
+ // which is the one part of this domain that works.
52
+ subscribe(listener) {
53
+ const sub = AppState.addEventListener('change', next => {
54
+ listener({ type: 'appStateChanged', appState: mapState(next as string) } as any);
55
+ });
56
+ return { unsubscribe: () => sub.remove() };
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,283 @@
1
+ // ── spatial ──────────────────────────────────────────────────────────────
2
+ // position ✓ path ✓ (while a session is open) heading ✓ speed ~derived
3
+ import type { DeviceState, SpatialSnapshot, SpatialPoint, StateQuality } from '@ateam-ai-mobile/plugin-runtime';
4
+ import { visualEngine } from '../visualEngine';
5
+ import { startHeading, stopHeading, currentHeadingDetail, lastMeaningfulTurn } from '../headingSource';
6
+ import type { DeviceStateCollectors, SpeedRef } from '../collectors';
7
+ import { qualityFrom, qualityUnknown, unsupported } from '../quality';
8
+
9
+ /**
10
+ * Position, movement, facing and a short recent path.
11
+ *
12
+ * The design's performance rule is that navigation reads come from a WARM
13
+ * native cache, not a fresh GPS+compass call per invocation. There is no such
14
+ * cache today, so `getSnapshot` takes a fix — correct but not cheap. That is
15
+ * why `start()` matters: it opens a `watchPosition` stream AND a compass watch,
16
+ * and every later snapshot serves the newest streamed values instead, which is
17
+ * the warm path this layer can offer without a native module.
18
+ *
19
+ * TWO DIRECTIONS, NOT ONE, and conflating them is the bug this replaced.
20
+ *
21
+ * `orientation.headingDeg` — where the phone is POINTING. The compass.
22
+ * `movement.bearingDeg` — where the user is GOING. Course over ground.
23
+ *
24
+ * They disagree constantly and each answers a different sentence. "Turn right
25
+ * at the corner" is decided by the compass; "you are heading north on Main
26
+ * Street" by the course. The previous version assigned the same variable to
27
+ * both, which is only right by accident, and that variable was permanently
28
+ * null because the streaming collector dropped the fields.
29
+ *
30
+ * TURNS ARE SIGNED. `changes.lastMeaningfulTurnDeg` is positive for clockwise
31
+ * (right) and negative for anticlockwise (left), so a guide can say which way
32
+ * the user just went without a second field to consult. The freeze names the
33
+ * field but states no convention; this is a decision, recorded here.
34
+ */
35
+ export function createSpatialDomain(
36
+ collectors: DeviceStateCollectors,
37
+ speedRef: SpeedRef,
38
+ ): DeviceState['spatial'] {
39
+ // The warm cache, when a session is open. Newest last, bounded — an unbounded
40
+ // path on a walking user grows without limit.
41
+ const RECENT_PATH_MAX = 60;
42
+ /**
43
+ * Below this the user is standing still.
44
+ *
45
+ * 0.4 m/s is well under a slow walk (~1.2 m/s) and above the jitter a
46
+ * stationary phone's GPS produces. The point of the band is that a guide
47
+ * must not say "keep going straight" to someone who has stopped.
48
+ */
49
+ const STATIONARY_MPS = 0.4;
50
+
51
+ let recentPath: SpatialPoint[] = [];
52
+ let latest: SpatialPoint | null = null;
53
+ /** Speed and course from the newest STREAMED fix — see `latest`. */
54
+ let latestSpeed: number | null = null;
55
+ let latestCourse: number | null = null;
56
+ let stopWatch: (() => void) | null = null;
57
+ let sessionId: string | null = null;
58
+
59
+ const nonNegative = (v: unknown): number | null =>
60
+ typeof v === 'number' && v >= 0 ? v : null;
61
+
62
+ /**
63
+ * Speed from the path when the OS declined to estimate one.
64
+ *
65
+ * Only over the last two points and only if they are close in time: two fixes
66
+ * a minute apart give an average that describes neither moment. Returns null
67
+ * rather than a number the caller cannot tell apart from a measured one —
68
+ * the confidence on the snapshot is what marks it as derived.
69
+ */
70
+ const derivedSpeed = (): number | null => {
71
+ if (recentPath.length < 2) return null;
72
+ const b = recentPath[recentPath.length - 1];
73
+ const a = recentPath[recentPath.length - 2];
74
+ const dtS = (b.timestamp - a.timestamp) / 1000;
75
+ if (dtS <= 0 || dtS > 10) return null;
76
+ return distanceOf([a, b]) / dtS;
77
+ };
78
+
79
+ /** Course from the path, for the same reason and with the same caution. */
80
+ const derivedCourse = (): number | null => {
81
+ if (recentPath.length < 2) return null;
82
+ const b = recentPath[recentPath.length - 1];
83
+ const a = recentPath[recentPath.length - 2];
84
+ if (b.timestamp - a.timestamp > 10_000) return null;
85
+ // Too small a step is noise, not a direction.
86
+ if (distanceOf([a, b]) < 3) return null;
87
+ return bearingBetween(a, b);
88
+ };
89
+
90
+ return {
91
+ async getSnapshot(): Promise<SpatialSnapshot> {
92
+ // Serve the warm fix when a session is streaming; otherwise take one.
93
+ let point = latest;
94
+ let speedMps = latestSpeed;
95
+ let courseDeg = latestCourse;
96
+ let derived = false;
97
+
98
+ if (!point) {
99
+ try {
100
+ if (!collectors.location) throw unsupported('location collector');
101
+ const fix: any = await collectors.location.getPosition({ accuracy: 'low' });
102
+ point = {
103
+ latitude: fix.latitude,
104
+ longitude: fix.longitude,
105
+ altitude: fix.altitude ?? null,
106
+ accuracyM: nonNegative(fix.accuracy),
107
+ timestamp: fix.timestamp ?? Date.now(),
108
+ };
109
+ speedMps = nonNegative(fix.speed);
110
+ courseDeg = nonNegative(fix.course);
111
+ } catch {
112
+ // Permission refused, or no fix. Not a position at (0,0).
113
+ return {
114
+ position: null,
115
+ movement: { speedMps: null, bearingDeg: null, distanceRecentM: null, state: 'unknown' },
116
+ orientation: { headingDeg: null },
117
+ recentPath: [],
118
+ quality: qualityUnknown(),
119
+ };
120
+ }
121
+ }
122
+
123
+ // Fall back to the path only where the OS said nothing.
124
+ if (speedMps === null) { const d = derivedSpeed(); if (d !== null) { speedMps = d; derived = true; } }
125
+ if (courseDeg === null) { const d = derivedCourse(); if (d !== null) { courseDeg = d; derived = true; } }
126
+
127
+ const heading = currentHeadingDetail();
128
+ const turn = lastMeaningfulTurn();
129
+
130
+ return {
131
+ position: point,
132
+ movement: {
133
+ speedMps,
134
+ bearingDeg: courseDeg,
135
+ // Needs two points from the SAME session; without a stream there is
136
+ // nothing to measure against, and a distance of 0 would read as
137
+ // "the user has not moved".
138
+ distanceRecentM: recentPath.length >= 2 ? distanceOf(recentPath) : null,
139
+ // Only claim stationary/moving when a speed was actually obtained.
140
+ // With no speed the truthful answer is `unknown`, not `stationary` —
141
+ // "you have stopped" is a false statement, not a neutral default.
142
+ state: speedMps === null ? 'unknown' : speedMps < STATIONARY_MPS ? 'stationary' : 'moving',
143
+ },
144
+ orientation: {
145
+ headingDeg: heading?.headingDeg ?? null,
146
+ // NOT the calibration rank. iOS reports 0–3 (none…high), which is not
147
+ // a number of degrees, and converting one to the other would be an
148
+ // invented error bar. The rank drives `confidence` below instead.
149
+ headingAccuracyDeg: null,
150
+ },
151
+ changes: {
152
+ lastMeaningfulTurnDeg: turn ? turn.deg : null,
153
+ timestamp: turn ? turn.timestamp : null,
154
+ },
155
+ recentPath: [...recentPath],
156
+ quality: qualityFrom(
157
+ point.timestamp,
158
+ spatialConfidence(point.accuracyM ?? null, heading?.calibration ?? null, derived),
159
+ ),
160
+ };
161
+ },
162
+
163
+ /**
164
+ * Open the warm window: position stream + compass.
165
+ *
166
+ * The intent is recorded but does NOT yet change sampling — no native
167
+ * strategy layer exists — so it is accepted rather than refused only
168
+ * because the streams it opens are real and useful.
169
+ */
170
+ async start(options) {
171
+ if (stopWatch) return { id: sessionId!, intent: options.intent, startedAt: Date.now() } as any;
172
+ sessionId = `spatial_${Date.now()}`;
173
+ if (!collectors.location) throw unsupported('location collector');
174
+ // The compass is what makes heading and turn detection possible, and it
175
+ // has to run for the session's whole life: a turn happens between reads,
176
+ // so a compass started on demand would miss exactly the event a guide
177
+ // most needs. Idempotent, and shared with a visual session if one is open.
178
+ void startHeading();
179
+ stopWatch = collectors.location.watchPosition((pos: any) => {
180
+ const p: SpatialPoint = {
181
+ latitude: pos.latitude,
182
+ longitude: pos.longitude,
183
+ altitude: pos.altitude ?? null,
184
+ accuracyM: nonNegative(pos.accuracy),
185
+ timestamp: pos.timestamp ?? Date.now(),
186
+ };
187
+ latest = p;
188
+ latestSpeed = nonNegative(pos.speed);
189
+ latestCourse = nonNegative(pos.course);
190
+ speedRef.mps = latestSpeed;
191
+ speedRef.at = p.timestamp;
192
+ recentPath = [...recentPath, p].slice(-RECENT_PATH_MAX);
193
+ });
194
+ return { id: sessionId, intent: options.intent, startedAt: Date.now() } as any;
195
+ },
196
+
197
+ async stop() {
198
+ stopWatch?.();
199
+ stopWatch = null;
200
+ sessionId = null;
201
+ // The path belongs to the session that recorded it. Keeping it would let
202
+ // a later snapshot report movement from a walk that ended hours ago.
203
+ recentPath = [];
204
+ latest = null;
205
+ latestSpeed = null;
206
+ latestCourse = null;
207
+ speedRef.mps = null;
208
+ speedRef.at = 0;
209
+ // Leave the compass alone if a visual session is still using it — it is
210
+ // shared, and stopping it here would silently blind the frame stamps.
211
+ if (!visualEngine.getSession()) stopHeading();
212
+ },
213
+
214
+ // No event vocabulary is frozen for spatial, and inventing names here would
215
+ // put them in plugins before the freeze can contradict them.
216
+ subscribe() { throw unsupported('spatial.subscribe'); },
217
+ };
218
+ }
219
+
220
+ /**
221
+ * How much to trust this spatial reading.
222
+ *
223
+ * Three things degrade it and they are not interchangeable: a loose GPS fix
224
+ * (the position is vague), an uncalibrated compass (the FACING is vague, which
225
+ * is what breaks "turn left"), and values derived from the path rather than
226
+ * measured by the OS. The lowest of the three wins, because a caller acting on
227
+ * the snapshot is exposed to the worst part of it, not the average.
228
+ *
229
+ * This is the signal that decides Ada Guide's modality switch: indoors the fix
230
+ * degrades, confidence drops, and that drop is what makes the guide ask for the
231
+ * camera instead of guessing from a GPS fix it should not trust.
232
+ */
233
+ function spatialConfidence(
234
+ accuracyM: number | null,
235
+ calibration: number | null,
236
+ derived: boolean,
237
+ ): StateQuality['confidence'] {
238
+ const rank = (v: 'high' | 'medium' | 'low' | 'unknown') =>
239
+ v === 'high' ? 3 : v === 'medium' ? 2 : v === 'low' ? 1 : 0;
240
+
241
+ // Metres of horizontal error. ~10m is a good open-sky fix; beyond ~50m the
242
+ // position is street-level at best and useless for "turn at the next corner".
243
+ const fromFix: 'high' | 'medium' | 'low' | 'unknown' =
244
+ accuracyM === null ? 'unknown' : accuracyM <= 10 ? 'high' : accuracyM <= 50 ? 'medium' : 'low';
245
+
246
+ // iOS calibration rank: 3 high, 2 medium, 1 low, 0 none.
247
+ const fromCompass: 'high' | 'medium' | 'low' | 'unknown' =
248
+ calibration === null ? 'unknown'
249
+ : calibration >= 3 ? 'high'
250
+ : calibration >= 2 ? 'medium'
251
+ : 'low';
252
+
253
+ const parts = [fromFix, fromCompass, ...(derived ? (['low'] as const) : [])];
254
+ const known = parts.filter(p => p !== 'unknown');
255
+ if (!known.length) return { level: 'unknown' };
256
+ const worst = known.reduce((a, b) => (rank(a) <= rank(b) ? a : b));
257
+ return { level: worst };
258
+ }
259
+
260
+ /** Initial bearing from `a` to `b`, degrees clockwise from north. */
261
+ function bearingBetween(a: SpatialPoint, b: SpatialPoint): number {
262
+ const toRad = (d: number) => (d * Math.PI) / 180;
263
+ const la1 = toRad(a.latitude), la2 = toRad(b.latitude);
264
+ const dLon = toRad(b.longitude - a.longitude);
265
+ const y = Math.sin(dLon) * Math.cos(la2);
266
+ const x = Math.cos(la1) * Math.sin(la2) - Math.sin(la1) * Math.cos(la2) * Math.cos(dLon);
267
+ return (((Math.atan2(y, x) * 180) / Math.PI) + 360) % 360;
268
+ }
269
+
270
+ /** Metres along a path, great-circle per leg. */
271
+ function distanceOf(path: SpatialPoint[]): number {
272
+ const R = 6_371_000;
273
+ let total = 0;
274
+ for (let i = 1; i < path.length; i++) {
275
+ const a = path[i - 1], b = path[i];
276
+ const dLat = ((b.latitude - a.latitude) * Math.PI) / 180;
277
+ const dLon = ((b.longitude - a.longitude) * Math.PI) / 180;
278
+ const la1 = (a.latitude * Math.PI) / 180, la2 = (b.latitude * Math.PI) / 180;
279
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
280
+ total += 2 * R * Math.asin(Math.sqrt(h));
281
+ }
282
+ return Math.round(total);
283
+ }
@@ -0,0 +1,100 @@
1
+ // ── visual ───────────────────────────────────────────────────────────────
2
+ // frames ✓ heading ✓ freshness ✓ blur/exposure ✗
3
+ //
4
+ // Visual owns the semantic frame timeline/retention: the rolling ring, opaque
5
+ // frame ids, and the freshness the frames carry (frameQuality) — see
6
+ // visualEngine.ts. Opaque ids only above L1; pixels never cross to JS.
7
+ import type { DeviceState, VisualSnapshot } from '@ateam-ai-mobile/plugin-runtime';
8
+ import { visualEngine, frameQuality } from '../visualEngine';
9
+ import { currentHeading, startHeading, stopHeading } from '../headingSource';
10
+ import { unsupported } from '../quality';
11
+
12
+ /**
13
+ * Reads the rolling timeline the engine maintains. See visualEngine.ts for why
14
+ * the capture half lives in a mounted component.
15
+ *
16
+ * `getSnapshot` NEVER opens the camera and never takes a picture — that is the
17
+ * design's explicit performance rule, and the reason a guidance loop can ask
18
+ * every turn without paying for it. What it costs is one array read.
19
+ */
20
+ export function createVisualDomain(): DeviceState['visual'] {
21
+ return {
22
+ async getSnapshot(): Promise<VisualSnapshot> {
23
+ const session = visualEngine.getSession();
24
+ const latest = visualEngine.latest();
25
+ const timeline = visualEngine.timeline();
26
+
27
+ return {
28
+ camera: {
29
+ // `available` = the app could use a camera at all. `active` = one is
30
+ // running RIGHT NOW. They differ exactly when it matters: a pocketed
31
+ // phone is available but not active, and answering `true` to both
32
+ // would let a guide speak about a scene nothing is watching.
33
+ available: !!session,
34
+ active: visualEngine.isCameraReady(),
35
+ facing: session ? 'back' : 'unknown',
36
+ },
37
+ // null, not a stale frame dressed as current. A caller with no usable
38
+ // frame must see nothing rather than something old.
39
+ latest: latest as any,
40
+ recentTimeline: timeline as any,
41
+ quality: frameQuality(latest),
42
+ };
43
+ },
44
+
45
+ async start(options) {
46
+ const s = visualEngine.startSession((options?.intent as any) || 'navigation');
47
+ // Warm the compass alongside the camera. Started here rather than at app
48
+ // launch: a magnetometer subscription running all day for a feature
49
+ // nobody opened is exactly the kind of cost this layer is meant to avoid.
50
+ visualEngine.setContextProvider({ heading: currentHeading });
51
+ void startHeading();
52
+ return { id: s.id, intent: s.intent, startedAt: s.startedAt } as any;
53
+ },
54
+
55
+ async stop() {
56
+ visualEngine.stopSession();
57
+ visualEngine.setContextProvider(null);
58
+ stopHeading();
59
+ },
60
+
61
+ /**
62
+ * A deliberate one-shot. Distinct from getSnapshot because it is an
63
+ * OPERATION: it waits for a frame rather than serving the newest one.
64
+ *
65
+ * Throws when it cannot, rather than returning an empty result — the
66
+ * design is explicit that capture is an operation and reads are state.
67
+ */
68
+ async capture() {
69
+ if (!visualEngine.getSession()) {
70
+ throw unsupported('visual.capture without an open session — call start({ intent }) first');
71
+ }
72
+ if (!visualEngine.isCameraReady()) {
73
+ const err: any = new Error(
74
+ 'The camera is not running right now — the app may be in the background.',
75
+ );
76
+ err.code = 'TEMPORARILY_UNAVAILABLE';
77
+ throw err;
78
+ }
79
+ // Wait briefly for the NEXT frame rather than returning the last one:
80
+ // "capture" means now, and the newest ring entry may be a second old.
81
+ const before = visualEngine.latest()?.timestamp ?? 0;
82
+ const deadline = Date.now() + 4000;
83
+ while (Date.now() < deadline) {
84
+ const f = visualEngine.latest();
85
+ if (f && f.timestamp > before) return { frame: f } as any;
86
+ await new Promise(r => setTimeout(r, 100));
87
+ }
88
+ const err: any = new Error('No frame arrived within 4s.');
89
+ err.code = 'TIMEOUT';
90
+ throw err;
91
+ },
92
+
93
+ subscribe(listener) {
94
+ const off = visualEngine.subscribe(() => {
95
+ listener({ type: 'visualStateChanged' } as any);
96
+ });
97
+ return { unsubscribe: off };
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The compass, kept warm — and the turn detector fed from it.
3
+ *
4
+ * `getHeadingAsync()` is not usable on the capture path: it starts a watch,
5
+ * waits for a reading and stops it, which costs hundreds of milliseconds — per
6
+ * frame. So this holds ONE subscription while a session is open and callers
7
+ * read the last value synchronously.
8
+ *
9
+ * Returns null until a reading arrives, and null means NOT MEASURED. A heading
10
+ * of 0 is due north, so an unstarted compass must never report 0.
11
+ *
12
+ * WHY THE TURN DETECTOR IS FED HERE. A turn happens BETWEEN reads — it is an
13
+ * event, not a state — so a detector driven by sampled snapshots would miss
14
+ * exactly the moment a guide needs. It runs on the compass callback instead.
15
+ * The logic itself lives in `turnDetector.ts`, free of any Expo import, so it
16
+ * can be tested against a scripted walk.
17
+ */
18
+ import * as ExpoLocation from 'expo-location';
19
+
20
+ import { TurnDetector, type TurnEvent } from './turnDetector';
21
+
22
+ export type { TurnEvent };
23
+ export { signedDelta } from './turnDetector';
24
+
25
+ interface HeadingReading {
26
+ headingDeg: number | null;
27
+ /**
28
+ * iOS calibration rank: 3 high, 2 medium, 1 low, 0 none. NOT degrees of
29
+ * error, which is why it is never surfaced as `headingAccuracyDeg` — that
30
+ * field wants degrees, and converting a rank into one would be a fabricated
31
+ * error bar. It drives CONFIDENCE instead, which is what it can honestly say.
32
+ */
33
+ calibration: number | null;
34
+ timestamp: number;
35
+ }
36
+
37
+ let latest: HeadingReading | null = null;
38
+ let sub: { remove(): void } | null = null;
39
+ const turns = new TurnDetector();
40
+
41
+ /** Start watching. Idempotent; safe to call on every session start. */
42
+ export async function startHeading(): Promise<void> {
43
+ if (sub) return;
44
+ try {
45
+ sub = await ExpoLocation.watchHeadingAsync(h => {
46
+ // trueHeading is -1 until the compass calibrates; magHeading is the
47
+ // fallback the OS itself recommends.
48
+ const raw = h.trueHeading >= 0 ? h.trueHeading : h.magHeading;
49
+ const deg = typeof raw === 'number' && raw >= 0 ? raw : null;
50
+ const cal = typeof h.accuracy === 'number' && h.accuracy >= 0 ? h.accuracy : null;
51
+ latest = { headingDeg: deg, calibration: cal, timestamp: Date.now() };
52
+ turns.push(deg, Date.now());
53
+ });
54
+ } catch {
55
+ // No compass, or permission refused. Callers get headingDeg: null, which
56
+ // is the honest answer and does not stop anything else working.
57
+ sub = null;
58
+ }
59
+ }
60
+
61
+ export function stopHeading(): void {
62
+ sub?.remove();
63
+ sub = null;
64
+ // Clear everything: a heading from a finished session describes a direction
65
+ // the user may have long since turned away from, and a turn from a previous
66
+ // walk is not something a new one should report.
67
+ latest = null;
68
+ turns.reset();
69
+ }
70
+
71
+ /** The newest reading, or null if the compass has said nothing yet. */
72
+ export function currentHeading(): { headingDeg: number | null } | null {
73
+ return latest ? { headingDeg: latest.headingDeg } : null;
74
+ }
75
+
76
+ /** The newest reading with its calibration and age. */
77
+ export function currentHeadingDetail(): HeadingReading | null {
78
+ return latest;
79
+ }
80
+
81
+ /** The most recent completed turn. Signed: positive = clockwise = right. */
82
+ export function lastMeaningfulTurn(): TurnEvent | null {
83
+ return turns.last();
84
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Device Bridge v2 — the runtime behind `props.deviceState`.
3
+ *
4
+ * R5 split the former monolith into one module per domain (./domains/*), a
5
+ * single quality module that owns freshness/confidence/unknown (./quality), and
6
+ * this composition root, which wires the domains and injects their dependencies.
7
+ * The behaviour — and every snapshot shape — is unchanged; only the file layout is.
8
+ *
9
+ * WHAT IS AND IS NOT HERE — stated up front, because a half-built state layer
10
+ * that pretends to be whole is the exact failure this whole effort exists to
11
+ * end:
12
+ *
13
+ * deviceContext power ✓ thermal ✗ memory/storage ✗
14
+ * connectivity reachability ✓ transport ✓ quality/stability/metered ✗
15
+ * capability permission ✓ availability ✗ restriction ✗
16
+ * interaction app lifecycle ✓ screenOn ✗ audio ✗
17
+ * spatial position ✓ path ✓ (while a session is open) heading/speed ✗
18
+ * visual frames ✓ heading ✓ freshness ✓ blur/exposure ✗
19
+ * activity / environment — NOT IMPLEMENTED AT ALL
20
+ *
21
+ * Everything marked ✗ reports `unknown`, which is a real answer in this design
22
+ * and the only honest one. It never reports a plausible default: an invented
23
+ * 'nominal' thermal state is indistinguishable from a measured one, and that is
24
+ * how 56 fabrications reached users from the layer below.
25
+ *
26
+ * A domain with no engine here is not simply missing: `gateDeviceState` (in
27
+ * plugin-runtime/src/deviceStateHost.ts) fills the gap with a domain whose
28
+ * reads answer explicit `unknown`s and whose ACTIONS throw. So a plugin can
29
+ * call any of the eight without an existence check, and still cannot mistake
30
+ * "not built" for "nothing to report". `environment` is deliberately absent
31
+ * from the composition below for exactly that reason.
32
+ */
33
+ import type { DeviceState } from '@ateam-ai-mobile/plugin-runtime';
34
+ import { DeviceStateCollectors, SpeedRef } from './collectors';
35
+ import { createDeviceContextDomain } from './domains/deviceContext';
36
+ import { createConnectivityDomain } from './domains/connectivity';
37
+ import { createCapabilityDomain } from './domains/capability';
38
+ import { createInteractionDomain } from './domains/interaction';
39
+ import { createSpatialDomain } from './domains/spatial';
40
+ import { createVisualDomain } from './domains/visual';
41
+ import { createActivityDomain } from './domains/activity';
42
+
43
+ export type { DeviceStateCollectors, SpeedRef } from './collectors';
44
+
45
+ /**
46
+ * Build the semantic surface.
47
+ *
48
+ * Only implemented domains appear. `environment` is deliberately absent: a
49
+ * plugin touching it gets a TypeError naming it, which is a far better failure
50
+ * than a snapshot of nulls that reads like a quiet device.
51
+ */
52
+ export function createDeviceState(collectors: DeviceStateCollectors = {}): Partial<DeviceState> {
53
+ // The ONE explicit cross-domain dependency: Activity needs Spatial's newest
54
+ // speed to tell "walking" from "sitting in a moving car". Passed as a shared
55
+ // reference, not a cross-domain import — see SpeedRef.
56
+ const speedRef: SpeedRef = { mps: null, at: 0 };
57
+ return {
58
+ deviceContext: createDeviceContextDomain(),
59
+ connectivity: createConnectivityDomain(collectors),
60
+ capability: createCapabilityDomain(collectors),
61
+ interaction: createInteractionDomain(),
62
+ spatial: createSpatialDomain(collectors, speedRef),
63
+ visual: createVisualDomain(),
64
+ activity: createActivityDomain(speedRef),
65
+ };
66
+ }
67
+
68
+ /** Which domains this build can actually answer. */
69
+ export const IMPLEMENTED_DOMAINS = [
70
+ 'deviceContext',
71
+ 'connectivity',
72
+ 'capability',
73
+ 'interaction',
74
+ 'spatial',
75
+ 'visual',
76
+ 'activity',
77
+ ] as const;