@mentra/engine 3.2.0-dev.226 → 3.2.0-dev.232

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 (34) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/services/AcsMeetingService.d.ts +15 -7
  4. package/build/services/AcsMeetingService.d.ts.map +1 -1
  5. package/build/services/AcsMeetingService.js +32 -9
  6. package/build/services/AcsMeetingService.js.map +1 -1
  7. package/build/services/GlassesHotspotLease.d.ts +2 -0
  8. package/build/services/GlassesHotspotLease.d.ts.map +1 -0
  9. package/build/services/GlassesHotspotLease.js +13 -0
  10. package/build/services/GlassesHotspotLease.js.map +1 -0
  11. package/build/services/LocalMiniappRuntime.d.ts +2 -0
  12. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  13. package/build/services/LocalMiniappRuntime.js +35 -11
  14. package/build/services/LocalMiniappRuntime.js.map +1 -1
  15. package/build/services/ManagedWebRtcRelay.d.ts +87 -0
  16. package/build/services/ManagedWebRtcRelay.d.ts.map +1 -0
  17. package/build/services/ManagedWebRtcRelay.js +239 -0
  18. package/build/services/ManagedWebRtcRelay.js.map +1 -0
  19. package/build/services/PhoneStreamCoordinator.d.ts +5 -0
  20. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  21. package/build/services/PhoneStreamCoordinator.js +131 -68
  22. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  23. package/build/services/SoftapCallTransport.d.ts +5 -1
  24. package/build/services/SoftapCallTransport.d.ts.map +1 -1
  25. package/build/services/SoftapCallTransport.js +23 -5
  26. package/build/services/SoftapCallTransport.js.map +1 -1
  27. package/package.json +8 -7
  28. package/src/generated/releaseMetadata.ts +5 -5
  29. package/src/services/AcsMeetingService.ts +38 -9
  30. package/src/services/GlassesHotspotLease.ts +11 -0
  31. package/src/services/LocalMiniappRuntime.ts +47 -18
  32. package/src/services/ManagedWebRtcRelay.ts +278 -0
  33. package/src/services/PhoneStreamCoordinator.ts +152 -70
  34. package/src/services/SoftapCallTransport.ts +28 -7
@@ -9,6 +9,7 @@
9
9
  * management (connect/disconnect/ping).
10
10
  */
11
11
 
12
+ import {acquireGlassesHotspot} from "./GlassesHotspotLease"
12
13
  import {AppState, Linking} from "react-native"
13
14
  import Share from "react-native-share"
14
15
  import * as Battery from "expo-battery"
@@ -68,7 +69,12 @@ import {
68
69
  } from "../runtime/config"
69
70
  import {getAnalytics, getMiniappConfiguration, getUiSeams, isFeatureEnabled} from "../runtime/bootstrap"
70
71
  import {invokeScanQrSeam} from "../runtime/scanQrSeam"
71
- import {normalizeStreamAudioConfig, normalizeStreamVideoConfig, resolveCaptureAudio} from "../runtime/streamConfig"
72
+ import {
73
+ normalizeStreamAudioConfig,
74
+ normalizeStreamVideoConfig,
75
+ normalizeCaptureAudio,
76
+ resolveCaptureAudio,
77
+ } from "../runtime/streamConfig"
72
78
  import {toLanguageHint} from "@mentra/cloud-protocol/languages"
73
79
  import type {AudioSubscription, LanguageSource, TranscriptionData, TranslationData} from "@mentra/cloud-protocol"
74
80
  import {buildMiniappManifestSnapshot, type MiniappRuntimeDiagnosticSnapshot} from "../utils/miniappDiagnostics"
