@mentra/engine 3.1.0-dev.9 → 3.1.0-dev.94

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 (109) hide show
  1. package/README.md +23 -5
  2. package/build/bluetooth-sdk/ota-transport.d.ts +2 -0
  3. package/build/bluetooth-sdk/ota-transport.d.ts.map +1 -0
  4. package/build/bluetooth-sdk/ota-transport.js +2 -0
  5. package/build/bluetooth-sdk/ota-transport.js.map +1 -0
  6. package/build/engine.d.ts +5 -2
  7. package/build/engine.d.ts.map +1 -1
  8. package/build/facades/ota.d.ts +11 -6
  9. package/build/facades/ota.d.ts.map +1 -1
  10. package/build/facades/ota.js +9 -5
  11. package/build/facades/ota.js.map +1 -1
  12. package/build/facades/pairing.d.ts +4 -0
  13. package/build/facades/pairing.d.ts.map +1 -1
  14. package/build/facades/pairing.js +81 -7
  15. package/build/facades/pairing.js.map +1 -1
  16. package/build/generated/releaseMetadata.js +5 -5
  17. package/build/generated/releaseMetadata.js.map +1 -1
  18. package/build/index.d.ts +2 -2
  19. package/build/index.d.ts.map +1 -1
  20. package/build/index.js +1 -1
  21. package/build/index.js.map +1 -1
  22. package/build/react/MentraLiveOtaFlow.d.ts +8 -2
  23. package/build/react/MentraLiveOtaFlow.d.ts.map +1 -1
  24. package/build/react/MentraLiveOtaFlow.js +256 -354
  25. package/build/react/MentraLiveOtaFlow.js.map +1 -1
  26. package/build/react/index.d.ts +1 -0
  27. package/build/react/index.d.ts.map +1 -1
  28. package/build/react/index.js +1 -0
  29. package/build/react/index.js.map +1 -1
  30. package/build/react/useMentraLiveOta.d.ts +84 -0
  31. package/build/react/useMentraLiveOta.d.ts.map +1 -0
  32. package/build/react/useMentraLiveOta.js +627 -0
  33. package/build/react/useMentraLiveOta.js.map +1 -0
  34. package/build/services/BlobStore.d.ts +2 -0
  35. package/build/services/BlobStore.d.ts.map +1 -1
  36. package/build/services/BlobStore.js +63 -10
  37. package/build/services/BlobStore.js.map +1 -1
  38. package/build/services/GlassesStatusProjection.d.ts +15 -2
  39. package/build/services/GlassesStatusProjection.d.ts.map +1 -1
  40. package/build/services/GlassesStatusProjection.js +3 -3
  41. package/build/services/GlassesStatusProjection.js.map +1 -1
  42. package/build/services/HotspotOtaTransport.d.ts.map +1 -1
  43. package/build/services/HotspotOtaTransport.js +8 -7
  44. package/build/services/HotspotOtaTransport.js.map +1 -1
  45. package/build/services/OtaAutoChain.d.ts +9 -2
  46. package/build/services/OtaAutoChain.d.ts.map +1 -1
  47. package/build/services/OtaAutoChain.js +10 -2
  48. package/build/services/OtaAutoChain.js.map +1 -1
  49. package/build/services/OtaInstallCoordinator.d.ts +26 -3
  50. package/build/services/OtaInstallCoordinator.d.ts.map +1 -1
  51. package/build/services/OtaInstallCoordinator.js +35 -13
  52. package/build/services/OtaInstallCoordinator.js.map +1 -1
  53. package/build/services/OtaService.d.ts.map +1 -1
  54. package/build/services/OtaService.js +1 -1
  55. package/build/services/OtaService.js.map +1 -1
  56. package/build/services/OtaUpdateCheckService.d.ts +4 -1
  57. package/build/services/OtaUpdateCheckService.d.ts.map +1 -1
  58. package/build/services/OtaUpdateCheckService.js +21 -5
  59. package/build/services/OtaUpdateCheckService.js.map +1 -1
  60. package/build/services/asg/localNetworkTransport.d.ts.map +1 -1
  61. package/build/services/asg/localNetworkTransport.js +13 -12
  62. package/build/services/asg/localNetworkTransport.js.map +1 -1
  63. package/build/services/glassesClockSync.js +1 -1
  64. package/build/services/glassesClockSync.js.map +1 -1
  65. package/build/services/otaInstallPolicy.d.ts +4 -0
  66. package/build/services/otaInstallPolicy.d.ts.map +1 -1
  67. package/build/services/otaInstallPolicy.js +4 -0
  68. package/build/services/otaInstallPolicy.js.map +1 -1
  69. package/build/services/otaReleaseVersion.d.ts +10 -0
  70. package/build/services/otaReleaseVersion.d.ts.map +1 -0
  71. package/build/services/otaReleaseVersion.js +14 -0
  72. package/build/services/otaReleaseVersion.js.map +1 -0
  73. package/build/stores/appVisibility.d.ts +3 -0
  74. package/build/stores/appVisibility.d.ts.map +1 -0
  75. package/build/stores/appVisibility.js +3 -0
  76. package/build/stores/appVisibility.js.map +1 -0
  77. package/build/stores/apps.d.ts.map +1 -1
  78. package/build/stores/apps.js +7 -1
  79. package/build/stores/apps.js.map +1 -1
  80. package/build/stores/core.d.ts +3 -3
  81. package/build/stores/core.d.ts.map +1 -1
  82. package/build/stores/core.js.map +1 -1
  83. package/build/stores/glasses.d.ts +10 -4
  84. package/build/stores/glasses.d.ts.map +1 -1
  85. package/build/stores/glasses.js.map +1 -1
  86. package/package.json +13 -6
  87. package/src/bluetooth-sdk/ota-transport.ts +1 -0
  88. package/src/facades/ota.ts +19 -6
  89. package/src/facades/pairing.ts +88 -12
  90. package/src/generated/releaseMetadata.ts +5 -5
  91. package/src/index.ts +2 -0
  92. package/src/react/MentraLiveOtaFlow.tsx +378 -480
  93. package/src/react/index.ts +14 -0
  94. package/src/react/useMentraLiveOta.ts +793 -0
  95. package/src/services/BlobStore.ts +57 -10
  96. package/src/services/GlassesStatusProjection.ts +7 -6
  97. package/src/services/HotspotOtaTransport.ts +8 -7
  98. package/src/services/OtaAutoChain.ts +26 -2
  99. package/src/services/OtaInstallCoordinator.ts +37 -13
  100. package/src/services/OtaService.ts +1 -2
  101. package/src/services/OtaUpdateCheckService.ts +26 -8
  102. package/src/services/asg/localNetworkTransport.ts +20 -19
  103. package/src/services/glassesClockSync.ts +1 -1
  104. package/src/services/otaInstallPolicy.ts +4 -0
  105. package/src/services/otaReleaseVersion.ts +22 -0
  106. package/src/stores/appVisibility.ts +3 -0
  107. package/src/stores/apps.ts +7 -1
  108. package/src/stores/core.ts +4 -4
  109. package/src/stores/glasses.ts +13 -6
