@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.
- 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 +2473 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/bootstrap.mjs +64 -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-4XYXJYN5.mjs +1513 -0
- package/dist/chunk-4XYXJYN5.mjs.map +1 -0
- package/dist/chunk-5HM6UA2K.mjs +571 -0
- package/dist/chunk-5HM6UA2K.mjs.map +1 -0
- package/dist/chunk-6LXF3JVX.mjs +96 -0
- package/dist/chunk-6LXF3JVX.mjs.map +1 -0
- package/dist/chunk-FF6ZZZXH.mjs +71 -0
- package/dist/chunk-FF6ZZZXH.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-TZXWVYYR.mjs +67 -0
- package/dist/chunk-TZXWVYYR.mjs.map +1 -0
- package/dist/chunk-VQJPSLHP.mjs +1801 -0
- package/dist/chunk-VQJPSLHP.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/createBridge.mjs +2 -0
- 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 +967 -0
- package/dist/device/createDeviceProvider.js.map +1 -0
- package/dist/device/createDeviceProvider.mjs +17 -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 +483 -0
- package/dist/device-bridge/index.d.ts +483 -0
- package/dist/device-bridge/index.js +2084 -0
- package/dist/device-bridge/index.js.map +1 -0
- package/dist/device-bridge/index.mjs +32 -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 +493 -18
- package/dist/index.d.ts +493 -18
- package/dist/index.js +1409 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +23 -194
- package/dist/index.mjs.map +1 -1
- 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 +81 -0
- package/dist/vision/index.d.ts +81 -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/AdaVision.podspec +28 -0
- package/ios/AdaVisionModule.swift +432 -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 +107 -7
- package/src/bootstrap.ts +159 -0
- package/src/camera/CameraTaskHost.tsx +175 -0
- package/src/camera/cameraTasks.ts +129 -0
- package/src/device/createDeviceProvider.ts +832 -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 +224 -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 +31 -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 +55 -0
- package/src/device-state/domains/interaction.ts +59 -0
- package/src/device-state/domains/spatial.ts +283 -0
- package/src/device-state/domains/visual.ts +100 -0
- package/src/device-state/headingSource.ts +84 -0
- package/src/device-state/index.ts +77 -0
- package/src/device-state/quality.ts +60 -0
- package/src/device-state/replay.ts +220 -0
- package/src/device-state/turnDetector.ts +121 -0
- package/src/device-state/visualEngine.ts +341 -0
- package/src/haptics.ts +4 -0
- package/src/index.ts +37 -0
- package/src/oauth/expoOAuth.tsx +113 -0
- package/src/vision/analysis.js +80 -0
- package/src/vision/index.ts +144 -0
- 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
|
+
}
|