@mentra/engine 3.2.0-dev.235 → 3.2.0-dev.245
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/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/services/AcsMeetingService.d.ts +70 -0
- package/build/services/AcsMeetingService.d.ts.map +1 -1
- package/build/services/AcsMeetingService.js +97 -0
- package/build/services/AcsMeetingService.js.map +1 -1
- package/build/services/AppRegistry.d.ts +2 -12
- package/build/services/AppRegistry.d.ts.map +1 -1
- package/build/services/AppRegistry.js +2 -37
- package/build/services/AppRegistry.js.map +1 -1
- package/build/services/CloudClientService.d.ts +1 -1
- package/build/services/CloudClientService.d.ts.map +1 -1
- package/build/services/CloudClientService.js +2 -2
- package/build/services/CloudClientService.js.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts +51 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +228 -6
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/PhoneNotificationsSync.d.ts +1 -1
- package/build/services/PhonePhotoCoordinator.d.ts +3 -2
- package/build/services/PhonePhotoCoordinator.d.ts.map +1 -1
- package/build/services/PhonePhotoCoordinator.js +19 -14
- package/build/services/PhonePhotoCoordinator.js.map +1 -1
- package/build/services/SoftapCallTransport.d.ts +64 -0
- package/build/services/SoftapCallTransport.d.ts.map +1 -1
- package/build/services/SoftapCallTransport.js +103 -3
- package/build/services/SoftapCallTransport.js.map +1 -1
- package/build/services/manifestPermissions.d.ts +13 -0
- package/build/services/manifestPermissions.d.ts.map +1 -0
- package/build/services/manifestPermissions.js +42 -0
- package/build/services/manifestPermissions.js.map +1 -0
- package/build/types/applet.d.ts +13 -1
- package/build/types/applet.d.ts.map +1 -1
- package/build/types/applet.js.map +1 -1
- package/package.json +8 -8
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/services/AcsMeetingService.ts +138 -0
- package/src/services/AppRegistry.ts +3 -40
- package/src/services/CloudClientService.ts +2 -2
- package/src/services/LocalMiniappRuntime.ts +268 -7
- package/src/services/PhonePhotoCoordinator.ts +18 -14
- package/src/services/SoftapCallTransport.ts +151 -16
- package/src/services/manifestPermissions.ts +44 -0
- package/src/types/applet.ts +11 -0
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* management (connect/disconnect/ping).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import {parsePhotoCompression} from "@mentra/cloud-protocol/photo-compression"
|
|
12
13
|
import {acquireGlassesHotspot} from "./GlassesHotspotLease"
|
|
13
14
|
import {AppState, Linking} from "react-native"
|
|
14
15
|
import Share from "react-native-share"
|
|
@@ -88,9 +89,11 @@ import {advanceMiniappPingLiveness} from "./MiniappLiveness"
|
|
|
88
89
|
import {listPhoneCalendarEvents, PhoneCalendarError} from "./PhoneCalendarService"
|
|
89
90
|
import {LocalMiniappStorage} from "./LocalMiniappStorage"
|
|
90
91
|
import acsMeetingService, {
|
|
92
|
+
parseAcsCallOrigin,
|
|
91
93
|
parseAcsOutgoingVideo,
|
|
92
94
|
parseAcsVideoSource,
|
|
93
95
|
resolveAcsAudioSource,
|
|
96
|
+
type AcsCallOrigin,
|
|
94
97
|
type AcsOutgoingVideo,
|
|
95
98
|
type AcsVideoSource,
|
|
96
99
|
type MeetingState,
|
|
@@ -427,6 +430,37 @@ type SoftapAttempt = {
|
|
|
427
430
|
/** How long a stalled SoftAP cleanup goes unremarked before it is logged. It is never abandoned. */
|
|
428
431
|
const SOFTAP_CLEANUP_STALL_LOG_MS = 10_000
|
|
429
432
|
|
|
433
|
+
/**
|
|
434
|
+
* The WHIP listener's tombstone (3s) plus a margin for the close itself.
|
|
435
|
+
*
|
|
436
|
+
* Sized to the grace period rather than picked round, so an expiry here means something actually
|
|
437
|
+
* went wrong instead of meaning we guessed short.
|
|
438
|
+
*/
|
|
439
|
+
const SOFTAP_INGEST_CLOSE_WAIT_MS = 3_500
|
|
440
|
+
|
|
441
|
+
/** How long the glasses get to acknowledge their hotspot off, over BLE. */
|
|
442
|
+
const SOFTAP_HOTSPOT_OFF_ACK_MS = 5_000
|
|
443
|
+
|
|
444
|
+
/** After a forced close there is nothing left to wait for, so this only catches a real failure. */
|
|
445
|
+
const SOFTAP_FORCED_VERIFY_MS = 500
|
|
446
|
+
|
|
447
|
+
/** Reject with [reason] if [work] has not settled in [ms]. The work itself is not cancellable. */
|
|
448
|
+
function withTimeout<T>(work: Promise<T>, ms: number, reason: string): Promise<T> {
|
|
449
|
+
return new Promise<T>((resolve, reject) => {
|
|
450
|
+
const timer = setTimeout(() => reject(new Error(reason)), ms)
|
|
451
|
+
work.then(
|
|
452
|
+
(value) => {
|
|
453
|
+
clearTimeout(timer)
|
|
454
|
+
resolve(value)
|
|
455
|
+
},
|
|
456
|
+
(error) => {
|
|
457
|
+
clearTimeout(timer)
|
|
458
|
+
reject(error)
|
|
459
|
+
},
|
|
460
|
+
)
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
|
|
430
464
|
/** How long the next call waits for the previous one's cleanup before telling the wearer about it. */
|
|
431
465
|
const SOFTAP_CLEANUP_NARRATE_AFTER_MS = 250
|
|
432
466
|
|
|
@@ -446,6 +480,16 @@ class LocalMiniappRuntime {
|
|
|
446
480
|
* before the first `await` and is what every later stage checks itself against.
|
|
447
481
|
*/
|
|
448
482
|
private softapAttempt: SoftapAttempt | null = null
|
|
483
|
+
/**
|
|
484
|
+
* The outer generation. Every async callback that can outlive an attempt checks itself against
|
|
485
|
+
* it and traces `softap_stale_callback` when it loses: ACS state, SoftAP progress, glasses
|
|
486
|
+
* stream status, scoped-network loss, and each checkpoint through the join.
|
|
487
|
+
*
|
|
488
|
+
* There are inner generations below this one — `AcsMeetingSession.joinGeneration` and
|
|
489
|
+
* `LocalWhipIngestSource.generation` — and they are not redundant. They fence work inside one
|
|
490
|
+
* native session against itself; this one fences whole attempts against each other, and only it
|
|
491
|
+
* knows that the wearer pressed Cancel before any of that native work existed.
|
|
492
|
+
*/
|
|
449
493
|
private softapAttemptSeq = 0
|
|
450
494
|
/**
|
|
451
495
|
* Why the previous SoftAP call's cleanup failed, if it did.
|
|
@@ -454,6 +498,13 @@ class LocalMiniappRuntime {
|
|
|
454
498
|
* would not turn off is precisely what makes the next call fail somewhere far less legible.
|
|
455
499
|
*/
|
|
456
500
|
private softapCleanupError: string | null = null
|
|
501
|
+
/**
|
|
502
|
+
* SoftAP teardown disables the glasses hotspot from the transport and again
|
|
503
|
+
* from the host barrier. Native used to throw `request_in_flight` on the
|
|
504
|
+
* second call, which was recorded as a cleanup failure and refused the next
|
|
505
|
+
* join. One queue so those overlapping disables wait their turn.
|
|
506
|
+
*/
|
|
507
|
+
private glassesHotspotCommand: Promise<unknown> = Promise.resolve()
|
|
457
508
|
|
|
458
509
|
/** Connected miniapps keyed by packageName. */
|
|
459
510
|
private connectedApps: Map<string, ConnectedMiniapp> = new Map()
|
|
@@ -3376,7 +3427,7 @@ class LocalMiniappRuntime {
|
|
|
3376
3427
|
private requireManifestPermission(
|
|
3377
3428
|
packageName: string,
|
|
3378
3429
|
requestId: string | undefined,
|
|
3379
|
-
permission: "CAMERA" | "MICROPHONE",
|
|
3430
|
+
permission: "CAMERA" | "PHONE_CAMERA" | "MICROPHONE",
|
|
3380
3431
|
purpose: string,
|
|
3381
3432
|
operation: MiniappRequestType,
|
|
3382
3433
|
): boolean {
|
|
@@ -3392,6 +3443,31 @@ class LocalMiniappRuntime {
|
|
|
3392
3443
|
return false
|
|
3393
3444
|
}
|
|
3394
3445
|
|
|
3446
|
+
/**
|
|
3447
|
+
* OS-permission gate: checked, never requested.
|
|
3448
|
+
*
|
|
3449
|
+
* The prompt belongs at miniapp-open time, where it can be explained. Requesting here would put
|
|
3450
|
+
* a system dialog on top of a join that has already started building a hotspot, and a wearer who
|
|
3451
|
+
* denies it gets a call that half-exists. Missing means the join is refused with the permission
|
|
3452
|
+
* named, so the miniapp can send them to Settings.
|
|
3453
|
+
*/
|
|
3454
|
+
private async requireOsPermission(
|
|
3455
|
+
packageName: string,
|
|
3456
|
+
requestId: string | undefined,
|
|
3457
|
+
feature: string,
|
|
3458
|
+
label: string,
|
|
3459
|
+
): Promise<boolean> {
|
|
3460
|
+
if (await permissions.check(feature)) return true
|
|
3461
|
+
softapTraceFailure("softap_permission_missing", {packageName, feature})
|
|
3462
|
+
this.sendResult(packageName, requestId, false, undefined, {
|
|
3463
|
+
code: MiniappErrorCode.PERMISSION_DENIED,
|
|
3464
|
+
message: `This phone's ${label} permission is not granted. Grant it in Settings, then try again.`,
|
|
3465
|
+
permission: feature,
|
|
3466
|
+
operation: MiniappRequestType.MEETING_JOIN,
|
|
3467
|
+
})
|
|
3468
|
+
return false
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3395
3471
|
private async handlePhoto(packageName: string, payload: Record<string, unknown>, requestId?: string): Promise<void> {
|
|
3396
3472
|
// Manifest CAMERA permission gate.
|
|
3397
3473
|
const app = this.connectedApps.get(packageName)
|
|
@@ -3412,7 +3488,7 @@ class LocalMiniappRuntime {
|
|
|
3412
3488
|
size: payload.size as "low" | "medium" | "high" | "max" | "small" | "large" | "full" | undefined,
|
|
3413
3489
|
mode: payload.mode as "photo" | "text" | undefined,
|
|
3414
3490
|
transferMethod: payload.transferMethod as "auto" | "direct" | "ble" | undefined,
|
|
3415
|
-
compress: payload.compress
|
|
3491
|
+
compress: parsePhotoCompression(payload.compress),
|
|
3416
3492
|
sound: payload.sound as boolean | undefined,
|
|
3417
3493
|
saveToGallery: payload.saveToGallery as boolean | undefined,
|
|
3418
3494
|
exposureTimeNs: payload.exposureTimeNs as number | undefined,
|
|
@@ -3707,13 +3783,29 @@ class LocalMiniappRuntime {
|
|
|
3707
3783
|
"MICROPHONE",
|
|
3708
3784
|
"to join a meeting",
|
|
3709
3785
|
MiniappRequestType.MEETING_JOIN,
|
|
3786
|
+
) ||
|
|
3787
|
+
!this.requireManifestPermission(
|
|
3788
|
+
packageName,
|
|
3789
|
+
requestId,
|
|
3790
|
+
"PHONE_CAMERA",
|
|
3791
|
+
"to join a meeting",
|
|
3792
|
+
MiniappRequestType.MEETING_JOIN,
|
|
3710
3793
|
)
|
|
3711
3794
|
) {
|
|
3712
3795
|
return
|
|
3713
3796
|
}
|
|
3797
|
+
// Defensive only: these are prompted for when the wearer opens the miniapp, so by the time a
|
|
3798
|
+
// join runs they are either granted or were refused minutes ago on a screen that explained
|
|
3799
|
+
// them. Checked and never requested — a permission dialog on top of a half-built call is
|
|
3800
|
+
// asked too late to explain itself, and ACS fails the join outright without the camera.
|
|
3801
|
+
if (!(await this.requireOsPermission(packageName, requestId, PermissionFeatures.CAMERA, "camera"))) return
|
|
3802
|
+
if (!(await this.requireOsPermission(packageName, requestId, PermissionFeatures.MICROPHONE, "microphone"))) return
|
|
3714
3803
|
const meetingUrl = typeof payload.meetingUrl === "string" ? payload.meetingUrl : ""
|
|
3715
3804
|
const token = typeof payload.token === "string" ? payload.token : ""
|
|
3716
3805
|
const displayName = typeof payload.displayName === "string" ? payload.displayName : undefined
|
|
3806
|
+
// Diagnostic only. A miniapp that does not send it gets `unknown`, which is a third answer in
|
|
3807
|
+
// the comparison rather than a default that would quietly file every old build under "created".
|
|
3808
|
+
const origin = parseAcsCallOrigin(payload.origin)
|
|
3717
3809
|
if (!meetingUrl || !token) {
|
|
3718
3810
|
this.sendResult(packageName, requestId, false, undefined, {
|
|
3719
3811
|
code: MiniappErrorCode.INVALID_ARGUMENT,
|
|
@@ -3754,10 +3846,11 @@ class LocalMiniappRuntime {
|
|
|
3754
3846
|
transport: videoSource.type,
|
|
3755
3847
|
hasDisplayName: Boolean(displayName),
|
|
3756
3848
|
video: video ? `${video.width}x${video.height}@${video.fps}` : "default",
|
|
3849
|
+
origin,
|
|
3757
3850
|
})
|
|
3758
3851
|
const startedAt = Date.now()
|
|
3759
3852
|
try {
|
|
3760
|
-
const state = await this.joinSoftapMeeting(packageName, {meetingUrl, token, displayName, video})
|
|
3853
|
+
const state = await this.joinSoftapMeeting(packageName, {meetingUrl, token, displayName, video, origin})
|
|
3761
3854
|
softapTrace("meeting_join_result", {
|
|
3762
3855
|
packageName,
|
|
3763
3856
|
requestId: requestId ?? "none",
|
|
@@ -3784,13 +3877,20 @@ class LocalMiniappRuntime {
|
|
|
3784
3877
|
token,
|
|
3785
3878
|
videoSource,
|
|
3786
3879
|
displayName,
|
|
3880
|
+
origin,
|
|
3787
3881
|
...(video ? {video} : {}),
|
|
3788
3882
|
})
|
|
3789
3883
|
this.sendResult(packageName, requestId, true, state)
|
|
3790
3884
|
} catch (err) {
|
|
3885
|
+
// A SoftAP failure already knows what it was and where. Flattening every one of them to
|
|
3886
|
+
// INTERNAL is what left the miniapp unable to tell "your Wi-Fi is off" — a condition with a
|
|
3887
|
+
// two-tap fix — from an ACS timeout, so it showed the same shrug for both. The step comes
|
|
3888
|
+
// along too: the same code at `hotspot` and at `scopedJoin` are different stories.
|
|
3889
|
+
const softap = err instanceof SoftapCallError ? err : undefined
|
|
3791
3890
|
this.sendResult(packageName, requestId, false, undefined, {
|
|
3792
|
-
code: MiniappErrorCode.INTERNAL,
|
|
3891
|
+
code: softap?.code ?? MiniappErrorCode.INTERNAL,
|
|
3793
3892
|
message: err instanceof Error ? err.message : "ACS meeting join failed",
|
|
3893
|
+
...(softap ? {step: softap.step} : {}),
|
|
3794
3894
|
})
|
|
3795
3895
|
}
|
|
3796
3896
|
}
|
|
@@ -3806,7 +3906,7 @@ class LocalMiniappRuntime {
|
|
|
3806
3906
|
*/
|
|
3807
3907
|
private async joinSoftapMeeting(
|
|
3808
3908
|
packageName: string,
|
|
3809
|
-
args: {meetingUrl: string; token: string; displayName?: string; video?: AcsOutgoingVideo},
|
|
3909
|
+
args: {meetingUrl: string; token: string; displayName?: string; video?: AcsOutgoingVideo; origin?: AcsCallOrigin},
|
|
3810
3910
|
): Promise<MeetingState> {
|
|
3811
3911
|
// Reserve before awaiting retirement: a second Start or Cancel must see this request,
|
|
3812
3912
|
// including while it is waiting for its predecessor's native cleanup.
|
|
@@ -3944,7 +4044,7 @@ class LocalMiniappRuntime {
|
|
|
3944
4044
|
private async runSoftapAttempt(
|
|
3945
4045
|
attempt: SoftapAttempt,
|
|
3946
4046
|
previous: SoftapAttempt | null,
|
|
3947
|
-
args: {meetingUrl: string; token: string; displayName?: string; video?: AcsOutgoingVideo},
|
|
4047
|
+
args: {meetingUrl: string; token: string; displayName?: string; video?: AcsOutgoingVideo; origin?: AcsCallOrigin},
|
|
3948
4048
|
): Promise<MeetingState> {
|
|
3949
4049
|
const packageName = attempt.packageName
|
|
3950
4050
|
if (previous) {
|
|
@@ -4000,15 +4100,22 @@ class LocalMiniappRuntime {
|
|
|
4000
4100
|
meetingUrl: args.meetingUrl,
|
|
4001
4101
|
token: args.token,
|
|
4002
4102
|
displayName: args.displayName,
|
|
4103
|
+
video: args.video,
|
|
4003
4104
|
awaitFirstFrame: () => acsMeetingService.waitForFirstFrame(SOFTAP_FIRST_FRAME_MS),
|
|
4004
4105
|
subsystems: {
|
|
4005
4106
|
setHotspotState: async (enabled) => {
|
|
4006
|
-
const status = await
|
|
4107
|
+
const status = await this.setGlassesHotspotState(enabled)
|
|
4007
4108
|
if (status.state === "enabled") {
|
|
4008
4109
|
return {state: status.state, ssid: status.ssid, password: status.password, localIp: status.localIp}
|
|
4009
4110
|
}
|
|
4010
4111
|
return {state: status.state}
|
|
4011
4112
|
},
|
|
4113
|
+
isWifiEnabled: async () => {
|
|
4114
|
+
// A host that cannot answer reports "on" so the preflight stands aside; the native
|
|
4115
|
+
// throw at scopedJoin is what covers those platforms.
|
|
4116
|
+
const enabled = await acsMeetingService.isWifiEnabled()
|
|
4117
|
+
return enabled ?? true
|
|
4118
|
+
},
|
|
4012
4119
|
joinScopedNetwork: (ssid, passphrase, gateway) =>
|
|
4013
4120
|
acsMeetingService.joinScopedNetwork(ssid, passphrase, gateway),
|
|
4014
4121
|
leaveScopedNetwork: () => acsMeetingService.leaveScopedNetwork(),
|
|
@@ -4021,6 +4128,18 @@ class LocalMiniappRuntime {
|
|
|
4021
4128
|
awaitValidatedDefaultNetwork: () => acsMeetingService.awaitValidatedDefaultNetwork(),
|
|
4022
4129
|
onGlassesStreamStatus: (listener) => {
|
|
4023
4130
|
const sub = BluetoothSdk.addListener("stream_status", (event) => {
|
|
4131
|
+
// The glasses narrate over BLE, which is slow enough that a status from the previous
|
|
4132
|
+
// publish can land after this attempt was retired — and it is narration, so it would
|
|
4133
|
+
// be written straight onto the new call's checklist as if it were happening now.
|
|
4134
|
+
if (this.softapAttempt !== attempt || attempt.cancelled) {
|
|
4135
|
+
softapTrace("softap_stale_callback", {
|
|
4136
|
+
source: "glasses_stream_status",
|
|
4137
|
+
attempt: attempt.id,
|
|
4138
|
+
status: event.status,
|
|
4139
|
+
current: this.softapAttempt?.id ?? "none",
|
|
4140
|
+
})
|
|
4141
|
+
return
|
|
4142
|
+
}
|
|
4024
4143
|
listener({
|
|
4025
4144
|
status: event.status,
|
|
4026
4145
|
streamId: event.streamId,
|
|
@@ -4036,6 +4155,7 @@ class LocalMiniappRuntime {
|
|
|
4036
4155
|
token: options.token,
|
|
4037
4156
|
videoSource: options.videoSource,
|
|
4038
4157
|
displayName: options.displayName,
|
|
4158
|
+
origin: args.origin,
|
|
4039
4159
|
...(args.video ? {video: args.video} : {}),
|
|
4040
4160
|
}),
|
|
4041
4161
|
leaveMeeting: (pkg) => acsMeetingService.leave(pkg),
|
|
@@ -4263,8 +4383,10 @@ class LocalMiniappRuntime {
|
|
|
4263
4383
|
// Start Call's `teams:create` return `Request failed (503)` — Cloudflare, no internet, not a
|
|
4264
4384
|
// Teams configuration problem.
|
|
4265
4385
|
const defaultNetworkStartedAt = Date.now()
|
|
4386
|
+
let defaultNetworkOk = false
|
|
4266
4387
|
try {
|
|
4267
4388
|
const network = await acsMeetingService.awaitDefaultNetworkAfterHotspot()
|
|
4389
|
+
defaultNetworkOk = true
|
|
4268
4390
|
softapTrace("softap_teardown_step", {
|
|
4269
4391
|
attempt: attempt.id,
|
|
4270
4392
|
step: "awaitValidatedDefaultNetwork",
|
|
@@ -4288,9 +4410,12 @@ class LocalMiniappRuntime {
|
|
|
4288
4410
|
reason: error instanceof Error ? error.message : String(error),
|
|
4289
4411
|
})
|
|
4290
4412
|
}
|
|
4413
|
+
const defaultNetworkMs = Date.now() - defaultNetworkStartedAt
|
|
4291
4414
|
const acsLeaveStartedAt = Date.now()
|
|
4415
|
+
let acsLeaveOk = false
|
|
4292
4416
|
try {
|
|
4293
4417
|
const outcome = await acsMeetingService.leaveAndAwait(attempt.packageName)
|
|
4418
|
+
acsLeaveOk = outcome.completed
|
|
4294
4419
|
softapTrace("softap_teardown_step", {
|
|
4295
4420
|
attempt: attempt.id,
|
|
4296
4421
|
step: "acsLeaveAndAwait",
|
|
@@ -4324,6 +4449,12 @@ class LocalMiniappRuntime {
|
|
|
4324
4449
|
step: "scopedLostListener",
|
|
4325
4450
|
outcome: hadScopedLostListener ? "ok" : "not taken",
|
|
4326
4451
|
})
|
|
4452
|
+
failures.push(
|
|
4453
|
+
...(await this.settleSoftapTeardown(attempt, {
|
|
4454
|
+
acsLeave: {ok: acsLeaveOk, durationMs: Date.now() - acsLeaveStartedAt},
|
|
4455
|
+
defaultNetwork: {ok: defaultNetworkOk, durationMs: defaultNetworkMs},
|
|
4456
|
+
})),
|
|
4457
|
+
)
|
|
4327
4458
|
if (failures.length > 0) {
|
|
4328
4459
|
this.softapCleanupError = failures.join("; ")
|
|
4329
4460
|
console.warn("[LocalMiniappRuntime] softap_cleanup_failed", {attempt: attempt.id, failures})
|
|
@@ -4342,6 +4473,136 @@ class LocalMiniappRuntime {
|
|
|
4342
4473
|
if (endFailure) throw endFailure
|
|
4343
4474
|
}
|
|
4344
4475
|
|
|
4476
|
+
private setGlassesHotspotState(enabled: boolean) {
|
|
4477
|
+
const run = this.glassesHotspotCommand.then(
|
|
4478
|
+
() => BluetoothSdk.setHotspotState(enabled),
|
|
4479
|
+
() => BluetoothSdk.setHotspotState(enabled),
|
|
4480
|
+
)
|
|
4481
|
+
this.glassesHotspotCommand = run.then(
|
|
4482
|
+
() => undefined,
|
|
4483
|
+
() => undefined,
|
|
4484
|
+
)
|
|
4485
|
+
return run
|
|
4486
|
+
}
|
|
4487
|
+
|
|
4488
|
+
/**
|
|
4489
|
+
* The last two gates, and the point where a timeout stops being treated as consent.
|
|
4490
|
+
*
|
|
4491
|
+
* Four things have to be true before the next call may start: this device is out of the ACS
|
|
4492
|
+
* call, the WHIP listener's port is released, the glasses have acknowledged their hotspot off,
|
|
4493
|
+
* and the phone has a validated default route again. The first and last already ran; this adds
|
|
4494
|
+
* the two that were never waited for at all, and the ones that produced the stop-then-start
|
|
4495
|
+
* failure — the listener was still inside its 3s tombstone and the glasses AP was still up.
|
|
4496
|
+
*
|
|
4497
|
+
* A bound that expires is not success. It means the resource is still held by a call nobody is
|
|
4498
|
+
* in any more, so it is taken by force and re-verified. Only if *that* fails does the next join
|
|
4499
|
+
* get refused, which is a worse outcome than a slow teardown and a better one than two calls
|
|
4500
|
+
* sharing a port.
|
|
4501
|
+
*
|
|
4502
|
+
* @returns failures to record; a non-empty list refuses the next join.
|
|
4503
|
+
*/
|
|
4504
|
+
private async settleSoftapTeardown(
|
|
4505
|
+
attempt: SoftapAttempt,
|
|
4506
|
+
earlier: {acsLeave: {ok: boolean; durationMs: number}; defaultNetwork: {ok: boolean; durationMs: number}},
|
|
4507
|
+
): Promise<string[]> {
|
|
4508
|
+
const ingestStartedAt = Date.now()
|
|
4509
|
+
let ingestClosed = false
|
|
4510
|
+
try {
|
|
4511
|
+
ingestClosed = await acsMeetingService.awaitIngestClosed(SOFTAP_INGEST_CLOSE_WAIT_MS)
|
|
4512
|
+
} catch (error) {
|
|
4513
|
+
softapTraceFailure("softap_teardown_step", {
|
|
4514
|
+
attempt: attempt.id,
|
|
4515
|
+
step: "awaitIngestClosed",
|
|
4516
|
+
outcome: "failed",
|
|
4517
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
4518
|
+
})
|
|
4519
|
+
}
|
|
4520
|
+
const ingestMs = Date.now() - ingestStartedAt
|
|
4521
|
+
|
|
4522
|
+
const hotspotStartedAt = Date.now()
|
|
4523
|
+
let hotspotOff = false
|
|
4524
|
+
let hotspotReason = ""
|
|
4525
|
+
try {
|
|
4526
|
+
// Idempotent by design — the command asks for "disabled" rather than toggling — so sending
|
|
4527
|
+
// it again after the transport's own stop costs nothing and is the only way to get an ack
|
|
4528
|
+
// that is bound to a deadline we chose.
|
|
4529
|
+
const status = await withTimeout(
|
|
4530
|
+
this.setGlassesHotspotState(false),
|
|
4531
|
+
SOFTAP_HOTSPOT_OFF_ACK_MS,
|
|
4532
|
+
"hotspot_off_ack_timeout",
|
|
4533
|
+
)
|
|
4534
|
+
hotspotOff = status.state === "disabled"
|
|
4535
|
+
hotspotReason = status.state
|
|
4536
|
+
} catch (error) {
|
|
4537
|
+
hotspotReason = error instanceof Error ? error.message : String(error)
|
|
4538
|
+
}
|
|
4539
|
+
const hotspotMs = Date.now() - hotspotStartedAt
|
|
4540
|
+
|
|
4541
|
+
softapTrace("softap_teardown_settled", {
|
|
4542
|
+
attempt: attempt.id,
|
|
4543
|
+
acsLeaveMs: earlier.acsLeave.durationMs,
|
|
4544
|
+
acsLeaveOk: earlier.acsLeave.ok,
|
|
4545
|
+
ingestClosedMs: ingestMs,
|
|
4546
|
+
ingestClosed,
|
|
4547
|
+
hotspotOffMs: hotspotMs,
|
|
4548
|
+
hotspotOff,
|
|
4549
|
+
hotspotDetail: hotspotReason,
|
|
4550
|
+
defaultNetworkMs: earlier.defaultNetwork.durationMs,
|
|
4551
|
+
defaultNetworkOk: earlier.defaultNetwork.ok,
|
|
4552
|
+
})
|
|
4553
|
+
|
|
4554
|
+
if (ingestClosed && hotspotOff) return []
|
|
4555
|
+
return this.forceSoftapCleanup(attempt, {ingestClosed, hotspotOff, hotspotReason})
|
|
4556
|
+
}
|
|
4557
|
+
|
|
4558
|
+
/**
|
|
4559
|
+
* Take back what the bounded waits could not confirm was released.
|
|
4560
|
+
*
|
|
4561
|
+
* Runs only after a gate timed out, and re-verifies rather than assuming: the whole reason the
|
|
4562
|
+
* barrier exists is that "it has probably finished by now" is what broke restarts.
|
|
4563
|
+
*/
|
|
4564
|
+
private async forceSoftapCleanup(
|
|
4565
|
+
attempt: SoftapAttempt,
|
|
4566
|
+
gates: {ingestClosed: boolean; hotspotOff: boolean; hotspotReason: string},
|
|
4567
|
+
): Promise<string[]> {
|
|
4568
|
+
const failures: string[] = []
|
|
4569
|
+
softapTraceFailure("softap_teardown_forced", {
|
|
4570
|
+
attempt: attempt.id,
|
|
4571
|
+
ingestClosed: gates.ingestClosed,
|
|
4572
|
+
hotspotOff: gates.hotspotOff,
|
|
4573
|
+
hotspotDetail: gates.hotspotReason,
|
|
4574
|
+
})
|
|
4575
|
+
if (!gates.ingestClosed) {
|
|
4576
|
+
try {
|
|
4577
|
+
await acsMeetingService.forceCloseIngest()
|
|
4578
|
+
// Re-asked, not assumed: `forceCloseIngest` skips the tombstone, so the latch should be
|
|
4579
|
+
// open immediately. A short bound here is enough and a failure is real.
|
|
4580
|
+
const closed = await acsMeetingService.awaitIngestClosed(SOFTAP_FORCED_VERIFY_MS)
|
|
4581
|
+
if (!closed) failures.push("the video receiver did not release its port")
|
|
4582
|
+
} catch (error) {
|
|
4583
|
+
failures.push(`forced ingest close: ${error instanceof Error ? error.message : String(error)}`)
|
|
4584
|
+
}
|
|
4585
|
+
}
|
|
4586
|
+
if (!gates.hotspotOff) {
|
|
4587
|
+
try {
|
|
4588
|
+
const status = await withTimeout(
|
|
4589
|
+
this.setGlassesHotspotState(false),
|
|
4590
|
+
SOFTAP_HOTSPOT_OFF_ACK_MS,
|
|
4591
|
+
"hotspot_off_ack_timeout",
|
|
4592
|
+
)
|
|
4593
|
+
if (status.state !== "disabled") failures.push(`the glasses hotspot is still ${status.state}`)
|
|
4594
|
+
} catch (error) {
|
|
4595
|
+
failures.push(`glasses hotspot off: ${error instanceof Error ? error.message : String(error)}`)
|
|
4596
|
+
}
|
|
4597
|
+
}
|
|
4598
|
+
softapTrace("softap_teardown_forced_done", {
|
|
4599
|
+
attempt: attempt.id,
|
|
4600
|
+
recovered: failures.length === 0,
|
|
4601
|
+
failures: failures.join("; "),
|
|
4602
|
+
})
|
|
4603
|
+
return failures
|
|
4604
|
+
}
|
|
4605
|
+
|
|
4345
4606
|
private async handleMeetingLeave(packageName: string, requestId?: string): Promise<void> {
|
|
4346
4607
|
const startedAt = Date.now()
|
|
4347
4608
|
softapTrace("meeting_leave_request", {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* etc.) instead of waiting 30s for cloud's timeout.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import {parsePhotoCompression, type PhotoCompression} from "@mentra/cloud-protocol/photo-compression"
|
|
21
22
|
import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
|
|
22
23
|
import type {PhotoSize, PhotoTransferMethod} from "@mentra/bluetooth-sdk/internal"
|
|
23
24
|
import {cloudClientService} from "./CloudClientService"
|
|
@@ -51,7 +52,7 @@ export interface PhotoOpts {
|
|
|
51
52
|
mode?: "photo" | "text"
|
|
52
53
|
/** Select direct-only, phone-relayed BLE, or the default Wi-Fi/BLE fallback policy. */
|
|
53
54
|
transferMethod?: PhotoTransferMethod
|
|
54
|
-
compress?:
|
|
55
|
+
compress?: PhotoCompression
|
|
55
56
|
sound?: boolean
|
|
56
57
|
saveToGallery?: boolean
|
|
57
58
|
exposureTimeNs?: number
|
|
@@ -120,7 +121,7 @@ interface ActiveRequest {
|
|
|
120
121
|
*/
|
|
121
122
|
export const CAPTURE_PIPELINE_TIMEOUT_MS = 45_000
|
|
122
123
|
export const CAMERA_WARM_UP_DEFAULT_DURATION_MS = 15_000
|
|
123
|
-
export const CAMERA_WARM_UP_MAX_DURATION_MS =
|
|
124
|
+
export const CAMERA_WARM_UP_MAX_DURATION_MS = 300_000
|
|
124
125
|
|
|
125
126
|
let bleRequestCounter = 0
|
|
126
127
|
|
|
@@ -131,12 +132,6 @@ function mintBleRequestId(): string {
|
|
|
131
132
|
return bleRequestCounter.toString(16).padStart(4, "0")
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
function toNativeCompression(compress: PhotoOpts["compress"]): "none" | "medium" | "heavy" {
|
|
135
|
-
if (compress === "high") return "heavy"
|
|
136
|
-
if (compress === "low" || compress === "medium") return "medium"
|
|
137
|
-
return "none"
|
|
138
|
-
}
|
|
139
|
-
|
|
140
135
|
export class PhonePhotoCoordinator {
|
|
141
136
|
// Cloud requestId → in-flight slot. The gated photo_response listener
|
|
142
137
|
// (DeviceEventRouter) resolves short BLE ids via bleIdToCloud before calling
|
|
@@ -159,6 +154,7 @@ export class PhonePhotoCoordinator {
|
|
|
159
154
|
}
|
|
160
155
|
|
|
161
156
|
async takePhoto(packageName: string, opts: PhotoOpts): Promise<PhotoTaken> {
|
|
157
|
+
const compress = parsePhotoCompression(opts.compress)
|
|
162
158
|
const transferMethod = parsePhotoTransferMethod(opts.transferMethod)
|
|
163
159
|
|
|
164
160
|
// Pre-check: if glasses aren't even connected, the BLE photo command
|
|
@@ -176,8 +172,8 @@ export class PhonePhotoCoordinator {
|
|
|
176
172
|
throw new PhotoError("GLASSES_NOT_CONNECTED", "Glasses are not connected", "command", "ble")
|
|
177
173
|
}
|
|
178
174
|
|
|
179
|
-
// Text-mode sensor resolution is owned by ASG constants;
|
|
180
|
-
//
|
|
175
|
+
// Text-mode sensor resolution is owned by ASG constants; the glasses ignore the public
|
|
176
|
+
// size when mode=text.
|
|
181
177
|
const captureSize = opts.size ?? "medium"
|
|
182
178
|
|
|
183
179
|
// 1) Presign via the cloud-v2 managed-photo service. Local miniapps use
|
|
@@ -190,9 +186,7 @@ export class PhonePhotoCoordinator {
|
|
|
190
186
|
const flowStarted = performance.now()
|
|
191
187
|
try {
|
|
192
188
|
const presignStarted = performance.now()
|
|
193
|
-
const r = await cloudClientService.startManagedPhoto(
|
|
194
|
-
size: opts.mode === "text" ? "max" : captureSize,
|
|
195
|
-
})
|
|
189
|
+
const r = await cloudClientService.startManagedPhoto()
|
|
196
190
|
const presignMs = Math.round(performance.now() - presignStarted)
|
|
197
191
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
198
192
|
console.debug(
|
|
@@ -264,7 +258,7 @@ export class PhonePhotoCoordinator {
|
|
|
264
258
|
webhookUrl: uploadUrl,
|
|
265
259
|
authToken: null,
|
|
266
260
|
...(isLoopbackUpload ? {transferMethod: "ble" as const} : transferMethod ? {transferMethod} : {}),
|
|
267
|
-
compress
|
|
261
|
+
compress,
|
|
268
262
|
save: opts.saveToGallery ?? false,
|
|
269
263
|
sound: opts.sound ?? true,
|
|
270
264
|
exposureTimeNs: opts.exposureTimeNs ?? null,
|
|
@@ -277,6 +271,16 @@ export class PhonePhotoCoordinator {
|
|
|
277
271
|
...(opts.mfnr != null ? {mfnr: opts.mfnr} : {}),
|
|
278
272
|
ispDigitalGain: opts.ispDigitalGain,
|
|
279
273
|
ispAnalogGain: opts.ispAnalogGain,
|
|
274
|
+
}).then((response) => {
|
|
275
|
+
// Native success is emitted only after the direct/phone-relayed upload
|
|
276
|
+
// completes. It is an independent completion signal when photo.ready
|
|
277
|
+
// cannot reach this phone (for example, a cross-pod WS routing gap).
|
|
278
|
+
// Older native bridges may resolve at dispatch with no response: those
|
|
279
|
+
// must still wait for the cloud push, never return an unuploaded image.
|
|
280
|
+
if (response?.state !== "success" || response.requestId !== bleRequestId) return
|
|
281
|
+
const e = this.activeRequests.get(requestId)
|
|
282
|
+
if (!e || e.abort.signal.aborted) return
|
|
283
|
+
e.resolve({photoUrl: readUrl, mimeType: "image/jpeg", size: -1})
|
|
280
284
|
}).catch((err) => {
|
|
281
285
|
const e = this.activeRequests.get(requestId)
|
|
282
286
|
if (!e) return
|