@@ -399,6 +405,7 @@ type SoftapAttempt = {
399
405
  /** Set by leave, end, or a superseding join. Checked after every await the join performs. */
400
406
  cancelled: boolean
401
407
  /** Set only after the preceding attempt has fully settled. */
408
+ releaseHotspot?: () => void
402
409
  ownsResources: boolean
403
410
  transport: SoftapCallTransport | null
404
411
  /** The checklist as the miniapp last saw it; preflight rows live here until `start()` takes over. */
@@ -991,13 +998,14 @@ class LocalMiniappRuntime {
991
998
  // remain active for another subscriber; a rejected FOV release then stops
992
999
  // renewing the departed owner's lease and ASG restores it once idle.
993
1000
  const cameraCleanup = Promise.allSettled([
1001
+ this.leaveMeetingForApp(packageName),
994
1002
  phonePhotoCoordinator.stopWarmUpForApp(packageName),
995
1003
  phoneStreamCoordinator.stop(packageName),
996
1004
  phoneVideoCoordinator.stopForApp(packageName),
997
1005
  ]).then((results) => {
998
1006
  for (const result of results) {
999
1007
  if (result.status === "rejected") {
1000
- console.warn(`${LOG_TAG}: failed to stop camera consumer for ${packageName} on unregister`, result.reason)
1008
+ console.warn(`${LOG_TAG}: failed to stop miniapp resource for ${packageName} on unregister`, result.reason)
1001
1009
  }
1002
1010
  }
1003
1011
  })
@@ -1031,10 +1039,6 @@ class LocalMiniappRuntime {
1031
1039
  // so a crashed/closed miniapp doesn't leak partial files or file handles.
1032
1040
  this.blobStore.onAppGone(packageName)
1033
1041
 
1034
- void acsMeetingService.leaveIfOwner(packageName).catch((error) => {
1035
- console.warn(`${LOG_TAG}: failed to leave ACS meeting for ${packageName} on unregister`, error)
1036
- })
1037
-
1038
1042
  // Detach the per-app nav event forwarder but leave the native nav session
1039
1043
  // running. The user may have just closed the mini-app UI and will reopen
1040
1044
  // it; stopping the session here would kill an active trip mid-route.
@@ -2327,11 +2331,11 @@ class LocalMiniappRuntime {
2327
2331
  typeof rawText === "string"
2328
2332
  ? [rawText.trim()].filter(Boolean)
2329
2333
  : Array.isArray(rawText)
2330
- ? rawText
2331
- .filter((sentence): sentence is string => typeof sentence === "string")
2332
- .map((sentence) => sentence.trim())
2333
- .filter(Boolean)
2334
- : []
2334
+ ? rawText
2335
+ .filter((sentence): sentence is string => typeof sentence === "string")
2336
+ .map((sentence) => sentence.trim())
2337
+ .filter(Boolean)
2338
+ : []
2335
2339
  const enableSanitization = payload.enableSanitization !== false
2336
2340
  const sentences = prepareTtsSentences(rawSentences, enableSanitization)
2337
2341
  if (sentences.length === 0) {
@@ -3627,13 +3631,18 @@ class LocalMiniappRuntime {
3627
3631
  return
3628
3632
  }
3629
3633
  try {
3634
+ if (payload.ingest === "whip" && !(await permissions.check(PermissionFeatures.LOCAL_WIFI))) {
3635
+ if (!(await permissions.request(PermissionFeatures.LOCAL_WIFI))) {
3636
+ throw new Error("Nearby devices permission is required to join the glasses hotspot")
3637
+ }
3638
+ }
3630
3639
  const result = await streaming.startManaged(packageName, {
3631
3640
  restreamDestinations: payload.restreamDestinations as Array<string | {url: string; name?: string}> | undefined,
3632
3641
  video: normalizeStreamVideoConfig(payload.video),
3633
3642
  audio: normalizeStreamAudioConfig(payload.audio),
3634
3643
  sound: payload.sound as boolean | undefined,
3635
- ingest: payload.ingest as "srt" | "whip" | undefined,
3636
- captureAudio: resolveCaptureAudio(payload.captureAudio, resolveAcsAudioSource().source),
3644
+ ingest: payload.ingest as "srt" | "whip" | "rtmp" | undefined,
3645
+ captureAudio: normalizeCaptureAudio(payload.captureAudio),
3637
3646
  })
3638
3647
  this.sendResult(packageName, requestId, true, result)
3639
3648
  } catch (err) {
@@ -3656,6 +3665,9 @@ class LocalMiniappRuntime {
3656
3665
 
3657
3666
  private ensureMeetingStateBridge(): void {
3658
3667
  acsMeetingService.setStateHandler((owner, state) => {
3668
+ const attempt = this.softapAttempt
3669
+ // While a replacement waits for cleanup, native events still describe its predecessor.
3670
+ if (attempt?.packageName === owner && !attempt.ownsResources) return
3659
3671
  this.sendToMiniapp(owner, {
3660
3672
  type: MiniappResponseType.MEETING_STATE,
3661
3673
  ...state,
@@ -3663,6 +3675,16 @@ class LocalMiniappRuntime {
3663
3675
  })
3664
3676
  }
3665
3677
 
3678
+ /** Startup owns the hotspot before ACS has an owner. Closing the app must retire both. */
3679
+ private async leaveMeetingForApp(packageName: string): Promise<void> {
3680
+ const attempt = this.softapAttempt
3681
+ if (attempt?.packageName === packageName) {
3682
+ await this.retireSoftapAttempt({attempt})
3683
+ } else {
3684
+ await acsMeetingService.leaveIfOwner(packageName)
3685
+ }
3686
+ }
3687
+
3666
3688
  private async handleMeetingJoin(
3667
3689
  packageName: string,
3668
3690
  payload: Record<string, unknown>,
@@ -3891,7 +3913,8 @@ class LocalMiniappRuntime {
3891
3913
  return
3892
3914
  }
3893
3915
  attempt.progress = progress
3894
- const current = acsMeetingService.getState()
3916
+ // A reservation waiting behind another call must not inherit that call's connected state.
3917
+ const current = attempt.transport ? acsMeetingService.getState() : {state: "connecting", muted: false}
3895
3918
  this.sendToMiniapp(attempt.packageName, {
3896
3919
  type: MiniappResponseType.MEETING_STATE,
3897
3920
  ...current,
@@ -3925,6 +3948,7 @@ class LocalMiniappRuntime {
3925
3948
  ): Promise<MeetingState> {
3926
3949
  const packageName = attempt.packageName
3927
3950
  if (previous) {
3951
+ this.narrateSoftapPreflight(attempt, "Finishing the previous call’s cleanup…")
3928
3952
  softapTrace("softap_join_superseding", {
3929
3953
  previousAttempt: previous.id,
3930
3954
  previousAgeMs: Date.now() - previous.startedAt,
@@ -3949,6 +3973,7 @@ class LocalMiniappRuntime {
3949
3973
  softapTraceFailure("softap_join_refused", {packageName, reason: cleanupError})
3950
3974
  throw new Error(`Previous call cleanup failed: ${cleanupError}. Power-cycle the glasses hotspot and try again.`)
3951
3975
  }
3976
+ attempt.releaseHotspot = acquireGlassesHotspot()
3952
3977
  attempt.ownsResources = true
3953
3978
  this.narrateSoftapPreflight(attempt, "Checking the Nearby devices permission…")
3954
3979
  const permissionStartedAt = Date.now()
@@ -3980,12 +4005,14 @@ class LocalMiniappRuntime {
3980
4005
  setHotspotState: async (enabled) => {
3981
4006
  const status = await BluetoothSdk.setHotspotState(enabled)
3982
4007
  if (status.state === "enabled") {
3983
- return {state: status.state, ssid: status.ssid, password: status.password}
4008
+ return {state: status.state, ssid: status.ssid, password: status.password, localIp: status.localIp}
3984
4009
  }
3985
4010
  return {state: status.state}
3986
4011
  },
3987
- joinScopedNetwork: (ssid, passphrase) => acsMeetingService.joinScopedNetwork(ssid, passphrase),
4012
+ joinScopedNetwork: (ssid, passphrase, gateway) =>
4013
+ acsMeetingService.joinScopedNetwork(ssid, passphrase, gateway),
3988
4014
  leaveScopedNetwork: () => acsMeetingService.leaveScopedNetwork(),
4015
+ cancelScopedNetworkJoin: () => acsMeetingService.cancelScopedNetworkJoin(),
3989
4016
  probeGateway: async () => {
3990
4017
  const verdict = await acsMeetingService.probeScopedGateway()
3991
4018
  // No native probe means no verdict, and the orchestrator must not read that as "down".
@@ -4237,7 +4264,7 @@ class LocalMiniappRuntime {
4237
4264
  // Teams configuration problem.
4238
4265
  const defaultNetworkStartedAt = Date.now()
4239
4266
  try {
4240
- const network = await acsMeetingService.awaitValidatedDefaultNetwork()
4267
+ const network = await acsMeetingService.awaitDefaultNetworkAfterHotspot()
4241
4268
  softapTrace("softap_teardown_step", {
4242
4269
  attempt: attempt.id,
4243
4270
  step: "awaitValidatedDefaultNetwork",
@@ -4310,6 +4337,8 @@ class LocalMiniappRuntime {
4310
4337
  failures: failures.join("; "),
4311
4338
  endRefused: Boolean(endFailure),
4312
4339
  })
4340
+ attempt.releaseHotspot?.()
4341
+ attempt.releaseHotspot = undefined
4313
4342
  if (endFailure) throw endFailure
4314
4343
  }
4315
4344
 
@@ -4437,7 +4466,7 @@ class LocalMiniappRuntime {
4437
4466
  requestId?: string,
4438
4467
  ): Promise<void> {
4439
4468
  const videoSource = payload.videoSource as {type?: string; url?: string} | undefined
4440
- const whepUrl = videoSource?.type === "whep" ? videoSource.url ?? "" : ""
4469
+ const whepUrl = videoSource?.type === "whep" ? (videoSource.url ?? "") : ""
4441
4470
  if (!whepUrl) {
4442
4471
  this.sendResult(packageName, requestId, false, undefined, {
4443
4472
  code: MiniappErrorCode.INVALID_ARGUMENT,
@@ -0,0 +1,278 @@
1
+ import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
2
+ import type {StreamStartRequest, StreamStatusEvent} from "@mentra/bluetooth-sdk/internal"
3
+ import {acquireGlassesHotspot} from "./GlassesHotspotLease"
4
+
5
+ export interface RelayOptions {
6
+ streamId: string
7
+ ingestUrl: string
8
+ video?: StreamStartRequest["video"]
9
+ audio?: StreamStartRequest["audio"]
10
+ captureAudio?: boolean
11
+ sound?: boolean
12
+ }
13
+
14
+ interface NativeRelayEvent {
15
+ attemptId: string
16
+ state: string
17
+ reason: string
18
+ }
19
+ interface NativeRelay {
20
+ prepare(options: {
21
+ attemptId: string
22
+ ingestUrl: string
23
+ ssid: string
24
+ password: string
25
+ gatewayAddress?: string
26
+ captureAudio: boolean
27
+ bitrate: number
28
+ }): Promise<string>
29
+ stop(attemptId: string): Promise<void>
30
+ addListener(event: "onRelayState", listener: (event: NativeRelayEvent) => void): {remove(): void}
31
+ }
32
+
33
+ export interface RelayDependencies {
34
+ native: NativeRelay
35
+ hotspot(enabled: boolean): Promise<{state: string; ssid?: string; password?: string; localIp?: string}>
36
+ startGlasses(request: StreamStartRequest): Promise<StreamStatusEvent | undefined>
37
+ stopGlasses(): Promise<unknown>
38
+ deferredStop(): void
39
+ connected(): boolean
40
+ sleep(ms: number): Promise<void>
41
+ now(): number
42
+ acquire(): () => void
43
+ }
44
+
45
+ export interface ManagedRelay {
46
+ start(): Promise<StreamStatusEvent | undefined>
47
+ cancel(): void
48
+ stop(): Promise<void>
49
+ owns(streamId: string): boolean
50
+ handleGlassesStatus(event: StreamStatusEvent): void
51
+ }
52
+
53
+ /** One attempt at a time; media stays native. All retries unwind both peers and the hotspot. */
54
+ export class ManagedWebRtcRelay implements ManagedRelay {
55
+ private cancelled = false
56
+ private started = false
57
+ private attempt = 0
58
+ private attemptId: string | null = null
59
+ private attemptError: Error | null = null
60
+ private hotspotTouched = false
61
+ private nativeTouched = false
62
+ private glassesTouched = false
63
+ private release: (() => void) | null = null
64
+ private listener: {remove(): void} | null = null
65
+ private operation: Promise<unknown> = Promise.resolve()
66
+ private restarting = false
67
+ private retries = 0
68
+ private readyAt: number | null = null
69
+ private stopping: Promise<void> | null = null
70
+
71
+ constructor(
72
+ private readonly options: RelayOptions,
73
+ private readonly onStatus: (status: string, reason: string) => void,
74
+ private readonly onFailure: (error: Error) => void,
75
+ private readonly deps: RelayDependencies,
76
+ ) {}
77
+
78
+ start(): Promise<StreamStatusEvent | undefined> {
79
+ if (this.started) return Promise.reject(new Error("Relay already started"))
80
+ this.started = true
81
+ this.release = this.deps.acquire()
82
+ this.listener = this.deps.native.addListener("onRelayState", (event) => {
83
+ if (event.attemptId !== this.attemptId || this.cancelled) return
84
+ if (event.state === "failed") this.failed(new Error(event.reason))
85
+ else this.onStatus(event.state, event.reason)
86
+ })
87
+ const start = this.startAttempt()
88
+ this.operation = start
89
+ return start
90
+ }
91
+
92
+ cancel(): void {
93
+ this.cancelled = true
94
+ }
95
+
96
+ owns(streamId: string): boolean {
97
+ // Consume late status from all attempts without allowing it to affect the current attempt.
98
+ return streamId.startsWith(`${this.options.streamId}-relay-`)
99
+ }
100
+
101
+ handleGlassesStatus(event: StreamStatusEvent): void {
102
+ if (event.streamId !== this.attemptId || this.cancelled) return
103
+ if (event.status === "stopped" || event.terminal) {
104
+ this.failed(new Error("Glasses publisher stopped"))
105
+ }
106
+ }
107
+
108
+ private checkpoint(): void {
109
+ if (this.cancelled) throw new Error("Relay cancelled")
110
+ if (this.attemptError) throw this.attemptError
111
+ }
112
+
113
+ private async startAttempt(): Promise<StreamStatusEvent | undefined> {
114
+ this.attemptId = `${this.options.streamId}-relay-${++this.attempt}`
115
+ this.attemptError = null
116
+ this.checkpoint()
117
+ this.hotspotTouched = true // A timed-out BLE command may still have enabled the AP.
118
+ const hotspot = await this.deps.hotspot(true)
119
+ this.checkpoint()
120
+ if (hotspot.state !== "enabled" || !hotspot.ssid || !hotspot.password)
121
+ throw new Error("Glasses hotspot did not start")
122
+ await this.deps.sleep(3_000) // Same beacon/DHCP startup allowance as ACS.
123
+ this.checkpoint()
124
+ this.nativeTouched = true
125
+ const url = await this.deps.native.prepare({
126
+ attemptId: this.attemptId,
127
+ ingestUrl: this.options.ingestUrl,
128
+ ssid: hotspot.ssid,
129
+ password: hotspot.password,
130
+ gatewayAddress: hotspot.localIp,
131
+ captureAudio: this.options.captureAudio !== false,
132
+ bitrate: this.options.video?.bitrate ?? 2_000_000,
133
+ })
134
+ this.checkpoint()
135
+ this.glassesTouched = true
136
+ const result = await this.deps.startGlasses({
137
+ type: "start_stream",
138
+ streamId: this.attemptId,
139
+ streamUrl: url,
140
+ ice: {stun: ""},
141
+ sound: this.options.sound ?? true,
142
+ captureAudio: this.options.captureAudio !== false,
143
+ ...(this.options.video !== undefined ? {video: this.options.video} : {}),
144
+ ...(this.options.audio !== undefined ? {audio: this.options.audio} : {}),
145
+ })
146
+ this.checkpoint()
147
+ this.readyAt = this.deps.now()
148
+ return result
149
+ }
150
+
151
+ private failed(error: Error): void {
152
+ if (this.cancelled || this.attemptError) return
153
+ this.attemptError = error
154
+ // Bound consecutive trouble, not the lifetime number of recoveries in a long stream.
155
+ if (this.readyAt !== null && this.deps.now() - this.readyAt >= 60_000) this.retries = 0
156
+ this.readyAt = null
157
+ if (this.restarting) return
158
+ this.restarting = true
159
+ // Await startup before cleanup: late native/BLE success still belongs to this attempt.
160
+ this.operation = this.operation
161
+ .catch(() => undefined)
162
+ .then(async () => {
163
+ while (!this.cancelled) {
164
+ try {
165
+ await this.cleanupAttempt()
166
+ if (this.cancelled) return
167
+ if (this.retries >= 3) throw error
168
+ this.onStatus("reconnecting", error.message)
169
+ await this.deps.sleep(1_000 * 2 ** this.retries++)
170
+ if (this.cancelled) return
171
+ await this.startAttempt()
172
+ this.onStatus("reconnected", "Glasses relay restarted")
173
+ return
174
+ } catch (failure) {
175
+ // Cleanup failure retains ownership; never create another stream on uncertain state.
176
+ if (this.hotspotTouched || this.nativeTouched || this.glassesTouched) {
177
+ try {
178
+ await this.cleanupAttempt()
179
+ } catch (cleanupError) {
180
+ if (!this.cancelled) this.onFailure(asError(cleanupError))
181
+ return
182
+ }
183
+ }
184
+ if (this.retries >= 3) {
185
+ if (!this.cancelled) this.onFailure(asError(failure))
186
+ return
187
+ }
188
+ }
189
+ }
190
+ })
191
+ .finally(() => {
192
+ this.restarting = false
193
+ })
194
+ }
195
+
196
+ stop(): Promise<void> {
197
+ this.cancel()
198
+ if (this.stopping) return this.stopping
199
+ this.listener?.remove()
200
+ this.listener = null
201
+ this.stopping = this.operation
202
+ .catch(() => undefined)
203
+ .then(async () => {
204
+ await this.cleanupAttempt()
205
+ this.release?.()
206
+ this.release = null
207
+ })
208
+ .finally(() => {
209
+ this.stopping = null
210
+ })
211
+ return this.stopping
212
+ }
213
+
214
+ private async cleanupAttempt(): Promise<void> {
215
+ const failures: unknown[] = []
216
+ const step = async (action: () => Promise<void>) => {
217
+ try {
218
+ await action()
219
+ } catch (error) {
220
+ failures.push(error)
221
+ }
222
+ }
223
+ // Invalidate callbacks before intentionally stopping the glasses or dropping the network.
224
+ const id = this.attemptId
225
+ this.attemptId = null
226
+ if (this.glassesTouched)
227
+ await step(async () => {
228
+ if (this.deps.connected()) await this.deps.stopGlasses()
229
+ else this.deps.deferredStop()
230
+ this.glassesTouched = false
231
+ })
232
+ if (this.nativeTouched && id)
233
+ await step(async () => {
234
+ await this.deps.native.stop(id)
235
+ this.nativeTouched = false
236
+ })
237
+ if (this.hotspotTouched)
238
+ await step(async () => {
239
+ if (this.deps.connected()) {
240
+ const result = await this.deps.hotspot(false)
241
+ if (result.state !== "disabled") throw new Error("Glasses hotspot shutdown was not confirmed")
242
+ } else this.deps.deferredStop()
243
+ this.hotspotTouched = false
244
+ })
245
+ if (failures.length) {
246
+ this.attemptId = id // Retain the native attempt token for a subsequent cleanup retry.
247
+ throw new Error(`Relay cleanup failed: ${failures.map((error) => asError(error).message).join("; ")}`)
248
+ }
249
+ }
250
+ }
251
+
252
+ function asError(error: unknown): Error {
253
+ return error instanceof Error ? error : new Error(String(error))
254
+ }
255
+
256
+ export function createManagedWebRtcRelay(
257
+ options: RelayOptions,
258
+ onStatus: (status: string, reason: string) => void,
259
+ onFailure: (error: Error) => void,
260
+ connected: () => boolean,
261
+ deferredStop: () => void,
262
+ ): ManagedRelay {
263
+ // Load only for managed WHIP. Hosts using direct SRT/RTMP need no relay module.
264
+ const {requireNativeModule} = require("expo-modules-core") as typeof import("expo-modules-core")
265
+ const {BgTimer} = require("../utils/timers") as typeof import("../utils/timers")
266
+ const native = requireNativeModule<NativeRelay>("MentraGlassesMediaRelay")
267
+ return new ManagedWebRtcRelay(options, onStatus, onFailure, {
268
+ native,
269
+ hotspot: (enabled) => BluetoothSdk.setHotspotState(enabled),
270
+ startGlasses: (request) => BluetoothSdk.startStream(request),
271
+ stopGlasses: () => BluetoothSdk.stopStream(),
272
+ connected,
273
+ deferredStop,
274
+ now: Date.now,
275
+ sleep: (ms) => new Promise((resolve) => BgTimer.setTimeout(resolve, ms)),
276
+ acquire: acquireGlassesHotspot,
277
+ })
278
+ }