@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,151 @@
1
+ /**
2
+ * What the user is doing, and how the phone is being held — as a pure function.
3
+ *
4
+ * Separated from the sensor plumbing so the JUDGEMENTS are testable: every
5
+ * threshold below is a claim about human beings, and a claim that decides
6
+ * whether a guide says "you have arrived" to someone sitting in a moving car
7
+ * deserves a test rather than a comment.
8
+ *
9
+ * WHY THIS DOMAIN EXISTS AT ALL WHEN SPATIAL ALREADY REPORTS MOVEMENT: GPS is
10
+ * what fails indoors, and indoors is where a guide most needs to know whether
11
+ * the user is walking. Steps come from the pedometer, which does not care about
12
+ * a roof. Spatial answers "are you moving through the world"; Activity answers
13
+ * "are you walking" — in a mall those are different questions with different
14
+ * sensors, and only one of them still works.
15
+ */
16
+ import type { Confidence } from '@ateam-ai-mobile/plugin-runtime';
17
+
18
+ export type HumanActivityValue =
19
+ | 'stationary' | 'walking' | 'running' | 'cycling' | 'driving' | 'unknown';
20
+ export type PlacementValue = 'handheld' | 'pocket' | 'table' | 'mounted' | 'unknown';
21
+ export type PoseOrientation = 'portrait' | 'landscape' | 'flat' | 'unknown';
22
+
23
+ export interface ActivityInput {
24
+ /** Steps per minute over the recent window. `null` = no pedometer reading. */
25
+ cadenceSpm: number | null;
26
+ /**
27
+ * Variance of accelerometer magnitude (g²) over the recent window.
28
+ * `null` = the accelerometer has not reported yet.
29
+ */
30
+ accelVariance: number | null;
31
+ /** Low-pass gravity estimate in g. `null` = not sampled yet. */
32
+ gravity: { x: number; y: number; z: number } | null;
33
+ /**
34
+ * Speed from Spatial, when a spatial session is streaming.
35
+ *
36
+ * Passed IN rather than read here, so this stays pure. It exists for exactly
37
+ * one job: a phone in a moving car looks identical to a phone on a desk —
38
+ * no steps, little vibration — and calling that `stationary` would tell a
39
+ * guide the user has arrived while they are doing 50 km/h.
40
+ */
41
+ speedMps: number | null;
42
+ }
43
+
44
+ export interface ActivityVerdict {
45
+ human: { state: HumanActivityValue; confidence: Confidence };
46
+ pose: { placement: PlacementValue; orientation: PoseOrientation; confidence: Confidence };
47
+ }
48
+
49
+ /**
50
+ * Cadence bands, in steps per minute.
51
+ *
52
+ * Ordinary walking sits around 100–120 spm and running around 160–180. 140 is
53
+ * placed in the gap rather than at either edge: misreading a fast walk as a run
54
+ * is harmless, but the reverse would have a guide give walking directions to
55
+ * someone covering ground twice as fast.
56
+ *
57
+ * 20 spm is the floor for "walking at all" — below that it is a few steps of
58
+ * shuffling, which is not travel.
59
+ */
60
+ const RUN_SPM = 140;
61
+ const WALK_SPM = 20;
62
+
63
+ /**
64
+ * Accelerometer magnitude variance, in g².
65
+ *
66
+ * A phone lying on a table reads a near-constant 1g, so its variance is
67
+ * essentially sensor noise. A hand holding a phone never manages that.
68
+ */
69
+ const STILL_VAR = 0.002;
70
+ /** Above this the phone is being moved about, not merely held. */
71
+ const ACTIVE_VAR = 0.02;
72
+
73
+ /**
74
+ * Speed at which the user is certainly not walking, in m/s.
75
+ *
76
+ * 3 m/s is ~11 km/h — above a fast walk (~2 m/s) and below anything a vehicle
77
+ * does in traffic.
78
+ */
79
+ const VEHICLE_MPS = 3;
80
+
81
+ /** Gravity component that has to dominate before an axis names the pose. */
82
+ const AXIS_DOMINANT = 0.75;
83
+
84
+ export function classifyActivity(input: ActivityInput): ActivityVerdict {
85
+ return { human: classifyHuman(input), pose: classifyPose(input) };
86
+ }
87
+
88
+ function classifyHuman({ cadenceSpm, accelVariance, speedMps }: ActivityInput): ActivityVerdict['human'] {
89
+ // Steps are the strongest evidence there is, and the only one that survives
90
+ // indoors. Take them first when they exist.
91
+ if (cadenceSpm !== null && cadenceSpm >= RUN_SPM) {
92
+ return { state: 'running', confidence: { level: 'high' } };
93
+ }
94
+ if (cadenceSpm !== null && cadenceSpm >= WALK_SPM) {
95
+ return { state: 'walking', confidence: { level: 'high' } };
96
+ }
97
+
98
+ // No meaningful stepping. Before calling it stationary, rule out a vehicle:
99
+ // riding is not walking, and it is not standing still either.
100
+ if (speedMps !== null && speedMps >= VEHICLE_MPS) {
101
+ // Moving fast without stepping. Which vehicle it is cannot be told from an
102
+ // accelerometer and a pedometer — separating cycling from driving needs
103
+ // real activity recognition (CMMotionActivity), which is not available
104
+ // here. `unknown` with the speed already published by Spatial is honest;
105
+ // guessing `driving` because it is the commoner case is not.
106
+ return { state: 'unknown', confidence: { level: 'low' } };
107
+ }
108
+
109
+ if (accelVariance !== null && accelVariance < STILL_VAR && (cadenceSpm === null || cadenceSpm < WALK_SPM)) {
110
+ // Genuinely still. Medium rather than high: a phone resting in a
111
+ // stationary car at a red light produces exactly this, and Spatial's speed
112
+ // cannot separate them either while the car is stopped.
113
+ return { state: 'stationary', confidence: { level: 'medium' } };
114
+ }
115
+
116
+ // Something is moving but nothing identifies it — a phone being handled
117
+ // while its owner stands still, most often. Naming an activity here would be
118
+ // a guess dressed as a reading.
119
+ return { state: 'unknown', confidence: { level: 'unknown' } };
120
+ }
121
+
122
+ function classifyPose({ gravity, accelVariance }: ActivityInput): ActivityVerdict['pose'] {
123
+ if (!gravity) {
124
+ return { placement: 'unknown', orientation: 'unknown', confidence: { level: 'unknown' } };
125
+ }
126
+
127
+ const { x, y, z } = gravity;
128
+ const orientation: PoseOrientation =
129
+ Math.abs(z) >= AXIS_DOMINANT ? 'flat'
130
+ : Math.abs(y) >= AXIS_DOMINANT ? 'portrait'
131
+ : Math.abs(x) >= AXIS_DOMINANT ? 'landscape'
132
+ : 'unknown';
133
+
134
+ // Flat and utterly still is a surface. This is the one placement the sensors
135
+ // can actually establish.
136
+ if (orientation === 'flat' && accelVariance !== null && accelVariance < STILL_VAR) {
137
+ return { placement: 'table', orientation, confidence: { level: 'high' } };
138
+ }
139
+
140
+ // Upright and moving a little is a hand. Medium, because a phone in a shirt
141
+ // pocket looks much the same and this cannot tell them apart.
142
+ if (orientation !== 'flat' && accelVariance !== null && accelVariance >= ACTIVE_VAR) {
143
+ return { placement: 'handheld', orientation, confidence: { level: 'medium' } };
144
+ }
145
+
146
+ // `pocket` and `mounted` are NEVER returned. Distinguishing a pocket needs
147
+ // the proximity or light sensor, and a car mount needs Bluetooth or CarPlay
148
+ // context — none of which is wired. The orientation is still reported,
149
+ // because it was actually measured.
150
+ return { placement: 'unknown', orientation, confidence: { level: 'unknown' } };
151
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * The Activity engine — steps and motion, sampled into short windows.
3
+ *
4
+ * Sensors only. Every judgement made from them lives in `activityClassifier`,
5
+ * which is pure and tested; this file's whole job is to keep two subscriptions
6
+ * alive and turn their streams into the three numbers the classifier wants.
7
+ *
8
+ * IT IS SESSION-SCOPED, and that is a power decision rather than a stylistic
9
+ * one. An accelerometer left running is a background drain and a pedometer
10
+ * subscription is a permission-visible one, so neither starts until a plugin
11
+ * declares it needs Activity. `getSnapshot()` with no session open returns an
12
+ * explicit unknown rather than quietly switching the sensors on — a read must
13
+ * not have that side effect.
14
+ */
15
+ import type { HumanActivityValue } from './activityClassifier';
16
+
17
+ /**
18
+ * expo-sensors is loaded LAZILY, on the first `start()`, and never at import.
19
+ *
20
+ * This module is reachable from app startup, and expo-sensors is a NATIVE
21
+ * module: an app binary built before it was added does not contain it. A
22
+ * top-level import that threw during evaluation would not degrade the Activity
23
+ * domain — it would fail the bundle and black-screen the whole app, on exactly
24
+ * the builds that predate this change. So the failure is contained here, where
25
+ * it can be reported as "no sensors" instead.
26
+ */
27
+ let sensors: any = null;
28
+ let sensorsMissing = false;
29
+
30
+ function loadSensors(): any {
31
+ if (sensors || sensorsMissing) return sensors;
32
+ try {
33
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
34
+ sensors = require('expo-sensors');
35
+ } catch {
36
+ sensorsMissing = true;
37
+ sensors = null;
38
+ }
39
+ return sensors;
40
+ }
41
+
42
+ /** Accelerometer cadence. 5 Hz is plenty for pose and for a variance band. */
43
+ const ACCEL_INTERVAL_MS = 200;
44
+ /** Motion window. Long enough to smooth a stride, short enough to be current. */
45
+ const MOTION_WINDOW_MS = 3000;
46
+ /**
47
+ * Step window.
48
+ *
49
+ * 20s, because cadence over a shorter window swings wildly at the start and end
50
+ * of walking — someone who takes four steps and stops would otherwise read as
51
+ * a steady 120 spm for a moment.
52
+ */
53
+ const STEP_WINDOW_MS = 20_000;
54
+ /** Low-pass coefficient isolating gravity from the raw accelerometer. */
55
+ const GRAVITY_ALPHA = 0.1;
56
+
57
+ interface StepSample { steps: number; t: number }
58
+
59
+ class ActivityEngine {
60
+ private accelSub: { remove(): void } | null = null;
61
+ private stepSub: { remove(): void } | null = null;
62
+
63
+ private magnitudes: Array<{ m: number; t: number }> = [];
64
+ private gravity: { x: number; y: number; z: number } | null = null;
65
+ private steps: StepSample[] = [];
66
+
67
+ private sessionId: string | null = null;
68
+ private startedAt = 0;
69
+ /** When the CURRENT reading last changed — feeds `human.since`. */
70
+ private pedometerAvailable: boolean | null = null;
71
+
72
+ isRunning(): boolean { return this.sessionId !== null; }
73
+ session(): { id: string; startedAt: number } | null {
74
+ return this.sessionId ? { id: this.sessionId, startedAt: this.startedAt } : null;
75
+ }
76
+
77
+ /**
78
+ * @param classify how to name the current activity — injected so the engine
79
+ * keeps no opinion of its own and the classifier stays pure and testable.
80
+ */
81
+ async start(classify: () => HumanActivityValue): Promise<{ id: string; startedAt: number }> {
82
+ if (this.sessionId) return this.session()!;
83
+ this.sessionId = `activity_${Date.now()}`;
84
+ this.startedAt = Date.now();
85
+
86
+ const s = loadSensors();
87
+ if (!s) {
88
+ // An app binary built before expo-sensors was added. The session is open
89
+ // — stopping it would be a lie in the other direction — but nothing
90
+ // samples, so every window stays empty and the classifier says `unknown`.
91
+ this.pedometerAvailable = false;
92
+ this.startTicking(classify);
93
+ return this.session()!;
94
+ }
95
+ const { Accelerometer, Pedometer } = s;
96
+
97
+ Accelerometer.setUpdateInterval(ACCEL_INTERVAL_MS);
98
+ this.accelSub = Accelerometer.addListener(({ x, y, z }: { x: number; y: number; z: number }) => {
99
+ const now = Date.now();
100
+ // Raw magnitude includes gravity, so a still phone sits at ~1g and the
101
+ // variance of this series is the motion signal.
102
+ this.magnitudes.push({ m: Math.sqrt(x * x + y * y + z * z), t: now });
103
+ const cutoff = now - MOTION_WINDOW_MS;
104
+ if (this.magnitudes.length > 8 && this.magnitudes[0].t < cutoff) {
105
+ this.magnitudes = this.magnitudes.filter(s => s.t >= cutoff);
106
+ }
107
+ // Gravity by low-pass: what survives smoothing is the constant pull, and
108
+ // that is what says which way up the phone is.
109
+ this.gravity = this.gravity
110
+ ? {
111
+ x: this.gravity.x + GRAVITY_ALPHA * (x - this.gravity.x),
112
+ y: this.gravity.y + GRAVITY_ALPHA * (y - this.gravity.y),
113
+ z: this.gravity.z + GRAVITY_ALPHA * (z - this.gravity.z),
114
+ }
115
+ : { x, y, z };
116
+ });
117
+
118
+ try {
119
+ this.pedometerAvailable = await Pedometer.isAvailableAsync();
120
+ } catch {
121
+ this.pedometerAvailable = false;
122
+ }
123
+ if (this.pedometerAvailable) {
124
+ try {
125
+ this.stepSub = Pedometer.watchStepCount(({ steps }: { steps: number }) => {
126
+ this.steps.push({ steps, t: Date.now() });
127
+ const cutoff = Date.now() - STEP_WINDOW_MS;
128
+ if (this.steps.length > 2 && this.steps[0].t < cutoff) {
129
+ this.steps = this.steps.filter(s => s.t >= cutoff);
130
+ }
131
+ });
132
+ } catch {
133
+ // Permission refused, most likely (iOS Motion & Fitness). Cadence stays
134
+ // null, the classifier says `unknown`, and nothing pretends otherwise.
135
+ this.stepSub = null;
136
+ this.pedometerAvailable = false;
137
+ }
138
+ }
139
+
140
+ this.startTicking(classify);
141
+ return this.session()!;
142
+ }
143
+
144
+ stop(): void {
145
+ this.accelSub?.remove();
146
+ this.stepSub?.remove();
147
+ this.accelSub = null;
148
+ this.stepSub = null;
149
+ this.sessionId = null;
150
+ this.stopTicking();
151
+ // The windows belong to the session. Keeping them would let a later read
152
+ // report a walk that finished before the phone went in a drawer.
153
+ this.magnitudes = [];
154
+ this.steps = [];
155
+ this.gravity = null;
156
+ }
157
+
158
+ /** Variance of accelerometer magnitude over the motion window, in g². */
159
+ accelVariance(): number | null {
160
+ const cutoff = Date.now() - MOTION_WINDOW_MS;
161
+ const window = this.magnitudes.filter(s => s.t >= cutoff);
162
+ // Two samples is not a variance; it is a coincidence.
163
+ if (window.length < 5) return null;
164
+ const mean = window.reduce((a, s) => a + s.m, 0) / window.length;
165
+ return window.reduce((a, s) => a + (s.m - mean) ** 2, 0) / window.length;
166
+ }
167
+
168
+ currentGravity(): { x: number; y: number; z: number } | null { return this.gravity; }
169
+
170
+ /**
171
+ * Steps per minute over the step window.
172
+ *
173
+ * Computed from the DIFFERENCE between the first and last cumulative counts,
174
+ * so it does not matter whether the platform counts from subscription start
175
+ * or from midnight.
176
+ *
177
+ * Returns null — not 0 — when the window is too short to divide by. Zero
178
+ * steps per minute is a real claim ("standing still") and must only be made
179
+ * once there is a window long enough to support it.
180
+ */
181
+ cadenceSpm(): number | null {
182
+ const cutoff = Date.now() - STEP_WINDOW_MS;
183
+ const window = this.steps.filter(s => s.t >= cutoff);
184
+ if (window.length < 2) {
185
+ // The pedometer is alive and has simply reported nothing recently, which
186
+ // is what standing still looks like: no callbacks at all.
187
+ if (this.pedometerAvailable && Date.now() - this.startedAt > STEP_WINDOW_MS) return 0;
188
+ return null;
189
+ }
190
+ const first = window[0], last = window[window.length - 1];
191
+ const minutes = (last.t - first.t) / 60_000;
192
+ if (minutes <= 0) return null;
193
+ return (last.steps - first.steps) / minutes;
194
+ }
195
+
196
+ /** Whether a pedometer exists and is permitted. Null before `start()`. */
197
+ hasPedometer(): boolean | null { return this.pedometerAvailable; }
198
+
199
+ /** When the newest sample arrived — what freshness is actually about. */
200
+ observedAt(): number | null {
201
+ const ts = [
202
+ this.magnitudes[this.magnitudes.length - 1]?.t,
203
+ this.steps[this.steps.length - 1]?.t,
204
+ ].filter((t): t is number => typeof t === 'number');
205
+ return ts.length ? Math.max(...ts) : null;
206
+ }
207
+
208
+ // ── transitions ──
209
+ //
210
+ // Tracked on a timer inside the engine rather than computed when someone
211
+ // reads, because a transition is an EVENT: "you started walking" happens
212
+ // between two reads, and a domain that only classifies on demand reports the
213
+ // state and loses the moment it changed. `since` has the same problem.
214
+
215
+ private current: HumanActivityValue = 'unknown';
216
+ private since: number | null = null;
217
+ private transitions: Array<{ name: string; timestamp: number }> = [];
218
+ private ticker: ReturnType<typeof setInterval> | null = null;
219
+
220
+ /** Feed the engine its own classification once a second. */
221
+ private startTicking(classify: () => HumanActivityValue): void {
222
+ this.ticker = setInterval(() => {
223
+ const next = classify();
224
+ if (next === this.current) return;
225
+ this.current = next;
226
+ this.since = Date.now();
227
+ // The transition NAME reuses the frozen HumanActivity vocabulary — it is
228
+ // the state being entered. The freeze leaves transition names as bare
229
+ // strings with no controlled vocabulary, and inventing one ("walk_start")
230
+ // would put a word in plugins that the freeze may later contradict.
231
+ this.transitions = [...this.transitions, { name: next, timestamp: this.since }].slice(-10);
232
+ }, 1000);
233
+ }
234
+
235
+ private stopTicking(): void {
236
+ if (this.ticker) clearInterval(this.ticker);
237
+ this.ticker = null;
238
+ this.current = 'unknown';
239
+ this.since = null;
240
+ this.transitions = [];
241
+ }
242
+
243
+ /** When the current activity began, or null if it has not been established. */
244
+ currentSince(): number | null { return this.since; }
245
+ /** Newest last, bounded. Empty is a real answer: nothing has changed yet. */
246
+ recentTransitions(): Array<{ name: string; timestamp: number }> {
247
+ return [...this.transitions];
248
+ }
249
+ }
250
+
251
+ /** One engine per app: the sensors are singleton hardware. */
252
+ export const activityEngine = new ActivityEngine();
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The explicit dependencies the composition root injects into the domains.
3
+ *
4
+ * Collectors arrive by INJECTION rather than import: the two repos keep
5
+ * device-bridge in different places, so a hard relative import can only compile
6
+ * in one of them. Injection also matches how the rest of this SDK takes host
7
+ * capabilities, and lets a test drive the engines without an Expo runtime.
8
+ */
9
+ export interface DeviceStateCollectors {
10
+ connectivity?: { collectConnectivity(): Promise<any> };
11
+ location?: {
12
+ getPosition(opts?: { accuracy?: 'low' | 'high' }): Promise<any>;
13
+ watchPosition(cb: (pos: any) => void): () => void;
14
+ hasPermission(): Promise<boolean>;
15
+ };
16
+ }
17
+
18
+ /**
19
+ * The one number Activity needs from Spatial, and the only thing shared between
20
+ * two domains.
21
+ *
22
+ * Passed as an explicit reference rather than a cross-domain import, because
23
+ * Activity's job is to answer "is the user walking" and it cannot do that
24
+ * honestly without knowing whether the phone is in a moving vehicle: no steps
25
+ * and no vibration describes a desk and a car equally well.
26
+ *
27
+ * `at` matters as much as the value. A speed from two minutes ago says nothing
28
+ * about now, and a stale speed used to rule out "stationary" would be worse than
29
+ * having none.
30
+ */
31
+ export interface SpeedRef { mps: number | null; at: number }
@@ -0,0 +1,93 @@
1
+ // ── activity ─────────────────────────────────────────────────────────────
2
+ // THE INDOOR MOVEMENT SIGNAL (Spatial stops working indoors).
3
+ import type { DeviceState, ActivitySnapshot } from '@ateam-ai-mobile/plugin-runtime';
4
+ import { activityEngine } from '../activityEngine';
5
+ import { classifyActivity, type ActivityInput } from '../activityClassifier';
6
+ import type { SpeedRef } from '../collectors';
7
+ import { qualityFrom, qualityUnknown, unsupported } from '../quality';
8
+
9
+ /**
10
+ * What the user is doing, and how the phone is being held.
11
+ *
12
+ * THIS IS THE INDOOR MOVEMENT SIGNAL. Spatial answers "are you moving through
13
+ * the world" and stops being able to indoors, which is exactly where a guide
14
+ * most needs to know whether its user is walking. The pedometer does not care
15
+ * about a roof.
16
+ *
17
+ * SESSION-SCOPED, unlike the passive domains. An accelerometer left running is
18
+ * a background drain and a pedometer subscription is permission-visible on
19
+ * iOS, so nothing samples until a plugin says it needs this. A `getSnapshot()`
20
+ * with no session open returns an explicit unknown rather than switching the
21
+ * sensors on — a read must not have that side effect, and one that did would
22
+ * also return junk, since the windows would be empty at the moment it started.
23
+ */
24
+ export function createActivityDomain(speedRef: SpeedRef): DeviceState['activity'] {
25
+ /** A speed older than this says nothing about now. */
26
+ const SPEED_MAX_AGE_MS = 15_000;
27
+
28
+ const input = (): ActivityInput => ({
29
+ cadenceSpm: activityEngine.cadenceSpm(),
30
+ accelVariance: activityEngine.accelVariance(),
31
+ gravity: activityEngine.currentGravity(),
32
+ speedMps:
33
+ speedRef.at && Date.now() - speedRef.at < SPEED_MAX_AGE_MS ? speedRef.mps : null,
34
+ });
35
+
36
+ return {
37
+ async getSnapshot(): Promise<ActivitySnapshot> {
38
+ if (!activityEngine.isRunning()) {
39
+ // Nothing is sampling. Every field says so rather than describing a
40
+ // motionless phone, which is what an unstarted engine would look like.
41
+ return {
42
+ human: { state: 'unknown', since: null, confidence: { level: 'unknown' } },
43
+ devicePose: { placement: 'unknown', orientation: 'unknown', confidence: { level: 'unknown' } },
44
+ recentTransitions: [],
45
+ quality: qualityUnknown(),
46
+ };
47
+ }
48
+
49
+ const verdict = classifyActivity(input());
50
+ const observedAt = activityEngine.observedAt();
51
+ const transitions = activityEngine.recentTransitions();
52
+
53
+ return {
54
+ human: {
55
+ state: verdict.human.state,
56
+ since: activityEngine.currentSince(),
57
+ confidence: verdict.human.confidence,
58
+ },
59
+ devicePose: {
60
+ placement: verdict.pose.placement,
61
+ orientation: verdict.pose.orientation,
62
+ confidence: verdict.pose.confidence,
63
+ },
64
+ transition: {
65
+ current: verdict.human.state,
66
+ // A hint, and we have no basis for one. The freeze calls `likelyNext`
67
+ // advisory; advisory is not licence to invent it.
68
+ likelyNext: null,
69
+ },
70
+ recentTransitions: transitions,
71
+ quality: observedAt === null
72
+ ? qualityUnknown()
73
+ : qualityFrom(observedAt, verdict.human.confidence),
74
+ };
75
+ },
76
+
77
+ /**
78
+ * Start sampling. The intent is recorded but does not yet change strategy —
79
+ * there is no native power-tuning layer for it to reach.
80
+ */
81
+ async start(options) {
82
+ const s = await activityEngine.start(() => classifyActivity(input()).human.state);
83
+ return { id: s.id, intent: options.intent, startedAt: s.startedAt } as any;
84
+ },
85
+
86
+ async stop() { activityEngine.stop(); },
87
+
88
+ // No activity event vocabulary is frozen — the design notes there is no
89
+ // "Initial Activity Events" list at all — so naming events here would put
90
+ // words in plugins that the freeze may contradict.
91
+ subscribe() { throw unsupported('activity.subscribe'); },
92
+ };
93
+ }
@@ -0,0 +1,60 @@
1
+ // ── capability ───────────────────────────────────────────────────────────
2
+ // permission ✓ availability ✗ restriction ✗
3
+ import type { DeviceState, CapabilitySnapshot, CapabilityStatus } from '@ateam-ai-mobile/plugin-runtime';
4
+ import type { DeviceStateCollectors } from '../collectors';
5
+ import { qualityFrom, unsupported } from '../quality';
6
+
7
+ /**
8
+ * The bridge's permission model is 4-valued (`not_requested | granted | denied
9
+ * | not_needed`); the frozen one is 7-valued. The extra states are real OS
10
+ * distinctions nothing currently reads, so they map to `unknown` rather than
11
+ * being collapsed into `denied` — "restricted by policy" and "the user said no"
12
+ * are different problems with different remedies, and guessing between them
13
+ * would put a wrong instruction in front of the user.
14
+ */
15
+ function statusFrom(granted: boolean | null): CapabilityStatus {
16
+ return {
17
+ support: 'unknown',
18
+ permission: granted === null ? 'unknown' : granted ? 'granted' : 'denied',
19
+ availability: 'unknown',
20
+ };
21
+ }
22
+
23
+ const UNKNOWN_STATUS: CapabilityStatus = {
24
+ support: 'unknown',
25
+ permission: 'unknown',
26
+ availability: 'unknown',
27
+ };
28
+
29
+ export function createCapabilityDomain(collectors: DeviceStateCollectors): DeviceState['capability'] {
30
+ return {
31
+ async getSnapshot(): Promise<CapabilitySnapshot> {
32
+ const observedAt = Date.now();
33
+ const locationGranted = collectors.location
34
+ ? await collectors.location.hasPermission().catch(() => null)
35
+ : null;
36
+
37
+ return {
38
+ // Only location has a real reader today. The rest are declared by the
39
+ // contract and answered honestly rather than omitted.
40
+ camera: UNKNOWN_STATUS,
41
+ microphone: UNKNOWN_STATUS,
42
+ location: {
43
+ ...statusFrom(locationGranted),
44
+ // iOS 14+ distinguishes precise from approximate; nothing reads it.
45
+ precision: 'unknown',
46
+ backgroundPermission: 'unknown',
47
+ },
48
+ motion: UNKNOWN_STATUS,
49
+ bluetooth: UNKNOWN_STATUS,
50
+ uwb: UNKNOWN_STATUS,
51
+ nfc: UNKNOWN_STATUS,
52
+ quality: qualityFrom(observedAt),
53
+ } as CapabilitySnapshot;
54
+ },
55
+
56
+ // No OS permission-change stream is wired. Same reasoning as connectivity:
57
+ // silence would be indistinguishable from stability.
58
+ subscribe() { throw unsupported('capability.subscribe'); },
59
+ };
60
+ }
@@ -0,0 +1,60 @@
1
+ // ── connectivity ─────────────────────────────────────────────────────────
2
+ // reachability ✓ transport ✓ quality/stability/metered ✗
3
+ import type { DeviceState, ConnectivitySnapshot } from '@ateam-ai-mobile/plugin-runtime';
4
+ import type { DeviceStateCollectors } from '../collectors';
5
+ import { qualityFrom, qualityUnknown, unsupported } from '../quality';
6
+
7
+ export function createConnectivityDomain(collectors: DeviceStateCollectors): DeviceState['connectivity'] {
8
+ return {
9
+ async getSnapshot(): Promise<ConnectivitySnapshot> {
10
+ const observedAt = Date.now();
11
+ try {
12
+ if (!collectors.connectivity) throw unsupported('connectivity collector');
13
+ const data: any = await collectors.connectivity.collectConnectivity();
14
+ const online = data?.is_connected === true;
15
+ const type = String(data?.type || '').toLowerCase();
16
+
17
+ return {
18
+ reachability: data?.is_connected == null ? 'unknown' : online ? 'online' : 'offline',
19
+ transport:
20
+ type === 'wifi' ? 'wifi'
21
+ : type === 'cellular' ? 'cellular'
22
+ : type === 'ethernet' ? 'ethernet'
23
+ : online ? 'other'
24
+ : data?.is_connected === false ? 'none'
25
+ : 'unknown',
26
+ // All four below need measurement nobody takes yet. `unknown` is the
27
+ // designed answer — "return unknown rather than stale precision".
28
+ qualityClass: 'unknown',
29
+ stability: 'unknown',
30
+ latencyMs: null,
31
+ bandwidthClass: 'unknown',
32
+ metered: null,
33
+ constrained: null,
34
+ quality: qualityFrom(observedAt),
35
+ };
36
+ } catch {
37
+ return {
38
+ reachability: 'unknown',
39
+ transport: 'unknown',
40
+ qualityClass: 'unknown',
41
+ stability: 'unknown',
42
+ latencyMs: null,
43
+ bandwidthClass: 'unknown',
44
+ metered: null,
45
+ constrained: null,
46
+ quality: qualityUnknown(),
47
+ };
48
+ }
49
+ },
50
+
51
+ // Intent selects a native sampling strategy — latency probes, quality
52
+ // classification. Nothing here samples anything, so accepting an intent
53
+ // would be accepting an instruction we do not carry out.
54
+ async start() { throw unsupported('connectivity.start'); },
55
+ async stop() { throw unsupported('connectivity.stop'); },
56
+ // A subscription that can never fire is worse than none: the caller
57
+ // reasonably reads silence as "nothing changed".
58
+ subscribe() { throw unsupported('connectivity.subscribe'); },
59
+ };
60
+ }