@mentra/engine 3.2.0-dev.235 → 3.2.0-dev.247

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 (44) 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 +70 -0
  4. package/build/services/AcsMeetingService.d.ts.map +1 -1
  5. package/build/services/AcsMeetingService.js +97 -0
  6. package/build/services/AcsMeetingService.js.map +1 -1
  7. package/build/services/AppRegistry.d.ts +2 -12
  8. package/build/services/AppRegistry.d.ts.map +1 -1
  9. package/build/services/AppRegistry.js +2 -37
  10. package/build/services/AppRegistry.js.map +1 -1
  11. package/build/services/CloudClientService.d.ts +1 -1
  12. package/build/services/CloudClientService.d.ts.map +1 -1
  13. package/build/services/CloudClientService.js +2 -2
  14. package/build/services/CloudClientService.js.map +1 -1
  15. package/build/services/LocalMiniappRuntime.d.ts +51 -0
  16. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  17. package/build/services/LocalMiniappRuntime.js +228 -6
  18. package/build/services/LocalMiniappRuntime.js.map +1 -1
  19. package/build/services/PhoneNotificationsSync.d.ts +1 -1
  20. package/build/services/PhonePhotoCoordinator.d.ts +3 -2
  21. package/build/services/PhonePhotoCoordinator.d.ts.map +1 -1
  22. package/build/services/PhonePhotoCoordinator.js +19 -14
  23. package/build/services/PhonePhotoCoordinator.js.map +1 -1
  24. package/build/services/SoftapCallTransport.d.ts +64 -0
  25. package/build/services/SoftapCallTransport.d.ts.map +1 -1
  26. package/build/services/SoftapCallTransport.js +103 -3
  27. package/build/services/SoftapCallTransport.js.map +1 -1
  28. package/build/services/manifestPermissions.d.ts +13 -0
  29. package/build/services/manifestPermissions.d.ts.map +1 -0
  30. package/build/services/manifestPermissions.js +42 -0
  31. package/build/services/manifestPermissions.js.map +1 -0
  32. package/build/types/applet.d.ts +13 -1
  33. package/build/types/applet.d.ts.map +1 -1
  34. package/build/types/applet.js.map +1 -1
  35. package/package.json +8 -8
  36. package/src/generated/releaseMetadata.ts +5 -5
  37. package/src/services/AcsMeetingService.ts +138 -0
  38. package/src/services/AppRegistry.ts +3 -40
  39. package/src/services/CloudClientService.ts +2 -2
  40. package/src/services/LocalMiniappRuntime.ts +268 -7
  41. package/src/services/PhonePhotoCoordinator.ts +18 -14
  42. package/src/services/SoftapCallTransport.ts +151 -16
  43. package/src/services/manifestPermissions.ts +44 -0
  44. 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 as "none" | "low" | "medium" | "high" | undefined,
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 BluetoothSdk.setHotspotState(enabled)
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?: "none" | "low" | "medium" | "high"
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 = 60_000
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; keep cloud metadata on a stable
180
- // high-capacity tier and let the glasses ignore the public size when mode=text.
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: toNativeCompression(opts.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