@@ -40,8 +40,15 @@ const LOG_TAG = "LocalMiniappRuntime"
40
40
  /** Per-app blob quota: 1 GB. */
41
41
  export const BLOB_QUOTA_BYTES = 1024 * 1024 * 1024
42
42
  const ROOT_DIR_NAME = "mentra_blobs"
43
- /** Cache dir root holding short-lived, properly-named copies handed to the OS share sheet (one unique subdir per share). */
43
+ /** Cache dir root holding short-lived, properly-named copies handed to the OS share sheet (one subdir per stored blob). */
44
44
  const SHARE_DIR_NAME = "mentra_blob_share"
45
+ /**
46
+ * Keep prepared share handoffs alive long enough for recipient apps to read
47
+ * them asynchronously (for example, an email composer attaching the file).
48
+ * Stale copies are collected before the next share; the OS may evict cache
49
+ * files sooner under storage pressure.
50
+ */
51
+ const SHARE_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000
45
52
  const META_KEY_ROOT = "mentraos_blobmeta_"
46
53
  /** Cap md5 computation so we don't block the JS thread hashing a huge file. */
47
54
  const MD5_MAX_BYTES = 50 * 1024 * 1024
@@ -598,6 +605,32 @@ export class BlobStore {
598
605
 
599
606
  // ---- share ---------------------------------------------------------------
600
607
 
608
+ /** Best-effort GC for prepared share handoffs retained in the OS cache. */
609
+ private cleanupShareCache(now = Date.now()): void {
610
+ try {
611
+ const root = new Directory(Paths.cache, SHARE_DIR_NAME)
612
+ if (!root.exists) return
613
+ const cutoff = now - SHARE_CACHE_MAX_AGE_MS
614
+ for (const entry of root.list()) {
615
+ if (!(entry instanceof Directory)) continue
616
+ try {
617
+ const info = entry.info()
618
+ // modificationTime is available on both platforms; creationTime can
619
+ // be absent on Android versions before API 26. The directory id
620
+ // begins with Date.now().toString(36), which is a final fallback.
621
+ const encoded = entry.name.split("-", 1)[0]
622
+ const encodedTime = /^[0-9a-z]+$/.test(encoded) ? Number.parseInt(encoded, 36) : Number.NaN
623
+ const timestamp = info.modificationTime ?? info.creationTime ?? encodedTime
624
+ if (Number.isFinite(timestamp) && timestamp < cutoff) entry.delete()
625
+ } catch {
626
+ // Keep scanning if one cache entry disappears or is unreadable.
627
+ }
628
+ }
629
+ } catch {
630
+ // Cache cleanup must never prevent a share.
631
+ }
632
+ }
633
+
601
634
  async handleShare(packageName: string, payload: Record<string, unknown>, requestId?: string): Promise<void> {
602
635
  const key = payload.key as string
603
636
  const meta = this.readMeta(packageName, key)
@@ -615,23 +648,33 @@ export class BlobStore {
615
648
  // applies to base64/data payloads, not file:// URLs), so sharing `file.uri`
616
649
  // directly would hand the recipient a machine-named file. Copy to a temp
617
650
  // file that carries the real display name + extension, share that, then
618
- // clean it up.
651
+ // clean it up after the recipient has had time to consume it.
619
652
  const shareName = shareFileName(meta)
620
- // Copy into a per-share unique subdir so two concurrent shares that resolve
621
- // to the same display name can't clobber each other's temp file while
622
- // Share.open still references it. The file keeps `shareName` as its basename
623
- // so the OS share sheet shows the right name + extension.
653
+ // Key the cache directory by the immutable stored filename. Repeated or
654
+ // concurrent shares of the same blob can safely reference the same bytes,
655
+ // avoiding an extra full-sized cache copy for every share. Different blobs
656
+ // still get isolated directories even when they use the same display name.
657
+ // The file keeps `shareName` as its basename so the OS share sheet shows the
658
+ // right name + extension.
624
659
  let tempDir: Directory | null = null
660
+ let cacheReady = false
625
661
  try {
626
- tempDir = new Directory(Paths.cache, SHARE_DIR_NAME, this.makeId())
627
- tempDir.create({intermediates: true})
662
+ this.cleanupShareCache()
663
+ tempDir = new Directory(Paths.cache, SHARE_DIR_NAME, sanitizeSegment(meta.fileName))
664
+ if (!tempDir.exists) tempDir.create({intermediates: true})
628
665
  const temp = new File(tempDir, shareName)
629
- file.copy(temp)
666
+ if (!temp.exists || temp.size !== meta.bytes) {
667
+ if (temp.exists) temp.delete()
668
+ file.copy(temp)
669
+ }
670
+ cacheReady = true
630
671
  await Share.open({
631
672
  url: temp.uri,
632
673
  type: meta.mimeType || OCTET,
633
674
  filename: shareName,
634
675
  })
676
+ // Share.open resolves once the OS accepts the handoff. Recipient apps may
677
+ // still read the URI asynchronously, so retain this cache copy for GC.
635
678
  this.hooks.sendResult(packageName, requestId, true, {success: true})
636
679
  } catch (error: any) {
637
680
  if (error?.message?.includes("User did not share")) {
@@ -641,7 +684,11 @@ export class BlobStore {
641
684
  this.hooks.sendResult(packageName, requestId, true, {success: false})
642
685
  }
643
686
  } finally {
644
- if (tempDir) {
687
+ // Once a reusable cache entry is ready, retain it even if this particular
688
+ // sheet is cancelled or fails: another concurrent/recent share may still
689
+ // be reading the same file. Only remove an entry whose preparation did
690
+ // not finish; ready entries are collected after the retention window.
691
+ if (tempDir && !cacheReady) {
645
692
  try {
646
693
  if (tempDir.exists) tempDir.delete()
647
694
  } catch {
@@ -11,17 +11,18 @@
11
11
  *
12
12
  * Started by `engine.start()`. Idempotent.
13
13
  */
14
- import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
15
- import type {GlassesStatus} from "@mentra/bluetooth-sdk/internal"
14
+ import BluetoothSdk, {type PublicGlassesStatus} from "@mentra/bluetooth-sdk"
16
15
  import {useCoreStore} from "../stores/core"
17
16
  import {useGlassesStore} from "../stores/glasses"
18
17
 
19
18
  let unsubs: Array<() => void> = []
20
19
  let projectionRunId = 0
21
- let glassesStatusForwarder: ((status: Partial<GlassesStatus>) => void) | null = null
20
+ let glassesStatusForwarder: ((status: Partial<PublicGlassesStatus>) => void) | null = null
22
21
  let hydrationPromise: Promise<void> | null = null
23
22
 
24
- export function startGlassesStatusProjection(forwarder?: (status: Partial<GlassesStatus>) => void): Promise<void> {
23
+ export function startGlassesStatusProjection(
24
+ forwarder?: (status: Partial<PublicGlassesStatus>) => void,
25
+ ): Promise<void> {
25
26
  if (forwarder) glassesStatusForwarder = forwarder
26
27
  if (unsubs.length) return hydrationPromise ?? Promise.resolve()
27
28
 
@@ -49,7 +50,7 @@ export function startGlassesStatusProjection(forwarder?: (status: Partial<Glasse
49
50
 
50
51
  // Bluetooth-adapter status -> core store.
51
52
  unsubs.push(
52
- BluetoothSdk.onBluetoothStatus((changed) => {
53
+ BluetoothSdk.subscribeBluetoothStatus((changed) => {
53
54
  bluetoothEventSeen = true
54
55
  useCoreStore.getState().setCoreInfo(changed)
55
56
  }),
@@ -58,7 +59,7 @@ export function startGlassesStatusProjection(forwarder?: (status: Partial<Glasse
58
59
  // Glasses status -> glasses store (+ optional full-runtime forwarding to local
59
60
  // miniapps; clear any stale OTA-available flag on disconnect).
60
61
  unsubs.push(
61
- BluetoothSdk.onGlassesStatus((changed) => {
62
+ BluetoothSdk.subscribeGlassesStatus((changed) => {
62
63
  glassesEventSeen = true
63
64
  useGlassesStore.getState().setGlassesInfo(changed)
64
65
  glassesStatusForwarder?.(changed)
@@ -1,4 +1,5 @@
1
- import BluetoothSdk, {isEnabledHotspotStatus, MentraOtaServer} from "@mentra/bluetooth-sdk/internal"
1
+ import BluetoothSdk, {isEnabledHotspotStatus} from "@mentra/bluetooth-sdk"
2
+ import {otaServer} from "@mentra/bluetooth-sdk/ota-transport"
2
3
  // One coordinator selects the native Android or iOS staging implementation at runtime.
3
4
  // eslint-disable-next-line react-native/split-platform-components
4
5
  import {PermissionsAndroid, Platform} from "react-native"
@@ -83,7 +84,7 @@ class HotspotOtaTransport {
83
84
  let localAddress = scopedAddress
84
85
  if (Platform.OS === "ios") {
85
86
  try {
86
- localAddress = await MentraOtaServer.waitForWifiAddress(hotspot.localIp, 15_000)
87
+ localAddress = await otaServer.waitForWifiAddress(hotspot.localIp, 15_000)
87
88
  } catch (error) {
88
89
  throw new HotspotOtaTransportError(
89
90
  "hotspot_join_failed",
@@ -94,10 +95,10 @@ class HotspotOtaTransport {
94
95
  const artifactPaths = Object.fromEntries(this.prepared.map((artifact) => [artifact.sha256, artifact.filePath]))
95
96
  // Start exactly one native listener to learn its selected port, then atomically replace
96
97
  // the placeholder with the immutable rewritten manifest before ota_start is sent.
97
- const server = await MentraOtaServer.startOtaServer("{}", artifactPaths, localAddress)
98
+ const server = await otaServer.start("{}", artifactPaths, localAddress)
98
99
  this.serverStarted = true
99
100
  const manifest = rewriteManifestForLocalServer(checkResult.manifestBody, this.prepared, server.baseUrl)
100
- const published = await MentraOtaServer.startOtaServer(manifest, artifactPaths, server.host)
101
+ const published = await otaServer.start(manifest, artifactPaths, server.host)
101
102
  if (published.manifestUrl !== server.manifestUrl) {
102
103
  throw new Error("Local OTA server endpoint changed while publishing the manifest")
103
104
  }
@@ -141,11 +142,11 @@ class HotspotOtaTransport {
141
142
  destination: string,
142
143
  onProgress?: (bytesWritten: number, contentLength: number) => void,
143
144
  ): Promise<{statusCode: number}> => {
144
- const subscription = MentraOtaServer.addListener("artifactDownloadProgress", (event) => {
145
+ const subscription = otaServer.onArtifactDownloadProgress((event) => {
145
146
  if (event.destination === destination) onProgress?.(event.bytesWritten, event.contentLength)
146
147
  })
147
148
  try {
148
- return await MentraOtaServer.downloadArtifact(entry.url, destination)
149
+ return await otaServer.downloadArtifact(entry.url, destination)
149
150
  } finally {
150
151
  subscription.remove()
151
152
  }
@@ -164,7 +165,7 @@ class HotspotOtaTransport {
164
165
  console.warn("[OTA_PROGRESS] glasses hotspot shutdown was not confirmed after bounded retries")
165
166
  }
166
167
  }
167
- if (this.serverStarted) await MentraOtaServer.stopOtaServer().catch(() => {})
168
+ if (this.serverStarted) await otaServer.stop().catch(() => {})
168
169
  if (this.localNetworkConnected) await localNetworkTransport.disconnect().catch(() => {})
169
170
  await cleanupArtifacts().catch(() => {})
170
171
  this.prepared = []
@@ -6,10 +6,18 @@ export const OTA_AUTO_CHAIN_RECONNECT_TIMEOUT_MS = 120_000
6
6
  type OtaAutoChainSession = {
7
7
  approvedDowngrade: boolean
8
8
  passCount: number
9
+ releaseRange: OtaAutoChainReleaseRange
9
10
  reconnectDeadline: number | null
10
11
  seenFingerprints: Set<string>
11
12
  }
12
13
 
14
+ export type OtaAutoChainReleaseRange = {
15
+ fromVersion: string | null
16
+ toVersion: string | null
17
+ /** Exact coordinated release identity for the OTA pin, absent for legacy manifests. */
18
+ releaseVersion?: string | null
19
+ }
20
+
13
21
  export type OtaAutoChainAdvanceResult =
14
22
  | {advance: true; passCount: number}
15
23
  | {advance: false; reason: "inactive" | "duplicate" | "max_passes" | "downgrade_not_approved"}
@@ -42,10 +50,15 @@ export function otaAutoChainFingerprint(result: OtaCheckCurrentGlassesResult): s
42
50
  }
43
51
 
44
52
  /** Start a chain after the user explicitly approves its first update pass. */
45
- export function beginOtaAutoChain(initialFingerprint: string, approvedDowngrade: boolean): void {
53
+ export function beginOtaAutoChain(
54
+ initialFingerprint: string,
55
+ approvedDowngrade: boolean,
56
+ releaseRange: OtaAutoChainReleaseRange,
57
+ ): void {
46
58
  session = {
47
59
  approvedDowngrade,
48
60
  passCount: 1,
61
+ releaseRange: {...releaseRange},
49
62
  reconnectDeadline: null,
50
63
  seenFingerprints: new Set([initialFingerprint]),
51
64
  }
@@ -55,6 +68,10 @@ export function isOtaAutoChainActive(): boolean {
55
68
  return session !== null
56
69
  }
57
70
 
71
+ export function otaAutoChainReleaseRange(): OtaAutoChainReleaseRange | null {
72
+ return session ? {...session.releaseRange} : null
73
+ }
74
+
58
75
  /** End the current chain. Safe to call when no chain is active. */
59
76
  export function stopOtaAutoChain(): void {
60
77
  session = null
@@ -80,7 +97,12 @@ export function clearOtaAutoChainReconnectWait(): void {
80
97
  * Admit the next pass of an active chain. Repeated offers, unexpectedly
81
98
  * destructive downgrades, and runaway chains fail closed and end automation.
82
99
  */
83
- export function tryAdvanceOtaAutoChain(fingerprint: string, isDowngrade: boolean): OtaAutoChainAdvanceResult {
100
+ export function tryAdvanceOtaAutoChain(
101
+ fingerprint: string,
102
+ isDowngrade: boolean,
103
+ targetVersion: string | null,
104
+ releaseVersion: string | null = null,
105
+ ): OtaAutoChainAdvanceResult {
84
106
  if (!session) {
85
107
  return {advance: false, reason: "inactive"}
86
108
  }
@@ -102,5 +124,7 @@ export function tryAdvanceOtaAutoChain(fingerprint: string, isDowngrade: boolean
102
124
 
103
125
  session.seenFingerprints.add(fingerprint)
104
126
  session.passCount += 1
127
+ if (targetVersion) session.releaseRange.toVersion = targetVersion
128
+ if (releaseVersion) session.releaseRange.releaseVersion = releaseVersion
105
129
  return {advance: true, passCount: session.passCount}
106
130
  }
@@ -17,8 +17,7 @@
17
17
  * state written mid-pass queues a follow-up pass — the same way a setState
18
18
  * during React effects scheduled a re-render after the current effect batch.
19
19
  */
20
- import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
21
- import type {OtaProgress, OtaStatus} from "@mentra/bluetooth-sdk"
20
+ import BluetoothSdk, {type OtaProgress, type OtaStatus} from "@mentra/bluetooth-sdk"
22
21
  import GlobalEventEmitter from "../utils/GlobalEventEmitter"
23
22
  import {isGlassesConnected, useGlassesStore} from "../stores/glasses"
24
23
  import {resolveOtaManifestUrl} from "./otaManifestUrl"
@@ -27,10 +26,12 @@ import type {OtaCheckCurrentGlassesResult} from "./OtaUpdateCheckService"
27
26
  import {deriveDisplayState, type DisplayState} from "./otaDisplayState"
28
27
  import {
29
28
  BES_CONTINUE_LOCKOUT_MS,
29
+ BES_RESTART_TIMEOUT_MS,
30
30
  DOWNLOAD_STUCK_TIMEOUT_MS,
31
31
  GLOBAL_OTA_TIMEOUT_MS,
32
32
  LEGACY_APK_COMPLETION_SETTLE_MS,
33
33
  LEGACY_BES_CONTINUE_LOCKOUT_MS,
34
+ LEGACY_BES_RESTART_TIMEOUT_MS,
34
35
  LEGACY_DOWNLOAD_STUCK_TIMEOUT_MS,
35
36
  LEGACY_GLOBAL_OTA_TIMEOUT_MS,
36
37
  LEGACY_MTK_INSTALL_TIMEOUT_MS,
@@ -170,6 +171,8 @@ export interface OtaInstallSnapshot {
170
171
  /** Pre-ota_start phone staging/join state for a hotspot attempt. */
171
172
  hotspotPhase: HotspotOtaPhase
172
173
  hotspotArtifactPercent: number | null
174
+ /** Transport selected from the checked glasses capabilities and Wi-Fi state. */
175
+ transport: "wifi" | "hotspot"
173
176
  }
174
177
 
175
178
  type OtaStartOutcome = "pending" | "acknowledged" | "rejected"
@@ -244,6 +247,7 @@ class OtaInstallCoordinator {
244
247
  private pingInterval: ReturnType<typeof setInterval> | null = null
245
248
  private queryReplyTimeout: ReturnType<typeof setTimeout> | null = null
246
249
  private continueLockoutTimer: ReturnType<typeof setTimeout> | null = null
250
+ private restartRecoveryTimeout: ReturnType<typeof setTimeout> | null = null
247
251
  private legacyApkSettleTimer: ReturnType<typeof setTimeout> | null = null
248
252
  private mtkStallDetectTimer: ReturnType<typeof setTimeout> | null = null
249
253
  private mtkSimTickTimer: ReturnType<typeof setInterval> | null = null
@@ -407,7 +411,7 @@ class OtaInstallCoordinator {
407
411
  if (this.otaStartOwnership?.outcome === "pending") {
408
412
  void this.sendOtaStartWithWatchdogs()
409
413
  } else {
410
- void BluetoothSdk.sendOtaQueryStatus().catch(() => {})
414
+ void BluetoothSdk.queryOtaStatus().catch(() => {})
411
415
  this.armQueryReplyFallback("retry")
412
416
  }
413
417
  }
@@ -449,16 +453,17 @@ class OtaInstallCoordinator {
449
453
  * stays in the screen): clear the selected update, and after an APK step
450
454
  * clear the stale build number so the next check re-reads version_info.
451
455
  */
452
- finish(): void {
456
+ async finish(): Promise<void> {
453
457
  if (this.otaStartOwnership?.outcome !== "pending") {
454
458
  this.otaStartOwnership = null
455
459
  }
456
460
  useGlassesStore.getState().setOtaUpdateAvailable(null)
457
461
  if (this.apkStepSeen) {
458
- BluetoothSdk.updateGlasses({buildNumber: ""})
459
462
  useGlassesStore.getState().setGlassesInfo({buildNumber: ""})
460
463
  }
461
- void this.teardownHotspotTransport()
464
+ // The post-install check needs internet again. Do not let callers leave the
465
+ // terminal screen while Android still owns the no-internet hotspot route.
466
+ await this.teardownHotspotTransport()
462
467
  }
463
468
 
464
469
  /**
@@ -467,8 +472,8 @@ class OtaInstallCoordinator {
467
472
  * deriveDisplayState doesn't resurrect the stale install when the host
468
473
  * navigates away and back through /ota routes.
469
474
  */
470
- discard(): void {
471
- this.finish()
475
+ async discard(): Promise<void> {
476
+ await this.finish()
472
477
  const store = useGlassesStore.getState()
473
478
  store.setOtaStatus(null)
474
479
  store.setOtaProgress(null)
@@ -505,6 +510,7 @@ class OtaInstallCoordinator {
505
510
  versionChangePhase: this.deriveVersionChangePhase(connected),
506
511
  hotspotPhase: this.hotspotPhase,
507
512
  hotspotArtifactPercent: this.hotspotArtifactPercent,
513
+ transport: this.selectedTransport,
508
514
  }
509
515
  }
510
516
 
@@ -997,12 +1003,22 @@ class OtaInstallCoordinator {
997
1003
  // (15s unified, 35s for legacy-shaped sessions — WP 8C-f).
998
1004
  if (displayStateChanged && displayState === "restarting") {
999
1005
  this.clearContinueLockoutTimer()
1006
+ this.clearRestartRecoveryTimeout()
1000
1007
  const lockoutMs = legacySession ? LEGACY_BES_CONTINUE_LOCKOUT_MS : BES_CONTINUE_LOCKOUT_MS
1008
+ const restartTimeoutMs = legacySession ? LEGACY_BES_RESTART_TIMEOUT_MS : BES_RESTART_TIMEOUT_MS
1001
1009
  this.setContinueButtonDisabled(true)
1002
1010
  this.continueLockoutTimer = setTimeout(() => {
1003
1011
  this.continueLockoutTimer = null
1004
1012
  this.setContinueButtonDisabled(false)
1005
1013
  }, lockoutMs)
1014
+ this.restartRecoveryTimeout = setTimeout(() => {
1015
+ this.restartRecoveryTimeout = null
1016
+ if (this.computeDisplayStateNow() !== "restarting") return
1017
+ console.log(`[OTA_PROGRESS] watchdog: glasses did not finish restarting in ${restartTimeoutMs}ms`)
1018
+ this.setErrorMsg(OtaProgressMessages.restartTimeout)
1019
+ }, restartTimeoutMs)
1020
+ } else if (displayStateChanged) {
1021
+ this.clearRestartRecoveryTimeout()
1006
1022
  }
1007
1023
 
1008
1024
  // Legacy MTK install stall simulation (WP 8C-e): display-only. The simulation
@@ -1065,7 +1081,7 @@ class OtaInstallCoordinator {
1065
1081
  if (this.otaStartOwnership?.outcome === "pending") {
1066
1082
  void this.sendOtaStartWithWatchdogs()
1067
1083
  }
1068
- void BluetoothSdk.sendOtaQueryStatus()
1084
+ void BluetoothSdk.queryOtaStatus().catch(() => {})
1069
1085
  this.armQueryReplyFallback("reconnect")
1070
1086
  return true
1071
1087
  }
@@ -1109,7 +1125,7 @@ class OtaInstallCoordinator {
1109
1125
  if (noSessionYet) {
1110
1126
  if (!isIdleStatus && this.otaStartOwnership?.outcome === "acknowledged") {
1111
1127
  console.log("[OTA_PROGRESS] initial mount, acknowledged session has no cached status — reconciling")
1112
- void BluetoothSdk.sendOtaQueryStatus().catch(() => {})
1128
+ void BluetoothSdk.queryOtaStatus().catch(() => {})
1113
1129
  this.armQueryReplyFallback("initial-mount")
1114
1130
  return
1115
1131
  }
@@ -1125,7 +1141,7 @@ class OtaInstallCoordinator {
1125
1141
  void this.sendOtaStartWithWatchdogs()
1126
1142
  } else {
1127
1143
  console.log("[OTA_PROGRESS] initial mount, session exists, sending ota_query_status")
1128
- void BluetoothSdk.sendOtaQueryStatus()
1144
+ void BluetoothSdk.queryOtaStatus().catch(() => {})
1129
1145
  this.armQueryReplyFallback("initial-mount")
1130
1146
  }
1131
1147
  }
@@ -1154,7 +1170,7 @@ class OtaInstallCoordinator {
1154
1170
  this.clearProgressTimeout()
1155
1171
  this.onFirstActivity()
1156
1172
  this.onFirstNonZeroProgress()
1157
- void BluetoothSdk.sendOtaQueryStatus()
1173
+ void BluetoothSdk.queryOtaStatus().catch(() => {})
1158
1174
  useGlassesStore.getState().setMtkUpdatedThisSession(true)
1159
1175
  }
1160
1176
 
@@ -1345,7 +1361,7 @@ class OtaInstallCoordinator {
1345
1361
  const s = useGlassesStore.getState().otaStatus
1346
1362
  if (s?.stepType === "apk" && s.phase === "install" && s.status === "in_progress") {
1347
1363
  this.apkInstallPollInFlight = true
1348
- void BluetoothSdk.sendOtaQueryStatus()
1364
+ void BluetoothSdk.queryOtaStatus()
1349
1365
  .catch(() => {})
1350
1366
  .finally(() => {
1351
1367
  this.apkInstallPollInFlight = false
@@ -1665,6 +1681,13 @@ class OtaInstallCoordinator {
1665
1681
  }
1666
1682
  }
1667
1683
 
1684
+ private clearRestartRecoveryTimeout(): void {
1685
+ if (this.restartRecoveryTimeout) {
1686
+ clearTimeout(this.restartRecoveryTimeout)
1687
+ this.restartRecoveryTimeout = null
1688
+ }
1689
+ }
1690
+
1668
1691
  private clearLegacyApkSettleTimer(): void {
1669
1692
  if (this.legacyApkSettleTimer) {
1670
1693
  clearTimeout(this.legacyApkSettleTimer)
@@ -1703,6 +1726,7 @@ class OtaInstallCoordinator {
1703
1726
  this.clearPingInterval()
1704
1727
  this.clearLegacyApkSettleTimer()
1705
1728
  this.clearMtkSimulationTimers()
1729
+ this.clearRestartRecoveryTimeout()
1706
1730
  }
1707
1731
  }
1708
1732
 
@@ -10,8 +10,7 @@
10
10
  *
11
11
  * Started by `engine.start()`. Idempotent.
12
12
  */
13
- import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
14
- import type {OtaStatus} from "@mentra/bluetooth-sdk/internal"
13
+ import BluetoothSdk, {type OtaStatus} from "@mentra/bluetooth-sdk"
15
14
  import GlobalEventEmitter from "../utils/GlobalEventEmitter"
16
15
  import {useGlassesStore} from "../stores/glasses"
17
16
  import {handleOtaClockSkewFromGlasses} from "./glassesClockSync"
@@ -1,8 +1,9 @@
1
- import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
2
- import type {OtaUpdateInfo} from "@mentra/bluetooth-sdk"
1
+ import BluetoothSdk, {type OtaUpdateInfo} from "@mentra/bluetooth-sdk"
2
+ import {ENGINE_RELEASE_METADATA} from "../generated/releaseMetadata"
3
3
  import {getGlassesSystemTimeMs, isGlassesConnected, useGlassesStore, waitForGlassesState} from "../stores/glasses"
4
4
  import {maybeFixGlassesClockFromVersionInfo} from "./glassesClockSync"
5
5
  import {hasConfiguredModernOtaManifestPin, resolveOtaManifestUrl} from "./otaManifestUrl"
6
+ import {resolveOtaReleaseVersion} from "./otaReleaseVersion"
6
7
 
7
8
  /**
8
9
  * Phone-side mirror of ASG's `DOWNGRADE_FLOOR_VERSION_CODE`. Set to the versionCode of the first
@@ -38,6 +39,7 @@ export interface BesFirmware {
38
39
  }
39
40
 
40
41
  export interface VersionJson {
42
+ releaseVersion?: string
41
43
  apps?: {
42
44
  [packageName: string]: VersionInfo
43
45
  }
@@ -66,6 +68,8 @@ export interface OtaCheckResult {
66
68
  * instead of racing it with a second fetch of the same URL.
67
69
  */
68
70
  manifestBody: string | null
71
+ /** Coordinated release identity for the selected OTA pin. */
72
+ releaseVersion: string | null
69
73
  /**
70
74
  * Why the check failed, when it did. "network" is retryable (connection/server
71
75
  * trouble); "pin_unavailable" is permanent for this app build (manifest 404/gone,
@@ -106,6 +110,7 @@ function emptyCheckResult(skippedReason?: OtaCheckSkippedReason): OtaCheckCurren
106
110
  besVersion: null,
107
111
  isApkDowngrade: false,
108
112
  manifestBody: null,
113
+ releaseVersion: null,
109
114
  updateInfo: null,
110
115
  isRequired: true,
111
116
  skippedReason,
@@ -122,7 +127,7 @@ async function fetchVersionInfoDetailed(url: string): Promise<ManifestFetchResul
122
127
  try {
123
128
  const response = await fetch(url)
124
129
  if (!response.ok) {
125
- console.error("Failed to fetch version info:", response.status)
130
+ console.warn("Failed to fetch version info:", response.status)
126
131
  // 4xx: the manifest does not exist (or is gone) — permanent for this app
127
132
  // build, retrying cannot help. Everything else is transient server/network
128
133
  // trouble and retryable.
@@ -134,11 +139,11 @@ async function fetchVersionInfoDetailed(url: string): Promise<ManifestFetchResul
134
139
  try {
135
140
  return {json: await response.json()}
136
141
  } catch (parseError) {
137
- console.error("OTA: manifest is not valid JSON:", parseError)
142
+ console.warn("OTA: manifest is not valid JSON:", parseError)
138
143
  return {json: null, failureReason: "pin_unavailable"}
139
144
  }
140
145
  } catch (error) {
141
- console.error("OTA: Error fetching version info:", error)
146
+ console.warn("OTA: Error fetching version info:", error)
142
147
  return {json: null, failureReason: "network"}
143
148
  }
144
149
  }
@@ -298,6 +303,7 @@ export async function checkForOtaUpdate(
298
303
  besVersion: null,
299
304
  isApkDowngrade: false,
300
305
  manifestBody: null,
306
+ releaseVersion: null,
301
307
  checkFailureReason: fetched.failureReason ?? "network",
302
308
  }
303
309
  }
@@ -329,6 +335,7 @@ export async function checkForOtaUpdate(
329
335
  besVersion: null,
330
336
  isApkDowngrade: false,
331
337
  manifestBody: null,
338
+ releaseVersion: null,
332
339
  checkFailureReason: "pin_unavailable",
333
340
  }
334
341
  }
@@ -367,6 +374,12 @@ export async function checkForOtaUpdate(
367
374
  besVersion: versionJson?.bes_firmware?.version || null,
368
375
  isApkDowngrade: apkDirection === "downgrade",
369
376
  manifestBody: versionJson ? JSON.stringify(versionJson) : null,
377
+ releaseVersion: resolveOtaReleaseVersion({
378
+ manifestReleaseVersion: versionJson.releaseVersion,
379
+ manifestUrl: otaVersionUrl,
380
+ packagedManifestUrl: ENGINE_RELEASE_METADATA.otaManifestUrl,
381
+ packagedReleaseIdentity: ENGINE_RELEASE_METADATA.releaseIdentity,
382
+ }),
370
383
  }
371
384
  } catch (error) {
372
385
  console.error("Error checking for OTA update:", error)
@@ -379,6 +392,7 @@ export async function checkForOtaUpdate(
379
392
  besVersion: null,
380
393
  isApkDowngrade: false,
381
394
  manifestBody: null,
395
+ releaseVersion: null,
382
396
  checkFailureReason: "network",
383
397
  }
384
398
  }
@@ -406,9 +420,13 @@ export async function checkCurrentGlassesForUpdate(
406
420
  }
407
421
 
408
422
  if (refreshVersionInfo) {
409
- void BluetoothSdk.requestVersionInfo().catch((error) => {
410
- console.warn("OTA: Failed to request version_info from glasses:", error)
411
- })
423
+ void BluetoothSdk.requestVersionInfo()
424
+ .then((versionInfo) => {
425
+ useGlassesStore.getState().setGlassesInfo(versionInfo)
426
+ })
427
+ .catch((error) => {
428
+ console.warn("OTA: Failed to request version_info from glasses:", error)
429
+ })
412
430
  }
413
431
 
414
432
  let buildNumber = useGlassesStore.getState().buildNumber
@@ -1,5 +1,5 @@
1
1
  import * as RNFS from "@dr.pogodin/react-native-fs"
2
- import {MentraLocalNetwork, type LocalNetworkDownloadProgress} from "@mentra/bluetooth-sdk/internal"
2
+ import {otaLocalNetwork, type OtaLocalNetworkDownloadProgress} from "@mentra/bluetooth-sdk/ota-transport"
3
3
  import {Buffer} from "buffer"
4
4
  import {Platform} from "react-native"
5
5
  import WifiManager from "react-native-wifi-reborn"
@@ -17,7 +17,7 @@ export function shouldUseScopedLocalNetwork(os: string, moduleAvailable: boolean
17
17
  }
18
18
 
19
19
  function supportsScopedConnection(): boolean {
20
- return shouldUseScopedLocalNetwork(Platform.OS, MentraLocalNetwork != null)
20
+ return shouldUseScopedLocalNetwork(Platform.OS, otaLocalNetwork.isAvailable())
21
21
  }
22
22
 
23
23
  function normalizeHeaders(headers?: HeadersInit): Record<string, string> {
@@ -48,25 +48,25 @@ export const localNetworkTransport = {
48
48
  await WifiManager.connectToProtectedSSID(ssid, password, false, false)
49
49
  return undefined
50
50
  }
51
- const result = await MentraLocalNetwork!.connect(ssid, password)
51
+ const result = await otaLocalNetwork.connect(ssid, password)
52
52
  scopedConnectionActive = true
53
53
  return result.localAddress
54
54
  },
55
55
 
56
56
  async disconnect(): Promise<void> {
57
57
  if (supportsScopedConnection()) {
58
- await MentraLocalNetwork!.disconnect().catch(() => {})
58
+ await otaLocalNetwork.disconnect().catch(() => {})
59
59
  }
60
60
  scopedConnectionActive = false
61
61
  nativeJobs.clear()
62
62
  },
63
63
 
64
64
  async fetch(url: string | URL | Request, init?: RequestInit, timeoutMs = 30_000): Promise<Response> {
65
- if (!scopedConnectionActive || !MentraLocalNetwork || typeof url !== "string") {
65
+ if (!scopedConnectionActive || !otaLocalNetwork.isAvailable() || typeof url !== "string") {
66
66
  return globalThis.fetch(url, init)
67
67
  }
68
68
 
69
- const nativeModule = MentraLocalNetwork
69
+ const nativeModule = otaLocalNetwork
70
70
  const id = requestId("request")
71
71
  const signal = init?.signal
72
72
  const cancel = () => void nativeModule.cancel(id)
@@ -92,13 +92,13 @@ export const localNetworkTransport = {
92
92
  },
93
93
 
94
94
  downloadFile(options: DownloadOptions): DownloadHandle {
95
- if (!scopedConnectionActive || !MentraLocalNetwork) return RNFS.downloadFile(options)
95
+ if (!scopedConnectionActive || !otaLocalNetwork.isAvailable()) return RNFS.downloadFile(options)
96
96
 
97
97
  const jobId = nextJobId++
98
98
  const id = requestId("download")
99
99
  let began = false
100
100
  nativeJobs.set(jobId, id)
101
- const subscription = MentraLocalNetwork.addListener("downloadProgress", (event: LocalNetworkDownloadProgress) => {
101
+ const subscription = otaLocalNetwork.onDownloadProgress((event: OtaLocalNetworkDownloadProgress) => {
102
102
  if (event.requestId !== id) return
103
103
  if (!began) {
104
104
  began = true
@@ -116,14 +116,15 @@ export const localNetworkTransport = {
116
116
  })
117
117
  })
118
118
 
119
- const promise = MentraLocalNetwork.download(
120
- id,
121
- options.fromUrl,
122
- options.toFile,
123
- options.headers || {},
124
- options.connectionTimeout || 30_000,
125
- options.readTimeout || 30_000,
126
- )
119
+ const promise = otaLocalNetwork
120
+ .download(
121
+ id,
122
+ options.fromUrl,
123
+ options.toFile,
124
+ options.headers || {},
125
+ options.connectionTimeout || 30_000,
126
+ options.readTimeout || 30_000,
127
+ )
127
128
  .then((result) => {
128
129
  if (!began) {
129
130
  options.begin?.({
@@ -154,16 +155,16 @@ export const localNetworkTransport = {
154
155
 
155
156
  stopDownload(jobId: number): void {
156
157
  const nativeId = nativeJobs.get(jobId)
157
- if (nativeId && MentraLocalNetwork) {
158
+ if (nativeId && otaLocalNetwork.isAvailable()) {
158
159
  cancelledNativeJobs.add(jobId)
159
- void MentraLocalNetwork.cancel(nativeId)
160
+ void otaLocalNetwork.cancel(nativeId)
160
161
  return
161
162
  }
162
163
  RNFS.stopDownload(jobId)
163
164
  },
164
165
  }
165
166
 
166
- MentraLocalNetwork?.addListener("networkLost", () => {
167
+ otaLocalNetwork.onNetworkLost(() => {
167
168
  scopedConnectionActive = false
168
169
  nativeJobs.clear()
169
170
  })