@mentra/engine 0.1.0-dev.0 → 3.1.0-dev.4

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 (503) hide show
  1. package/README.md +55 -18
  2. package/build/bluetooth-sdk/debug.d.ts +2 -0
  3. package/build/bluetooth-sdk/debug.d.ts.map +1 -0
  4. package/build/bluetooth-sdk/debug.js +2 -0
  5. package/build/bluetooth-sdk/debug.js.map +1 -0
  6. package/build/bluetooth-sdk/index.d.ts +3 -0
  7. package/build/bluetooth-sdk/index.d.ts.map +1 -0
  8. package/build/bluetooth-sdk/index.js +3 -0
  9. package/build/bluetooth-sdk/index.js.map +1 -0
  10. package/build/bluetooth-sdk/photo-receiver.d.ts +3 -0
  11. package/build/bluetooth-sdk/photo-receiver.d.ts.map +1 -0
  12. package/build/bluetooth-sdk/photo-receiver.js +3 -0
  13. package/build/bluetooth-sdk/photo-receiver.js.map +1 -0
  14. package/build/bluetooth-sdk/react.d.ts +2 -0
  15. package/build/bluetooth-sdk/react.d.ts.map +1 -0
  16. package/build/bluetooth-sdk/react.js +2 -0
  17. package/build/bluetooth-sdk/react.js.map +1 -0
  18. package/build/bluetooth-sdk/types.d.ts +2 -0
  19. package/build/bluetooth-sdk/types.d.ts.map +1 -0
  20. package/build/bluetooth-sdk/types.js +2 -0
  21. package/build/bluetooth-sdk/types.js.map +1 -0
  22. package/build/engine.d.ts +24 -16
  23. package/build/engine.d.ts.map +1 -1
  24. package/build/engine.js +9 -4
  25. package/build/engine.js.map +1 -1
  26. package/build/facades/dev.d.ts +3 -2
  27. package/build/facades/dev.d.ts.map +1 -1
  28. package/build/facades/dev.js +18 -12
  29. package/build/facades/dev.js.map +1 -1
  30. package/build/facades/display.d.ts +1 -1
  31. package/build/facades/display.d.ts.map +1 -1
  32. package/build/facades/display.js +1 -1
  33. package/build/facades/display.js.map +1 -1
  34. package/build/facades/gallery.js +1 -1
  35. package/build/facades/gallery.js.map +1 -1
  36. package/build/facades/glasses.d.ts +4 -13
  37. package/build/facades/glasses.d.ts.map +1 -1
  38. package/build/facades/glasses.js +21 -6
  39. package/build/facades/glasses.js.map +1 -1
  40. package/build/facades/glassesSettings.d.ts.map +1 -1
  41. package/build/facades/glassesSettings.js +1 -0
  42. package/build/facades/glassesSettings.js.map +1 -1
  43. package/build/facades/miniapps.d.ts +4 -0
  44. package/build/facades/miniapps.d.ts.map +1 -1
  45. package/build/facades/miniapps.js +5 -0
  46. package/build/facades/miniapps.js.map +1 -1
  47. package/build/facades/ota.d.ts +14 -16
  48. package/build/facades/ota.d.ts.map +1 -1
  49. package/build/facades/ota.js +22 -7
  50. package/build/facades/ota.js.map +1 -1
  51. package/build/facades/pairing.d.ts +8 -5
  52. package/build/facades/pairing.d.ts.map +1 -1
  53. package/build/facades/pairing.js +12 -10
  54. package/build/facades/pairing.js.map +1 -1
  55. package/build/facades/permissions.d.ts +10 -0
  56. package/build/facades/permissions.d.ts.map +1 -1
  57. package/build/facades/permissions.js +10 -0
  58. package/build/facades/permissions.js.map +1 -1
  59. package/build/facades/phoneNotifications.d.ts +0 -4
  60. package/build/facades/phoneNotifications.d.ts.map +1 -1
  61. package/build/facades/phoneNotifications.js +4 -14
  62. package/build/facades/phoneNotifications.js.map +1 -1
  63. package/build/facades/speech.d.ts +7 -6
  64. package/build/facades/speech.d.ts.map +1 -1
  65. package/build/facades/speech.js +1 -1
  66. package/build/facades/speech.js.map +1 -1
  67. package/build/generated/releaseMetadata.d.ts +12 -0
  68. package/build/generated/releaseMetadata.d.ts.map +1 -0
  69. package/build/generated/releaseMetadata.js +10 -0
  70. package/build/generated/releaseMetadata.js.map +1 -0
  71. package/build/index.d.ts +10 -8
  72. package/build/index.d.ts.map +1 -1
  73. package/build/index.js +6 -5
  74. package/build/index.js.map +1 -1
  75. package/build/react/MentraLiveOtaFlow.d.ts +38 -0
  76. package/build/react/MentraLiveOtaFlow.d.ts.map +1 -0
  77. package/build/react/MentraLiveOtaFlow.js +528 -0
  78. package/build/react/MentraLiveOtaFlow.js.map +1 -0
  79. package/build/react/index.d.ts +2 -0
  80. package/build/react/index.d.ts.map +1 -0
  81. package/build/react/index.js +2 -0
  82. package/build/react/index.js.map +1 -0
  83. package/build/react/useEngineSnapshot.d.ts +2 -0
  84. package/build/react/useEngineSnapshot.d.ts.map +1 -0
  85. package/build/react/useEngineSnapshot.js +14 -0
  86. package/build/react/useEngineSnapshot.js.map +1 -0
  87. package/build/runtime/bootstrap.d.ts +24 -1
  88. package/build/runtime/bootstrap.d.ts.map +1 -1
  89. package/build/runtime/bootstrap.js +17 -0
  90. package/build/runtime/bootstrap.js.map +1 -1
  91. package/build/runtime/scanQrSeam.d.ts +7 -0
  92. package/build/runtime/scanQrSeam.d.ts.map +1 -0
  93. package/build/runtime/scanQrSeam.js +16 -0
  94. package/build/runtime/scanQrSeam.js.map +1 -0
  95. package/build/services/AppRegistry.d.ts +41 -57
  96. package/build/services/AppRegistry.d.ts.map +1 -1
  97. package/build/services/AppRegistry.js +208 -144
  98. package/build/services/AppRegistry.js.map +1 -1
  99. package/build/services/AudioCloudUplink.d.ts +12 -0
  100. package/build/services/AudioCloudUplink.d.ts.map +1 -1
  101. package/build/services/AudioCloudUplink.js +22 -1
  102. package/build/services/AudioCloudUplink.js.map +1 -1
  103. package/build/services/AudioPlaybackService.d.ts +41 -2
  104. package/build/services/AudioPlaybackService.d.ts.map +1 -1
  105. package/build/services/AudioPlaybackService.js +250 -33
  106. package/build/services/AudioPlaybackService.js.map +1 -1
  107. package/build/services/BlobStore.d.ts +2 -1
  108. package/build/services/BlobStore.d.ts.map +1 -1
  109. package/build/services/BlobStore.js +17 -9
  110. package/build/services/BlobStore.js.map +1 -1
  111. package/build/services/CloudAudioSubscriptionSync.d.ts.map +1 -1
  112. package/build/services/CloudAudioSubscriptionSync.js +13 -1
  113. package/build/services/CloudAudioSubscriptionSync.js.map +1 -1
  114. package/build/services/CloudClientService.d.ts +15 -0
  115. package/build/services/CloudClientService.d.ts.map +1 -1
  116. package/build/services/CloudClientService.js +93 -1
  117. package/build/services/CloudClientService.js.map +1 -1
  118. package/build/services/DeviceEventRouter.js +5 -5
  119. package/build/services/DeviceEventRouter.js.map +1 -1
  120. package/build/services/ForegroundLocationPermission.d.ts +13 -0
  121. package/build/services/ForegroundLocationPermission.d.ts.map +1 -0
  122. package/build/services/ForegroundLocationPermission.js +12 -0
  123. package/build/services/ForegroundLocationPermission.js.map +1 -0
  124. package/build/services/GlassesSettingsSync.d.ts.map +1 -1
  125. package/build/services/GlassesSettingsSync.js +9 -3
  126. package/build/services/GlassesSettingsSync.js.map +1 -1
  127. package/build/services/GlassesStatusProjection.d.ts +2 -1
  128. package/build/services/GlassesStatusProjection.d.ts.map +1 -1
  129. package/build/services/GlassesStatusProjection.js +15 -8
  130. package/build/services/GlassesStatusProjection.js.map +1 -1
  131. package/build/services/HotspotOtaTransport.d.ts +29 -0
  132. package/build/services/HotspotOtaTransport.d.ts.map +1 -0
  133. package/build/services/HotspotOtaTransport.js +146 -0
  134. package/build/services/HotspotOtaTransport.js.map +1 -0
  135. package/build/services/HotspotShutdown.d.ts +17 -0
  136. package/build/services/HotspotShutdown.d.ts.map +1 -0
  137. package/build/services/HotspotShutdown.js +29 -0
  138. package/build/services/HotspotShutdown.js.map +1 -0
  139. package/build/services/LocalDisplayManager.d.ts +15 -0
  140. package/build/services/LocalDisplayManager.d.ts.map +1 -1
  141. package/build/services/LocalDisplayManager.js +106 -20
  142. package/build/services/LocalDisplayManager.js.map +1 -1
  143. package/build/services/LocalMiniappRuntime.d.ts +74 -2
  144. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  145. package/build/services/LocalMiniappRuntime.js +785 -291
  146. package/build/services/LocalMiniappRuntime.js.map +1 -1
  147. package/build/services/LocalMiniappStorage.d.ts +32 -0
  148. package/build/services/LocalMiniappStorage.d.ts.map +1 -0
  149. package/build/services/LocalMiniappStorage.js +66 -0
  150. package/build/services/LocalMiniappStorage.js.map +1 -0
  151. package/build/services/LocalMiniappUserIdentity.d.ts +20 -0
  152. package/build/services/LocalMiniappUserIdentity.d.ts.map +1 -0
  153. package/build/services/LocalMiniappUserIdentity.js +43 -0
  154. package/build/services/LocalMiniappUserIdentity.js.map +1 -0
  155. package/build/services/LocalSttFallbackCoordinator.d.ts +9 -6
  156. package/build/services/LocalSttFallbackCoordinator.d.ts.map +1 -1
  157. package/build/services/LocalSttFallbackCoordinator.js +18 -12
  158. package/build/services/LocalSttFallbackCoordinator.js.map +1 -1
  159. package/build/services/MicStateCoordinator.d.ts +42 -1
  160. package/build/services/MicStateCoordinator.d.ts.map +1 -1
  161. package/build/services/MicStateCoordinator.js +141 -3
  162. package/build/services/MicStateCoordinator.js.map +1 -1
  163. package/build/services/MiniappLauncher.d.ts.map +1 -1
  164. package/build/services/MiniappLauncher.js +23 -3
  165. package/build/services/MiniappLauncher.js.map +1 -1
  166. package/build/services/MiniappLiveness.d.ts +12 -0
  167. package/build/services/MiniappLiveness.d.ts.map +1 -0
  168. package/build/services/MiniappLiveness.js +16 -0
  169. package/build/services/MiniappLiveness.js.map +1 -0
  170. package/build/services/NativeHttpResponse.d.ts +3 -0
  171. package/build/services/NativeHttpResponse.d.ts.map +1 -0
  172. package/build/services/NativeHttpResponse.js +6 -0
  173. package/build/services/NativeHttpResponse.js.map +1 -0
  174. package/build/services/OtaArtifactDownloader.d.ts +65 -0
  175. package/build/services/OtaArtifactDownloader.d.ts.map +1 -0
  176. package/build/services/OtaArtifactDownloader.js +226 -0
  177. package/build/services/OtaArtifactDownloader.js.map +1 -0
  178. package/build/services/OtaAutoChain.d.ts +34 -0
  179. package/build/services/OtaAutoChain.d.ts.map +1 -0
  180. package/build/services/OtaAutoChain.js +83 -0
  181. package/build/services/OtaAutoChain.js.map +1 -0
  182. package/build/services/OtaErrorMapping.d.ts +18 -0
  183. package/build/services/OtaErrorMapping.d.ts.map +1 -0
  184. package/build/services/OtaErrorMapping.js +63 -0
  185. package/build/services/OtaErrorMapping.js.map +1 -0
  186. package/build/services/OtaInstallCoordinator.d.ts +95 -15
  187. package/build/services/OtaInstallCoordinator.d.ts.map +1 -1
  188. package/build/services/OtaInstallCoordinator.js +577 -130
  189. package/build/services/OtaInstallCoordinator.js.map +1 -1
  190. package/build/services/OtaService.d.ts.map +1 -1
  191. package/build/services/OtaService.js +8 -1
  192. package/build/services/OtaService.js.map +1 -1
  193. package/build/services/OtaUpdateCheckService.d.ts +39 -4
  194. package/build/services/OtaUpdateCheckService.d.ts.map +1 -1
  195. package/build/services/OtaUpdateCheckService.js +138 -22
  196. package/build/services/OtaUpdateCheckService.js.map +1 -1
  197. package/build/services/PhoneCalendarHelpers.d.ts +32 -0
  198. package/build/services/PhoneCalendarHelpers.d.ts.map +1 -0
  199. package/build/services/PhoneCalendarHelpers.js +157 -0
  200. package/build/services/PhoneCalendarHelpers.js.map +1 -0
  201. package/build/services/PhoneCalendarService.d.ts +5 -0
  202. package/build/services/PhoneCalendarService.d.ts.map +1 -0
  203. package/build/services/PhoneCalendarService.js +45 -0
  204. package/build/services/PhoneCalendarService.js.map +1 -0
  205. package/build/services/PhoneCameraFovCoordinator.d.ts +30 -0
  206. package/build/services/PhoneCameraFovCoordinator.d.ts.map +1 -0
  207. package/build/services/PhoneCameraFovCoordinator.js +194 -0
  208. package/build/services/PhoneCameraFovCoordinator.js.map +1 -0
  209. package/build/services/PhoneNotificationsSync.d.ts.map +1 -1
  210. package/build/services/PhoneNotificationsSync.js +12 -10
  211. package/build/services/PhoneNotificationsSync.js.map +1 -1
  212. package/build/services/PhonePhotoCoordinator.d.ts +26 -2
  213. package/build/services/PhonePhotoCoordinator.d.ts.map +1 -1
  214. package/build/services/PhonePhotoCoordinator.js +79 -9
  215. package/build/services/PhonePhotoCoordinator.js.map +1 -1
  216. package/build/services/PhoneStreamCoordinator.d.ts +8 -1
  217. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  218. package/build/services/PhoneStreamCoordinator.js +125 -13
  219. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  220. package/build/services/PhoneVideoCoordinator.d.ts +2 -0
  221. package/build/services/PhoneVideoCoordinator.d.ts.map +1 -1
  222. package/build/services/PhoneVideoCoordinator.js +10 -0
  223. package/build/services/PhoneVideoCoordinator.js.map +1 -1
  224. package/build/services/SceneRenderer.d.ts.map +1 -1
  225. package/build/services/SceneRenderer.js +15 -0
  226. package/build/services/SceneRenderer.js.map +1 -1
  227. package/build/services/SentenceTtsPipeline.d.ts +31 -0
  228. package/build/services/SentenceTtsPipeline.d.ts.map +1 -0
  229. package/build/services/SentenceTtsPipeline.js +67 -0
  230. package/build/services/SentenceTtsPipeline.js.map +1 -0
  231. package/build/services/SupportProfileSync.d.ts +22 -0
  232. package/build/services/SupportProfileSync.d.ts.map +1 -0
  233. package/build/services/SupportProfileSync.js +213 -0
  234. package/build/services/SupportProfileSync.js.map +1 -0
  235. package/build/services/TranscriptionRouting.d.ts +12 -0
  236. package/build/services/TranscriptionRouting.d.ts.map +1 -0
  237. package/build/services/TranscriptionRouting.js +24 -0
  238. package/build/services/TranscriptionRouting.js.map +1 -0
  239. package/build/services/TtsTextSanitizer.d.ts +5 -0
  240. package/build/services/TtsTextSanitizer.d.ts.map +1 -0
  241. package/build/services/TtsTextSanitizer.js +61 -0
  242. package/build/services/TtsTextSanitizer.js.map +1 -0
  243. package/build/services/WifiAdbRequest.d.ts +17 -0
  244. package/build/services/WifiAdbRequest.d.ts.map +1 -0
  245. package/build/services/WifiAdbRequest.js +35 -0
  246. package/build/services/WifiAdbRequest.js.map +1 -0
  247. package/build/services/asg/asgCameraApi.d.ts +43 -0
  248. package/build/services/asg/asgCameraApi.d.ts.map +1 -1
  249. package/build/services/asg/asgCameraApi.js +509 -26
  250. package/build/services/asg/asgCameraApi.js.map +1 -1
  251. package/build/services/asg/cameraRollExportCoordinator.d.ts +60 -0
  252. package/build/services/asg/cameraRollExportCoordinator.d.ts.map +1 -0
  253. package/build/services/asg/cameraRollExportCoordinator.js +618 -0
  254. package/build/services/asg/cameraRollExportCoordinator.js.map +1 -0
  255. package/build/services/asg/cameraRollExportLedger.d.ts +44 -0
  256. package/build/services/asg/cameraRollExportLedger.d.ts.map +1 -0
  257. package/build/services/asg/cameraRollExportLedger.js +78 -0
  258. package/build/services/asg/cameraRollExportLedger.js.map +1 -0
  259. package/build/services/asg/galleryNotices.d.ts +1 -1
  260. package/build/services/asg/galleryNotices.d.ts.map +1 -1
  261. package/build/services/asg/galleryNotices.js.map +1 -1
  262. package/build/services/asg/galleryPathMigration.d.ts +3 -0
  263. package/build/services/asg/galleryPathMigration.d.ts.map +1 -0
  264. package/build/services/asg/galleryPathMigration.js +26 -0
  265. package/build/services/asg/galleryPathMigration.js.map +1 -0
  266. package/build/services/asg/gallerySettingsService.d.ts +1 -0
  267. package/build/services/asg/gallerySettingsService.d.ts.map +1 -1
  268. package/build/services/asg/gallerySettingsService.js +2 -0
  269. package/build/services/asg/gallerySettingsService.js.map +1 -1
  270. package/build/services/asg/gallerySyncService.d.ts.map +1 -1
  271. package/build/services/asg/gallerySyncService.js +162 -56
  272. package/build/services/asg/gallerySyncService.js.map +1 -1
  273. package/build/services/asg/galleryTransferLedger.d.ts +58 -0
  274. package/build/services/asg/galleryTransferLedger.d.ts.map +1 -0
  275. package/build/services/asg/galleryTransferLedger.js +225 -0
  276. package/build/services/asg/galleryTransferLedger.js.map +1 -0
  277. package/build/services/asg/localNetworkTransport.d.ts +15 -0
  278. package/build/services/asg/localNetworkTransport.d.ts.map +1 -0
  279. package/build/services/asg/localNetworkTransport.js +144 -0
  280. package/build/services/asg/localNetworkTransport.js.map +1 -0
  281. package/build/services/asg/localStorageService.d.ts +18 -0
  282. package/build/services/asg/localStorageService.d.ts.map +1 -1
  283. package/build/services/asg/localStorageService.js +168 -19
  284. package/build/services/asg/localStorageService.js.map +1 -1
  285. package/build/services/asg/mediaProcessingQueue.d.ts +10 -0
  286. package/build/services/asg/mediaProcessingQueue.d.ts.map +1 -1
  287. package/build/services/asg/mediaProcessingQueue.js +193 -62
  288. package/build/services/asg/mediaProcessingQueue.js.map +1 -1
  289. package/build/services/audioPlaybackAssets.d.ts +8 -0
  290. package/build/services/audioPlaybackAssets.d.ts.map +1 -0
  291. package/build/services/audioPlaybackAssets.js +7 -0
  292. package/build/services/audioPlaybackAssets.js.map +1 -0
  293. package/build/services/gallerySyncClock.js +1 -1
  294. package/build/services/gallerySyncClock.js.map +1 -1
  295. package/build/services/glassesClockSync.d.ts.map +1 -1
  296. package/build/services/glassesClockSync.js +6 -2
  297. package/build/services/glassesClockSync.js.map +1 -1
  298. package/build/services/manifestActions.d.ts +7 -0
  299. package/build/services/manifestActions.d.ts.map +1 -0
  300. package/build/services/manifestActions.js +25 -0
  301. package/build/services/manifestActions.js.map +1 -0
  302. package/build/services/otaDisplayState.d.ts +23 -12
  303. package/build/services/otaDisplayState.d.ts.map +1 -1
  304. package/build/services/otaDisplayState.js +36 -14
  305. package/build/services/otaDisplayState.js.map +1 -1
  306. package/build/services/otaInstallPolicy.d.ts +16 -4
  307. package/build/services/otaInstallPolicy.d.ts.map +1 -1
  308. package/build/services/otaInstallPolicy.js +24 -3
  309. package/build/services/otaInstallPolicy.js.map +1 -1
  310. package/build/services/otaLegacyMapping.d.ts +1 -1
  311. package/build/services/otaLegacyMapping.d.ts.map +1 -1
  312. package/build/services/otaLegacyMapping.js.map +1 -1
  313. package/build/services/otaManifestPolicy.d.ts +14 -0
  314. package/build/services/otaManifestPolicy.d.ts.map +1 -0
  315. package/build/services/otaManifestPolicy.js +19 -0
  316. package/build/services/otaManifestPolicy.js.map +1 -0
  317. package/build/services/otaManifestUrl.d.ts +3 -0
  318. package/build/services/otaManifestUrl.d.ts.map +1 -0
  319. package/build/services/otaManifestUrl.js +38 -0
  320. package/build/services/otaManifestUrl.js.map +1 -0
  321. package/build/services/slimStreamStatus.d.ts +11 -0
  322. package/build/services/slimStreamStatus.d.ts.map +1 -1
  323. package/build/services/slimStreamStatus.js +14 -4
  324. package/build/services/slimStreamStatus.js.map +1 -1
  325. package/build/stores/apps.d.ts.map +1 -1
  326. package/build/stores/apps.js +11 -0
  327. package/build/stores/apps.js.map +1 -1
  328. package/build/stores/bluetoothSettingKeys.d.ts.map +1 -1
  329. package/build/stores/bluetoothSettingKeys.js +2 -0
  330. package/build/stores/bluetoothSettingKeys.js.map +1 -1
  331. package/build/stores/gallerySync.d.ts +2 -0
  332. package/build/stores/gallerySync.d.ts.map +1 -1
  333. package/build/stores/gallerySync.js +2 -0
  334. package/build/stores/gallerySync.js.map +1 -1
  335. package/build/stores/glasses.d.ts.map +1 -1
  336. package/build/stores/glasses.js +3 -0
  337. package/build/stores/glasses.js.map +1 -1
  338. package/build/stores/settings.d.ts +17 -0
  339. package/build/stores/settings.d.ts.map +1 -1
  340. package/build/stores/settings.js +157 -17
  341. package/build/stores/settings.js.map +1 -1
  342. package/build/types/applet.d.ts +2 -0
  343. package/build/types/applet.d.ts.map +1 -1
  344. package/build/types/applet.js.map +1 -1
  345. package/build/types/asg.d.ts +7 -0
  346. package/build/types/asg.d.ts.map +1 -1
  347. package/build/types/asg.js.map +1 -1
  348. package/build/types/enums.d.ts +2 -25
  349. package/build/types/enums.d.ts.map +1 -1
  350. package/build/types/enums.js +2 -27
  351. package/build/types/enums.js.map +1 -1
  352. package/build/types/hardware.d.ts +4 -13
  353. package/build/types/hardware.d.ts.map +1 -1
  354. package/build/types/hardware.js +15 -3
  355. package/build/types/hardware.js.map +1 -1
  356. package/build/types/index.d.ts +2 -6
  357. package/build/types/index.d.ts.map +1 -1
  358. package/build/types/index.js +2 -6
  359. package/build/types/index.js.map +1 -1
  360. package/build/utils/configuredDevHost.d.ts +6 -0
  361. package/build/utils/configuredDevHost.d.ts.map +1 -0
  362. package/build/utils/configuredDevHost.js +19 -0
  363. package/build/utils/configuredDevHost.js.map +1 -0
  364. package/build/utils/devMiniappLaunch.d.ts +19 -8
  365. package/build/utils/devMiniappLaunch.d.ts.map +1 -1
  366. package/build/utils/devMiniappLaunch.js +144 -21
  367. package/build/utils/devMiniappLaunch.js.map +1 -1
  368. package/build/utils/diagnosticContext.d.ts.map +1 -1
  369. package/build/utils/diagnosticContext.js +15 -17
  370. package/build/utils/diagnosticContext.js.map +1 -1
  371. package/build/utils/hardware/hardware.d.ts.map +1 -1
  372. package/build/utils/hardware/hardware.js +7 -3
  373. package/build/utils/hardware/hardware.js.map +1 -1
  374. package/build/utils/miniappDiagnostics.d.ts +64 -0
  375. package/build/utils/miniappDiagnostics.d.ts.map +1 -0
  376. package/build/utils/miniappDiagnostics.js +173 -0
  377. package/build/utils/miniappDiagnostics.js.map +1 -0
  378. package/build/utils/permissions/MediaLibraryPermissions.d.ts +11 -0
  379. package/build/utils/permissions/MediaLibraryPermissions.d.ts.map +1 -1
  380. package/build/utils/permissions/MediaLibraryPermissions.js +27 -4
  381. package/build/utils/permissions/MediaLibraryPermissions.js.map +1 -1
  382. package/build/utils/timers/timers.d.ts.map +1 -1
  383. package/build/utils/timers/timers.js +55 -18
  384. package/build/utils/timers/timers.js.map +1 -1
  385. package/package.json +59 -31
  386. package/src/assets/silence.wav +0 -0
  387. package/src/bluetooth-sdk/debug.ts +1 -0
  388. package/src/bluetooth-sdk/index.ts +2 -0
  389. package/src/bluetooth-sdk/photo-receiver.ts +2 -0
  390. package/src/bluetooth-sdk/react.ts +1 -0
  391. package/src/bluetooth-sdk/types.ts +1 -0
  392. package/src/engine.ts +9 -4
  393. package/src/facades/dev.ts +17 -12
  394. package/src/facades/display.ts +1 -1
  395. package/src/facades/gallery.ts +1 -1
  396. package/src/facades/glasses.ts +20 -7
  397. package/src/facades/glassesSettings.ts +5 -0
  398. package/src/facades/miniapps.ts +7 -0
  399. package/src/facades/ota.ts +23 -8
  400. package/src/facades/pairing.ts +15 -11
  401. package/src/facades/permissions.ts +14 -0
  402. package/src/facades/phoneNotifications.ts +4 -13
  403. package/src/facades/speech.ts +21 -7
  404. package/src/generated/releaseMetadata.ts +20 -0
  405. package/src/index.ts +39 -13
  406. package/src/react/MentraLiveOtaFlow.tsx +856 -0
  407. package/src/react/index.ts +7 -0
  408. package/src/react/useEngineSnapshot.ts +16 -0
  409. package/src/runtime/bootstrap.ts +33 -1
  410. package/src/runtime/scanQrSeam.ts +20 -0
  411. package/src/services/AppRegistry.ts +240 -149
  412. package/src/services/AudioCloudUplink.ts +23 -1
  413. package/src/services/AudioPlaybackService.ts +287 -38
  414. package/src/services/BlobStore.ts +22 -9
  415. package/src/services/CloudAudioSubscriptionSync.ts +13 -1
  416. package/src/services/CloudClientService.ts +96 -1
  417. package/src/services/DeviceEventRouter.ts +5 -5
  418. package/src/services/ForegroundLocationPermission.ts +24 -0
  419. package/src/services/GlassesSettingsSync.ts +9 -3
  420. package/src/services/GlassesStatusProjection.ts +16 -8
  421. package/src/services/HotspotOtaTransport.ts +184 -0
  422. package/src/services/HotspotShutdown.ts +41 -0
  423. package/src/services/LocalDisplayManager.ts +112 -21
  424. package/src/services/LocalMiniappRuntime.ts +967 -346
  425. package/src/services/LocalMiniappStorage.ts +82 -0
  426. package/src/services/LocalMiniappUserIdentity.ts +46 -0
  427. package/src/services/LocalSttFallbackCoordinator.ts +19 -12
  428. package/src/services/MicStateCoordinator.ts +169 -4
  429. package/src/services/MiniappLauncher.ts +30 -3
  430. package/src/services/MiniappLiveness.ts +24 -0
  431. package/src/services/NativeHttpResponse.ts +6 -0
  432. package/src/services/OtaArtifactDownloader.ts +312 -0
  433. package/src/services/OtaAutoChain.ts +106 -0
  434. package/src/services/OtaErrorMapping.ts +80 -0
  435. package/src/services/OtaInstallCoordinator.ts +649 -144
  436. package/src/services/OtaService.ts +11 -5
  437. package/src/services/OtaUpdateCheckService.ts +181 -21
  438. package/src/services/PhoneCalendarHelpers.ts +212 -0
  439. package/src/services/PhoneCalendarService.ts +65 -0
  440. package/src/services/PhoneCameraFovCoordinator.ts +210 -0
  441. package/src/services/PhoneNotificationsSync.ts +12 -10
  442. package/src/services/PhonePhotoCoordinator.ts +116 -18
  443. package/src/services/PhoneStreamCoordinator.ts +139 -25
  444. package/src/services/PhoneVideoCoordinator.ts +11 -0
  445. package/src/services/SceneRenderer.ts +16 -0
  446. package/src/services/SentenceTtsPipeline.ts +106 -0
  447. package/src/services/SupportProfileSync.ts +218 -0
  448. package/src/services/TranscriptionRouting.ts +37 -0
  449. package/src/services/TtsTextSanitizer.ts +66 -0
  450. package/src/services/WifiAdbRequest.ts +53 -0
  451. package/src/services/asg/asgCameraApi.ts +615 -33
  452. package/src/services/asg/cameraRollExportCoordinator.ts +627 -0
  453. package/src/services/asg/cameraRollExportLedger.ts +122 -0
  454. package/src/services/asg/galleryNotices.ts +1 -0
  455. package/src/services/asg/galleryPathMigration.ts +22 -0
  456. package/src/services/asg/gallerySettingsService.ts +3 -0
  457. package/src/services/asg/gallerySyncService.ts +184 -73
  458. package/src/services/asg/galleryTransferLedger.ts +312 -0
  459. package/src/services/asg/localNetworkTransport.ts +169 -0
  460. package/src/services/asg/localStorageService.ts +179 -24
  461. package/src/services/asg/mediaProcessingQueue.ts +209 -62
  462. package/src/services/audioPlaybackAssets.ts +8 -0
  463. package/src/services/gallerySyncClock.ts +1 -1
  464. package/src/services/glassesClockSync.ts +6 -2
  465. package/src/services/manifestActions.ts +30 -0
  466. package/src/services/otaDisplayState.ts +58 -17
  467. package/src/services/otaInstallPolicy.ts +30 -4
  468. package/src/services/otaLegacyMapping.ts +1 -1
  469. package/src/services/otaManifestPolicy.ts +42 -0
  470. package/src/services/otaManifestUrl.ts +44 -0
  471. package/src/services/slimStreamStatus.ts +16 -5
  472. package/src/stores/apps.ts +12 -0
  473. package/src/stores/bluetoothSettingKeys.ts +2 -0
  474. package/src/stores/gallerySync.ts +4 -0
  475. package/src/stores/glasses.ts +3 -0
  476. package/src/stores/settings.ts +179 -18
  477. package/src/types/applet.ts +2 -0
  478. package/src/types/asg.ts +7 -0
  479. package/src/types/enums.ts +3 -28
  480. package/src/types/hardware.ts +20 -13
  481. package/src/types/index.ts +2 -6
  482. package/src/utils/configuredDevHost.ts +17 -0
  483. package/src/utils/devMiniappLaunch.ts +151 -23
  484. package/src/utils/diagnosticContext.ts +17 -17
  485. package/src/utils/hardware/hardware.ts +7 -9
  486. package/src/utils/miniappDiagnostics.ts +226 -0
  487. package/src/utils/permissions/MediaLibraryPermissions.ts +35 -4
  488. package/src/utils/timers/timers.ts +57 -21
  489. package/build/devtools.d.ts +0 -11
  490. package/build/devtools.d.ts.map +0 -1
  491. package/build/devtools.js +0 -13
  492. package/build/devtools.js.map +0 -1
  493. package/build/internal.d.ts +0 -66
  494. package/build/internal.d.ts.map +0 -1
  495. package/build/internal.js +0 -89
  496. package/build/internal.js.map +0 -1
  497. package/build/services/asgOtaVersionUrl.d.ts +0 -2
  498. package/build/services/asgOtaVersionUrl.d.ts.map +0 -1
  499. package/build/services/asgOtaVersionUrl.js +0 -36
  500. package/build/services/asgOtaVersionUrl.js.map +0 -1
  501. package/src/devtools.ts +0 -13
  502. package/src/internal.ts +0 -158
  503. package/src/services/asgOtaVersionUrl.ts +0 -41
@@ -9,7 +9,7 @@
9
9
  * management (connect/disconnect/ping).
10
10
  */
11
11
 
12
- import {Linking} from "react-native"
12
+ import {AppState, Linking} from "react-native"
13
13
  import Share from "react-native-share"
14
14
  import * as Battery from "expo-battery"
15
15
  import * as Clipboard from "expo-clipboard"
@@ -45,9 +45,14 @@ import localSttFallbackCoordinator from "./LocalSttFallbackCoordinator"
45
45
  import micStateCoordinator from "./MicStateCoordinator"
46
46
  import {BlobStore} from "./BlobStore"
47
47
  import {CloudAudioSubscriptionSync} from "./CloudAudioSubscriptionSync"
48
+ import {phoneCameraFovCoordinator} from "./PhoneCameraFovCoordinator"
48
49
  import {phonePhotoCoordinator} from "./PhonePhotoCoordinator"
49
50
  import {phoneStreamCoordinator} from "./PhoneStreamCoordinator"
50
51
  import {phoneVideoCoordinator} from "./PhoneVideoCoordinator"
52
+ import {runSentenceTtsPipeline} from "./SentenceTtsPipeline"
53
+ import {summarizeTranscriptionRoutes, transcriptionDeliveryRoute} from "./TranscriptionRouting"
54
+ import {prepareTtsSentences} from "./TtsTextSanitizer"
55
+ import {handleWifiAdbRequest} from "./WifiAdbRequest"
51
56
  import {cloudClientService} from "./CloudClientService"
52
57
  import {miniappLauncher} from "./MiniappLauncher"
53
58
  import {
@@ -61,39 +66,51 @@ import {
61
66
  type TtsSynthesisResult,
62
67
  } from "../runtime/config"
63
68
  import {getAnalytics, getUiSeams} from "../runtime/bootstrap"
69
+ import {invokeScanQrSeam} from "../runtime/scanQrSeam"
64
70
  import {normalizeStreamAudioConfig, normalizeStreamVideoConfig} from "../runtime/streamConfig"
65
- import type {
66
- AudioSubscription,
67
- LanguageSource,
68
- TranscriptionData,
69
- TranslationData,
70
- } from "@mentra/cloud-protocol"
71
+ import {toLanguageHint} from "@mentra/cloud-protocol/languages"
72
+ import type {AudioSubscription, LanguageSource, TranscriptionData, TranslationData} from "@mentra/cloud-protocol"
73
+ import {buildMiniappManifestSnapshot, type MiniappRuntimeDiagnosticSnapshot} from "../utils/miniappDiagnostics"
71
74
  import ttsModelManager from "./TTSModelManager"
72
75
  import {NavigationHandlers} from "./NavigationHandlers"
73
76
  import type {ClientApp} from "../types/applet"
74
77
  import {useAppStatusStore} from "../stores/apps"
75
- import {DEV_APP_PACKAGE_NAME, getDevAppSourcePackage} from "./AppRegistry"
78
+ import {getDevAppAttestation, getDevAppSourcePackage} from "./AppRegistry"
79
+ import {resolveForegroundLocationPermission} from "./ForegroundLocationPermission"
80
+ import {advanceMiniappPingLiveness} from "./MiniappLiveness"
81
+ import {listPhoneCalendarEvents, PhoneCalendarError} from "./PhoneCalendarService"
82
+ import {LocalMiniappStorage} from "./LocalMiniappStorage"
76
83
 
77
84
  // =============================================================================
78
85
  // Types
79
86
  // =============================================================================
80
87
 
81
88
  export interface InstalledMiniappManifest {
89
+ packageName?: string
82
90
  /** Human-facing app name (from miniapp.json `name`). Shown in the "Starting
83
91
  * <name>…" boot message; falls back to the package name when absent. */
84
92
  name?: string
93
+ version?: string
94
+ sdkVersion?: string
95
+ minHostVersion?: string
96
+ type?: string
97
+ entry?: {background?: string; ui?: string}
85
98
  permissions?: Array<{type: string; required?: boolean; description?: string}>
86
99
  hardwareRequirements?: Array<{type: string; level: string; description?: string}>
100
+ actions?: Array<{id?: unknown; description?: unknown; parameters?: unknown; outputSchema?: unknown}>
87
101
  }
88
102
 
89
103
  type SpeakerStateValue = "idle" | "loading" | "playing" | "stopped" | "error"
90
104
 
91
105
  interface ConnectedMiniapp {
92
106
  subscriptions: Set<string>
107
+ /** Transcription streams this app explicitly requires to stay on-device. */
108
+ forceLocalTranscriptionStreams: Set<string>
109
+ /** Transcription streams with at least one listener using the default/cloud route. */
110
+ cloudTranscriptionStreams: Set<string>
93
111
  sendMessage: (raw: string) => void
94
112
  lastPongAt: number
95
- /** BGCAP diagnostic: wall-clock of the most recent PING send, for PONG RTT. */
96
- bgcapLastPingSentAt?: number
113
+ unansweredPingRounds: number
97
114
  installedManifest?: InstalledMiniappManifest
98
115
  authRefreshTimerId: number | null
99
116
  /**
@@ -122,6 +139,15 @@ interface ConnectedMiniapp {
122
139
  requestedLocationRate: string | null
123
140
  }
124
141
 
142
+ interface SpeechRun {
143
+ id: string
144
+ cancelled: boolean
145
+ playbackRequestId?: string
146
+ onCancelled?: () => void
147
+ }
148
+
149
+ type TranscriptionEventSource = "cloud" | "local"
150
+
125
151
  /**
126
152
  * Strictness ordering for `location_stream` rate values. Higher index =
127
153
  * higher accuracy and higher power cost. The aggregate tier applied to
@@ -158,6 +184,16 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | nul
158
184
  }
159
185
 
160
186
  const LOG_TAG = "LOCAL_MINIAPP"
187
+ const DIAGNOSTIC_MAX_LIST_ITEMS = 100
188
+ const DIAGNOSTIC_MAX_STRING_LENGTH = 512
189
+
190
+ function diagnosticStringList(values: Iterable<string>): string[] {
191
+ return [...values]
192
+ .map((value) => value.slice(0, DIAGNOSTIC_MAX_STRING_LENGTH))
193
+ .sort()
194
+ .slice(0, DIAGNOSTIC_MAX_LIST_ITEMS)
195
+ }
196
+
161
197
  const SYSTEM_MINIAPP_PACKAGES = new Set([
162
198
  "com.mentra.camera",
163
199
  "com.mentra.gallery",
@@ -189,81 +225,34 @@ const REQUEST_WIFI_SETUP_TYPE = "miniapp_request_wifi_setup"
189
225
  // comes back automatically — this threshold only bounds how long that takes.
190
226
  const PING_TIMEOUT_THRESHOLD = 6 // ~30s
191
227
 
192
- // ===========================================================================
193
- // BGCAP: temporary background-caption telemetry (DIAGNOSTIC — remove after the
194
- // "captions fall behind then flood in waves" investigation lands a fix).
195
- //
196
- // Captions now run as a local miniapp inside the per-context JS engine
197
- // (Crust: QuickJS on Android / JSC on iOS), driven off a single serial
198
- // queue/executor with NO background awareness. The hypothesis is that the OS
199
- // starves/freezes that queue when the screen is off (iOS app suspension /
200
- // Android Doze), so inbound transcript deliveries pile up and drain in a burst
201
- // on resume. These logs measure exactly that. All lines are prefixed "BGCAP:"
202
- // so they're trivial to grep in incident logs and to delete. Cross-platform —
203
- // host-side only, no native changes.
204
- // ===========================================================================
205
- const BGCAP_TELEMETRY = false
206
228
  const TRANSCRIPT_TIMING_TELEMETRY = (globalThis as {__DEV__?: boolean}).__DEV__ === true
207
- let bgcapHbTimer: ReturnType<typeof setInterval> | null = null
208
- let bgcapHbLast = 0
209
- let bgcapTxCount = 0
210
- let bgcapTxLastFinalAt = 0
211
- let bgcapTxLastLogAt = 0
212
-
213
- function bgcapStartHeartbeat(): void {
214
- if (!BGCAP_TELEMETRY || bgcapHbTimer !== null) return
215
- bgcapHbLast = Date.now()
216
- // PLAIN setInterval (NOT BgTimer): a plain JS timer only fires when the RN
217
- // JS thread is actually executing. A gap >> 1s between ticks therefore proves
218
- // the JS thread was suspended/throttled (iOS suspend / Android Doze) for that
219
- // long — the smoking gun for the freeze. Silent when healthy; logs the stall
220
- // duration when it happens. Pair with the "App state changed to:" lines.
221
- bgcapHbTimer = setInterval(() => {
222
- const now = Date.now()
223
- const gap = now - bgcapHbLast
224
- bgcapHbLast = now
225
- if (gap > 2000) {
226
- console.log(`BGCAP: host-js stall — no tick for ${gap}ms (RN JS thread frozen/throttled)`)
227
- }
228
- }, 1000)
229
- }
230
-
231
- function bgcapStopHeartbeat(): void {
232
- if (bgcapHbTimer !== null) {
233
- clearInterval(bgcapHbTimer)
234
- bgcapHbTimer = null
235
- }
236
- }
237
-
238
- // Rate-limited (1/s) summary of transcripts being FORWARDED to miniapps. Shows
239
- // whether transcripts keep arriving + being handed to the captions miniapp
240
- // during background. Compare its rate against the native "MAN: displayEvent"
241
- // (render) rate: fwd keeps flowing but render lags => miniapp executor is
242
- // starved; fwd itself stops => upstream (cloud delivery / RN thread) is the gap.
243
- function bgcapNoteTranscriptForward(isFinal: boolean): void {
244
- if (!BGCAP_TELEMETRY) return
245
- bgcapTxCount++
246
- if (isFinal) bgcapTxLastFinalAt = Date.now()
247
- const now = Date.now()
248
- if (now - bgcapTxLastLogAt >= 1000) {
249
- console.log(
250
- `BGCAP: tx forwarded=${bgcapTxCount} in last ${now - bgcapTxLastLogAt}ms (lastFinal ${
251
- bgcapTxLastFinalAt ? now - bgcapTxLastFinalAt : -1
252
- }ms ago)`,
253
- )
254
- bgcapTxCount = 0
255
- bgcapTxLastLogAt = now
256
- }
257
- }
258
229
 
259
230
  const RGB_LED_ACTIONS = new Set<RgbLedAction>(["on", "off"])
260
231
  const RGB_LED_COLORS = new Set<RgbLedColor>(["red", "green", "blue", "orange", "white"])
261
232
  const CAMERA_FOV_MIN = 62
262
233
  const CAMERA_FOV_MAX = 118
263
- const CAMERA_FOV_DEFAULT = 102
264
- const CAMERA_FOV_PRESETS: Record<CameraFovPreset, number> = {narrow: 82, standard: CAMERA_FOV_DEFAULT, wide: 118}
234
+ const CAMERA_FOV_DEFAULT = 118
235
+ const CAMERA_FOV_PRESETS: Record<CameraFovPreset, number> = {narrow: 82, standard: 102, wide: 118}
265
236
  const CAMERA_ROI_POSITION_BY_NAME: Record<string, CameraRoiPosition> = {center: "center", bottom: "bottom", top: "top"}
266
- const CAMERA_ROI_POSITION_VALUES: Record<CameraRoiPosition, 0 | 1 | 2> = {center: 0, bottom: 1, top: 2}
237
+
238
+ const localMiniappStorageBackend = {
239
+ get(key: string): unknown | null {
240
+ const result = mmkvStorage.load<unknown>(key)
241
+ return result.is_ok() ? result.value : null
242
+ },
243
+ has(key: string): boolean {
244
+ return mmkvStorage.load<unknown>(key).is_ok()
245
+ },
246
+ set(key: string, value: unknown): void {
247
+ mmkvStorage.save(key, value)
248
+ },
249
+ remove(key: string): void {
250
+ mmkvStorage.remove(key)
251
+ },
252
+ keys(): string[] {
253
+ return mmkvStorage.getAllKeys()
254
+ },
255
+ }
267
256
 
268
257
  // =============================================================================
269
258
  // Declared-permission record helper (for CONNECT_ACK / PERMISSIONS_UPDATE)
@@ -406,11 +395,22 @@ class LocalMiniappRuntime {
406
395
  /** Ref-counted stream subscriptions: stream → set of packageNames. */
407
396
  private streamSubscribers: Map<string, Set<string>> = new Map()
408
397
 
398
+ /** Host observers for the Mentra Live button-capture policy. */
399
+ private buttonPressSubscriberListeners = new Set<(packageNames: string[]) => void>()
400
+
409
401
  /** Guards one-time wiring of the cloud transcript/translation fan-out. */
410
402
  private cloudResultsWired = false
411
403
  private cloudStatusWired = false
412
404
  private cloudAudioSubscriptionSync = new CloudAudioSubscriptionSync()
413
405
 
406
+ /**
407
+ * Per-miniapp language hints from TRANSCRIPTION_CONFIG (issue 021 WP3).
408
+ * Bare ISO 639-1 codes, already registry-validated by the SDK and
409
+ * re-normalized here (defense in depth). Merged (union) into the auto-mode
410
+ * cloud transcription subscription in buildCloudAudioSubscriptions.
411
+ */
412
+ private transcriptionHintsByApp = new Map<string, string[]>()
413
+
414
414
  /** Ping interval handle. */
415
415
  private pingIntervalId: number | null = null
416
416
  private foregroundProbeTimers: Map<string, number> = new Map()
@@ -427,6 +427,7 @@ class LocalMiniappRuntime {
427
427
 
428
428
  /** Pending cloud requests: requestId → packageName that originated the request. */
429
429
  private pendingCloudRequests: Map<string, {packageName: string; envelopeRequestId?: string}> = new Map()
430
+ private speechRuns = new Map<string, SpeechRun>()
430
431
 
431
432
  // Browser fallback token auth — HMAC-signed blob with a phone-local
432
433
  // secret. Both issuer and verifier are the same process, so the
@@ -436,6 +437,99 @@ class LocalMiniappRuntime {
436
437
 
437
438
  private constructor() {}
438
439
 
440
+ /** Snapshot of miniapps actively subscribed to hardware button events. */
441
+ public getButtonPressSubscribers(): string[] {
442
+ return [...(this.streamSubscribers.get(MiniappStreamType.BUTTON_PRESS) ?? [])].sort()
443
+ }
444
+
445
+ /** Observe active hardware-button subscriptions. */
446
+ public onButtonPressSubscribersChanged(listener: (packageNames: string[]) => void): () => void {
447
+ this.buttonPressSubscriberListeners.add(listener)
448
+ return () => this.buttonPressSubscriberListeners.delete(listener)
449
+ }
450
+
451
+ /** Report-safe live miniapp/subscription/resource snapshot. */
452
+ public getDiagnosticSnapshot(nowMs = Date.now()): MiniappRuntimeDiagnosticSnapshot {
453
+ const connectedApps = [...this.connectedApps.entries()]
454
+ .sort(([a], [b]) => a.localeCompare(b))
455
+ .slice(0, DIAGNOSTIC_MAX_LIST_ITEMS)
456
+ .map(([packageName, app]) => ({
457
+ packageName: packageName.slice(0, DIAGNOSTIC_MAX_STRING_LENGTH),
458
+ handshakeComplete: this.handshookApps.has(packageName),
459
+ subscriptions: diagnosticStringList(app.subscriptions),
460
+ lastMessageAgeMs: Math.max(0, nowMs - app.lastPongAt),
461
+ requestedLocationRate: app.requestedLocationRate,
462
+ hasActiveSpeakerStream: app.activeSpeakerStreamId !== undefined,
463
+ ...(app.installedManifest
464
+ ? {
465
+ manifest: buildMiniappManifestSnapshot(app.installedManifest, {
466
+ packageName,
467
+ name: app.installedManifest.name,
468
+ version: app.installedManifest.version,
469
+ type: app.installedManifest.type,
470
+ }),
471
+ }
472
+ : {}),
473
+ }))
474
+ const subscriptionsByStream = Object.fromEntries(
475
+ [...this.streamSubscribers.entries()]
476
+ .filter(([, packageNames]) => packageNames.size > 0)
477
+ .sort(([a], [b]) => a.localeCompare(b))
478
+ .slice(0, DIAGNOSTIC_MAX_LIST_ITEMS)
479
+ .map(([stream, packageNames]) => [
480
+ stream.slice(0, DIAGNOSTIC_MAX_STRING_LENGTH),
481
+ diagnosticStringList(packageNames),
482
+ ]),
483
+ )
484
+
485
+ return {
486
+ connectedApps,
487
+ subscriptionsByStream,
488
+ resources: {
489
+ display: localDisplayManager.getDiagnosticSnapshot(),
490
+ camera: phonePhotoCoordinator.getDiagnosticSnapshot(),
491
+ video: phoneVideoCoordinator.getDiagnosticSnapshot(),
492
+ stream: phoneStreamCoordinator.getDiagnosticSnapshot(),
493
+ cameraFov: phoneCameraFovCoordinator.getDiagnosticSnapshot(),
494
+ },
495
+ }
496
+ }
497
+
498
+ private replaceStreamSubscribers(
499
+ packageName: string,
500
+ previousStreams: Iterable<string>,
501
+ nextStreams: Iterable<string>,
502
+ ) {
503
+ const previousButtonSubscribers = this.getButtonPressSubscribers().join("\u0000")
504
+
505
+ for (const stream of previousStreams) {
506
+ const subscribers = this.streamSubscribers.get(stream)
507
+ if (!subscribers) continue
508
+ subscribers.delete(packageName)
509
+ if (subscribers.size === 0) this.streamSubscribers.delete(stream)
510
+ }
511
+
512
+ for (const stream of nextStreams) {
513
+ let subscribers = this.streamSubscribers.get(stream)
514
+ if (!subscribers) {
515
+ subscribers = new Set()
516
+ this.streamSubscribers.set(stream, subscribers)
517
+ }
518
+ subscribers.add(packageName)
519
+ }
520
+
521
+ const nextButtonSubscribers = this.getButtonPressSubscribers()
522
+ if (previousButtonSubscribers !== nextButtonSubscribers.join("\u0000")) {
523
+ for (const listener of this.buttonPressSubscriberListeners) {
524
+ try {
525
+ listener(nextButtonSubscribers)
526
+ } catch (error) {
527
+ console.warn(`${LOG_TAG}: button press subscriber listener threw:`, error)
528
+ }
529
+ }
530
+ }
531
+ }
532
+
439
533
  /**
440
534
  * Generate an HMAC-signed local session token for browser fallback auth.
441
535
  * Token format: base64(JSON({userId, packageName, exp})).base64(HMAC-SHA256(payload, secret))
@@ -640,6 +734,7 @@ class LocalMiniappRuntime {
640
734
  // dangling references. The WebView will re-subscribe after its CONNECT.
641
735
  if (this.connectedApps.has(packageName)) {
642
736
  const existing = this.connectedApps.get(packageName)!
737
+ this.cancelSpeech(packageName)
643
738
  // A crash respawn replaces the ConnectedMiniapp without going through
644
739
  // unregisterApp(). Abort the old incarnation's native PCM player before
645
740
  // its only stream id is lost. Target the exact stream rather than using
@@ -649,10 +744,7 @@ class LocalMiniappRuntime {
649
744
  if (existingSpeakerStreamId) {
650
745
  existing.activeSpeakerStreamId = undefined
651
746
  void audioPlaybackService.abortStream(existingSpeakerStreamId).catch((error) => {
652
- console.warn(
653
- `${LOG_TAG}: failed to abort speaker stream while replacing ${packageName}`,
654
- error,
655
- )
747
+ console.warn(`${LOG_TAG}: failed to abort speaker stream while replacing ${packageName}`, error)
656
748
  })
657
749
  }
658
750
  if (existing.authRefreshTimerId !== null) {
@@ -663,20 +755,17 @@ class LocalMiniappRuntime {
663
755
  BgTimer.clearTimeout(existing.authRetryTimerId)
664
756
  existing.authRetryTimerId = null
665
757
  }
666
- for (const stream of existing.subscriptions) {
667
- const subs = this.streamSubscribers.get(stream)
668
- if (subs) {
669
- subs.delete(packageName)
670
- if (subs.size === 0) this.streamSubscribers.delete(stream)
671
- }
672
- }
758
+ this.replaceStreamSubscribers(packageName, existing.subscriptions, [])
673
759
  this.recomputeMicRequirements()
674
760
  this.updateCloudSubscriptions()
675
761
  }
676
762
  this.connectedApps.set(packageName, {
677
763
  subscriptions: new Set(),
764
+ forceLocalTranscriptionStreams: new Set(),
765
+ cloudTranscriptionStreams: new Set(),
678
766
  sendMessage: sendFn,
679
767
  lastPongAt: Date.now(),
768
+ unansweredPingRounds: 0,
680
769
  installedManifest,
681
770
  authRefreshTimerId: null,
682
771
  authRetryTimerId: null,
@@ -797,6 +886,7 @@ class LocalMiniappRuntime {
797
886
 
798
887
  public unregisterApp(packageName: string): void {
799
888
  console.log(`${LOG_TAG}: unregisterApp(${packageName})`)
889
+ const releasedMicGateOverride = micStateCoordinator.clearMiniappGateOverrides(packageName)
800
890
  this.clearForegroundProbe(packageName)
801
891
  this.clearMiniappAuthRefresh(packageName)
802
892
  this.clearMiniappAuthDeliveryRetry(packageName)
@@ -805,23 +895,44 @@ class LocalMiniappRuntime {
805
895
  // the early-return so it runs even if the connectedApps entry is already gone.
806
896
  this.handshookApps.delete(packageName)
807
897
  this.flushConnectWaiters(packageName, new Error(`${packageName} unregistered before connect`))
898
+ // Drop this app's transcription hints; the cloud subscription rebuild
899
+ // below (via replaceStreamSubscribers) re-derives the hint union without it.
900
+ this.transcriptionHintsByApp.delete(packageName)
901
+
902
+ // Warm-up is a request-owned camera lease. Cancel it even if the app record was already
903
+ // removed so a close racing the camera-open promise cannot leave the sensor running.
904
+ // Keep camera teardown ordered: restoring FOV can restart the HAL, so the warm-up lease must
905
+ // finish closing the sensor before FOV reconciliation begins. Continue to FOV cleanup even if
906
+ // warm-up cancellation fails; phone ownership remains retryable until the ASG lease TTL.
907
+ void phonePhotoCoordinator
908
+ .stopWarmUpForApp(packageName)
909
+ .catch((error) => {
910
+ console.warn(`${LOG_TAG}: failed to stop camera warm-up for ${packageName} on unregister`, error)
911
+ })
912
+ .then(() => phoneCameraFovCoordinator.releaseForApp(packageName))
913
+ .catch((error) => {
914
+ console.warn(`${LOG_TAG}: failed to release camera FOV override for ${packageName} on unregister`, error)
915
+ })
808
916
  const app = this.connectedApps.get(packageName)
809
- if (!app) return
810
-
811
- // Remove from all stream subscriber sets
812
- for (const stream of app.subscriptions) {
813
- const subs = this.streamSubscribers.get(stream)
814
- if (subs) {
815
- subs.delete(packageName)
816
- if (subs.size === 0) {
817
- this.streamSubscribers.delete(stream)
818
- }
917
+ if (!app) {
918
+ if (releasedMicGateOverride) {
919
+ void micStateCoordinator.syncEffectiveGatePolicy(this.getConfiguredMicGates()).catch((error) => {
920
+ console.warn(`${LOG_TAG}: failed to restore mic gate policy for ${packageName}`, error)
921
+ })
819
922
  }
923
+ return
820
924
  }
821
925
 
926
+ // Remove from all stream subscriber sets
927
+ this.replaceStreamSubscribers(packageName, app.subscriptions, [])
928
+
822
929
  // Reset per-session warning dedup so a relaunch surfaces issues again.
823
930
  resetPermissionWarnings(packageName)
824
931
 
932
+ // Cancel lookahead/in-flight synthesis before stopping the current file so
933
+ // an interrupted sentence cannot advance and reclaim playback.
934
+ this.cancelSpeech(packageName)
935
+
825
936
  // Stop audio for this app
826
937
  audioPlaybackService.stopForApp(packageName)
827
938
 
@@ -880,6 +991,11 @@ class LocalMiniappRuntime {
880
991
 
881
992
  this.connectedApps.delete(packageName)
882
993
  this.recomputeMicRequirements()
994
+ if (releasedMicGateOverride) {
995
+ void micStateCoordinator.syncEffectiveGatePolicy(this.getConfiguredMicGates()).catch((error) => {
996
+ console.warn(`${LOG_TAG}: failed to restore mic gate policy for ${packageName}`, error)
997
+ })
998
+ }
883
999
  this.updateCloudSubscriptions()
884
1000
 
885
1001
  if (this.connectedApps.size === 0) {
@@ -984,6 +1100,9 @@ class LocalMiniappRuntime {
984
1100
  case MiniappRequestType.LOCATION_POLL:
985
1101
  this.handleLocationPoll(packageName, requestId)
986
1102
  break
1103
+ case MiniappRequestType.CALENDAR_LIST_EVENTS:
1104
+ void this.handleCalendarListEvents(packageName, payload, requestId)
1105
+ break
987
1106
  case MiniappRequestType.NAVIGATION_START:
988
1107
  this.navigationHandlers.handleStart(packageName, payload, requestId)
989
1108
  break
@@ -1050,9 +1169,18 @@ class LocalMiniappRuntime {
1050
1169
  case MiniappRequestType.CAMERA_FOV:
1051
1170
  void this.handleCameraFov(packageName, payload, requestId)
1052
1171
  break
1172
+ case MiniappRequestType.SET_WIFI_ADB_STATE:
1173
+ void this.handleSetWifiAdbState(packageName, payload, requestId)
1174
+ break
1053
1175
  case MiniappRequestType.IMU_SET_ENABLED:
1054
1176
  this.handleImuSetEnabled(packageName, payload, requestId)
1055
1177
  break
1178
+ case MiniappRequestType.MIC_SET_VAD_ENABLED:
1179
+ void this.handleMicSetVadEnabled(packageName, payload, requestId)
1180
+ break
1181
+ case MiniappRequestType.MIC_SET_LOUDNESS_GATE_ENABLED:
1182
+ void this.handleMicSetLoudnessGateEnabled(packageName, payload, requestId)
1183
+ break
1056
1184
  case MiniappRequestType.PING:
1057
1185
  // SDK should handle this itself; reply PONG just in case
1058
1186
  this.sendToMiniapp(packageName, {type: MiniappResponseType.PONG}, requestId)
@@ -1060,23 +1188,6 @@ class LocalMiniappRuntime {
1060
1188
  case MiniappResponseType.PONG: {
1061
1189
  // Liveness already touched above for every inbound message; the
1062
1190
  // PONG carries no other action.
1063
- // BGCAP: measure PING→PONG round-trip ONLY here (a real pong), not in
1064
- // handlePong (which fires for every inbound envelope). RTT = how long
1065
- // the miniapp's own JS engine (Crust serial queue) took to answer — a
1066
- // large value means that queue was starved (the transcript→display
1067
- // layer). bgcapLastPingSentAt holds the OLDEST outstanding ping (see
1068
- // doPingRound), so a multi-round background stall reports its full
1069
- // duration on the first pong after resume.
1070
- if (BGCAP_TELEMETRY) {
1071
- const app = this.connectedApps.get(packageName)
1072
- if (app?.bgcapLastPingSentAt != null) {
1073
- const rtt = Date.now() - app.bgcapLastPingSentAt
1074
- if (rtt > 1500) {
1075
- console.log(`BGCAP: ${packageName} pong rtt=${rtt}ms (miniapp JS engine was starved)`)
1076
- }
1077
- app.bgcapLastPingSentAt = undefined
1078
- }
1079
- }
1080
1191
  break
1081
1192
  }
1082
1193
 
@@ -1092,52 +1203,83 @@ class LocalMiniappRuntime {
1092
1203
  case MiniappRequestType.DOWNLOAD:
1093
1204
  this.handleDownload(packageName, payload, requestId)
1094
1205
  break
1206
+ case MiniappRequestType.SCAN_QR:
1207
+ void this.handleScanQr(packageName, payload, requestId)
1208
+ break
1095
1209
 
1096
1210
  // Persistent binary blob storage (session.blob)
1097
1211
  case MiniappRequestType.BLOB_CREATE:
1098
- this.blobStore.handleCreate(packageName, payload, requestId)
1212
+ this.enqueueBlobRequest(packageName, requestId, () =>
1213
+ this.blobStore.handleCreate(packageName, payload, requestId),
1214
+ )
1099
1215
  break
1100
1216
  case MiniappRequestType.BLOB_WRITE:
1101
- this.blobStore.handleWrite(packageName, payload, requestId)
1217
+ this.enqueueBlobRequest(packageName, requestId, () =>
1218
+ this.blobStore.handleWrite(packageName, payload, requestId),
1219
+ )
1102
1220
  break
1103
1221
  case MiniappRequestType.BLOB_COMMIT:
1104
- this.blobStore.handleCommit(packageName, payload, requestId)
1222
+ this.enqueueBlobRequest(packageName, requestId, () =>
1223
+ this.blobStore.handleCommit(packageName, payload, requestId),
1224
+ )
1105
1225
  break
1106
1226
  case MiniappRequestType.BLOB_ABORT:
1107
- this.blobStore.handleAbort(packageName, payload, requestId)
1227
+ this.enqueueBlobRequest(packageName, requestId, () =>
1228
+ this.blobStore.handleAbort(packageName, payload, requestId),
1229
+ )
1108
1230
  break
1109
1231
  case MiniappRequestType.BLOB_SET_FROM_URL:
1110
- void this.blobStore.handleSetFromUrl(packageName, payload, requestId)
1232
+ this.enqueueBlobRequest(packageName, requestId, () =>
1233
+ this.blobStore.handleSetFromUrl(packageName, payload, requestId),
1234
+ )
1111
1235
  break
1112
1236
  case MiniappRequestType.BLOB_IMPORT:
1113
- void this.blobStore.handleImport(packageName, payload, requestId)
1237
+ this.enqueueBlobRequest(packageName, requestId, () =>
1238
+ this.blobStore.handleImport(packageName, payload, requestId),
1239
+ )
1114
1240
  break
1115
1241
  case MiniappRequestType.BLOB_GET:
1116
- this.blobStore.handleGet(packageName, payload, requestId)
1242
+ this.enqueueBlobRequest(packageName, requestId, () => this.blobStore.handleGet(packageName, payload, requestId))
1117
1243
  break
1118
1244
  case MiniappRequestType.BLOB_LIST:
1119
- this.blobStore.handleList(packageName, payload, requestId)
1245
+ this.enqueueBlobRequest(packageName, requestId, () =>
1246
+ this.blobStore.handleList(packageName, payload, requestId),
1247
+ )
1120
1248
  break
1121
1249
  case MiniappRequestType.BLOB_USAGE:
1122
- this.blobStore.handleUsage(packageName, payload, requestId)
1250
+ this.enqueueBlobRequest(packageName, requestId, () =>
1251
+ this.blobStore.handleUsage(packageName, payload, requestId),
1252
+ )
1123
1253
  break
1124
1254
  case MiniappRequestType.BLOB_DELETE:
1125
- this.blobStore.handleDelete(packageName, payload, requestId)
1255
+ this.enqueueBlobRequest(packageName, requestId, () =>
1256
+ this.blobStore.handleDelete(packageName, payload, requestId),
1257
+ )
1126
1258
  break
1127
1259
  case MiniappRequestType.BLOB_CLEAR:
1128
- this.blobStore.handleClear(packageName, payload, requestId)
1260
+ this.enqueueBlobRequest(packageName, requestId, () =>
1261
+ this.blobStore.handleClear(packageName, payload, requestId),
1262
+ )
1129
1263
  break
1130
1264
  case MiniappRequestType.BLOB_OPEN_READ:
1131
- this.blobStore.handleOpenRead(packageName, payload, requestId)
1265
+ this.enqueueBlobRequest(packageName, requestId, () =>
1266
+ this.blobStore.handleOpenRead(packageName, payload, requestId),
1267
+ )
1132
1268
  break
1133
1269
  case MiniappRequestType.BLOB_READ:
1134
- this.blobStore.handleRead(packageName, payload, requestId)
1270
+ this.enqueueBlobRequest(packageName, requestId, () =>
1271
+ this.blobStore.handleRead(packageName, payload, requestId),
1272
+ )
1135
1273
  break
1136
1274
  case MiniappRequestType.BLOB_CLOSE_READ:
1137
- this.blobStore.handleCloseRead(packageName, payload, requestId)
1275
+ this.enqueueBlobRequest(packageName, requestId, () =>
1276
+ this.blobStore.handleCloseRead(packageName, payload, requestId),
1277
+ )
1138
1278
  break
1139
1279
  case MiniappRequestType.BLOB_SHARE:
1140
- void this.blobStore.handleShare(packageName, payload, requestId)
1280
+ this.enqueueBlobRequest(packageName, requestId, () =>
1281
+ this.blobStore.handleShare(packageName, payload, requestId),
1282
+ )
1141
1283
  break
1142
1284
 
1143
1285
  // Cloud-coordinated features
@@ -1194,8 +1336,20 @@ class LocalMiniappRuntime {
1194
1336
  })
1195
1337
  break
1196
1338
 
1339
+ case MiniappRequestType.TRANSCRIPTION_CONFIG:
1340
+ this.handleTranscriptionConfig(packageName, payload, requestId)
1341
+ break
1342
+
1197
1343
  default:
1344
+ // NACK instead of silent drop (issue 021 S3): an SDK that sends a
1345
+ // request this runtime doesn't implement must get a rejected promise,
1346
+ // not a permanently-pending silent gap. One-shots (no requestId) can
1347
+ // only be logged.
1198
1348
  console.warn(`${LOG_TAG}: Unknown request type from ${packageName}: ${requestType}`)
1349
+ this.sendResult(packageName, requestId, false, undefined, {
1350
+ code: MiniappErrorCode.NOT_IMPLEMENTED,
1351
+ message: `Unknown or unimplemented request type: ${requestType}`,
1352
+ })
1199
1353
  break
1200
1354
  }
1201
1355
  }
@@ -1220,6 +1374,7 @@ class LocalMiniappRuntime {
1220
1374
 
1221
1375
  // Update lastPongAt so it doesn't time out right away
1222
1376
  existing.lastPongAt = Date.now()
1377
+ existing.unansweredPingRounds = 0
1223
1378
 
1224
1379
  // Read current glasses capabilities from the settings store
1225
1380
  const defaultWearable = useSettingsStore.getState().getSetting(ISLAND_SETTINGS_KEYS.defaultWearable) as
@@ -1276,9 +1431,12 @@ class LocalMiniappRuntime {
1276
1431
  }
1277
1432
 
1278
1433
  private async requestMiniappAuth(packageName: string, opts?: {minTtlMs?: number}): Promise<MiniappAuthToken | null> {
1279
- const authPackageName = packageName === DEV_APP_PACKAGE_NAME ? getDevAppSourcePackage() : packageName
1280
- if (!authPackageName) return null
1281
- return cloudClientService.getMiniappAuthToken(authPackageName, opts)
1434
+ const authPackageName = getDevAppSourcePackage(packageName) ?? packageName
1435
+ const devAttestation = getDevAppAttestation(packageName) ?? undefined
1436
+ return cloudClientService.getMiniappAuthToken(authPackageName, {
1437
+ ...opts,
1438
+ ...(devAttestation ? {devAttestation} : {}),
1439
+ })
1282
1440
  }
1283
1441
 
1284
1442
  private async handleAuthRefresh(
@@ -1424,18 +1582,65 @@ class LocalMiniappRuntime {
1424
1582
  }
1425
1583
  }
1426
1584
 
1585
+ /**
1586
+ * TRANSCRIPTION_CONFIG (issue 021 WP3): store the miniapp's language hints
1587
+ * and rebuild the cloud subscription set so the auto-mode subscription
1588
+ * carries them. Replies with a REQUEST_RESULT so the SDK's configure()
1589
+ * promise settles: unknown hint values are an error, not a silent drop.
1590
+ */
1591
+ private handleTranscriptionConfig(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
1592
+ const app = this.connectedApps.get(packageName)
1593
+ if (!app) return
1594
+
1595
+ const config = (payload.config ?? {}) as Record<string, unknown>
1596
+ const rawHints = config.languageHints
1597
+ if (rawHints !== undefined && !Array.isArray(rawHints)) {
1598
+ this.sendResult(packageName, requestId, false, undefined, {
1599
+ code: MiniappErrorCode.INTERNAL,
1600
+ message: "languageHints must be an array of language codes",
1601
+ })
1602
+ return
1603
+ }
1604
+
1605
+ if (rawHints === undefined || rawHints.length === 0) {
1606
+ this.transcriptionHintsByApp.delete(packageName)
1607
+ } else {
1608
+ // Defense in depth: the SDK already validated, but the wire is untyped.
1609
+ const hints: string[] = []
1610
+ const invalid: string[] = []
1611
+ for (const raw of rawHints as unknown[]) {
1612
+ const hint = typeof raw === "string" ? toLanguageHint(raw) : null
1613
+ if (hint) hints.push(hint)
1614
+ else invalid.push(String(raw))
1615
+ }
1616
+ if (invalid.length > 0) {
1617
+ this.sendResult(packageName, requestId, false, undefined, {
1618
+ code: MiniappErrorCode.INTERNAL,
1619
+ message: `Unknown language hint(s): ${invalid.join(", ")}`,
1620
+ })
1621
+ return
1622
+ }
1623
+ this.transcriptionHintsByApp.set(packageName, [...new Set(hints)].sort())
1624
+ }
1625
+
1626
+ this.updateCloudSubscriptions()
1627
+ this.sendResult(packageName, requestId, true, {applied: true})
1628
+ }
1629
+
1427
1630
  private handleSubscribe(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
1428
1631
  const app = this.connectedApps.get(packageName)
1429
1632
  if (!app) return
1430
1633
 
1431
1634
  const rawStreams = (payload.subscriptions ?? payload.streams) as
1432
- | (string | {stream: string; rate?: string})[]
1635
+ | (string | {stream: string; rate?: string; forceLocal?: boolean; includeCloud?: boolean})[]
1433
1636
  | undefined
1434
1637
  // Normalize: objects like {stream: "location_stream", rate: "realtime"} → extract stream name
1435
1638
  // "location_stream" is the rate-bearing alias for "location_update".
1436
1639
  // The strictest rate wins when multiple `location_stream` entries appear
1437
1640
  // in one SUBSCRIBE — the same rule we apply across apps.
1438
1641
  let requestedLocationRate: string | null = null
1642
+ const forceLocalTranscriptionStreams = new Set<string>()
1643
+ const cloudTranscriptionStreams = new Set<string>()
1439
1644
  const streams = rawStreams?.map((s) => {
1440
1645
  if (typeof s === "object" && s !== null) {
1441
1646
  if (s.stream === "location_stream") {
@@ -1444,8 +1649,15 @@ class LocalMiniappRuntime {
1444
1649
  }
1445
1650
  return "location_update"
1446
1651
  }
1652
+ if (s.forceLocal === true && s.stream.startsWith("transcription:")) {
1653
+ forceLocalTranscriptionStreams.add(s.stream)
1654
+ }
1655
+ if (s.stream.startsWith("transcription:") && (s.forceLocal !== true || s.includeCloud === true)) {
1656
+ cloudTranscriptionStreams.add(s.stream)
1657
+ }
1447
1658
  return s.stream
1448
1659
  }
1660
+ if (s.startsWith("transcription:")) cloudTranscriptionStreams.add(s)
1449
1661
  return s
1450
1662
  }) as string[] | undefined
1451
1663
  if (!Array.isArray(streams)) {
@@ -1469,7 +1681,6 @@ class LocalMiniappRuntime {
1469
1681
  if (s === "location_update") return "LOCATION"
1470
1682
  if (s === "phone_notification") return "READ_NOTIFICATIONS"
1471
1683
  if (s === "phone_notification_dismissed") return "READ_NOTIFICATIONS"
1472
- if (s === "calendar_event") return "CALENDAR"
1473
1684
  return null
1474
1685
  }
1475
1686
 
@@ -1489,27 +1700,16 @@ class LocalMiniappRuntime {
1489
1700
  }
1490
1701
  }
1491
1702
 
1492
- // Remove old subscriptions for this app
1493
- for (const oldStream of app.subscriptions) {
1494
- const subs = this.streamSubscribers.get(oldStream)
1495
- if (subs) {
1496
- subs.delete(packageName)
1497
- if (subs.size === 0) {
1498
- this.streamSubscribers.delete(oldStream)
1499
- }
1500
- }
1501
- }
1502
-
1503
1703
  // Add new subscriptions
1704
+ const previousSubscriptions = app.subscriptions
1504
1705
  app.subscriptions = new Set(streams)
1505
- for (const stream of streams) {
1506
- let subs = this.streamSubscribers.get(stream)
1507
- if (!subs) {
1508
- subs = new Set()
1509
- this.streamSubscribers.set(stream, subs)
1510
- }
1511
- subs.add(packageName)
1512
- }
1706
+ app.forceLocalTranscriptionStreams = new Set(
1707
+ [...forceLocalTranscriptionStreams].filter((stream) => app.subscriptions.has(stream)),
1708
+ )
1709
+ app.cloudTranscriptionStreams = new Set(
1710
+ [...cloudTranscriptionStreams].filter((stream) => app.subscriptions.has(stream)),
1711
+ )
1712
+ this.replaceStreamSubscribers(packageName, previousSubscriptions, app.subscriptions)
1513
1713
 
1514
1714
  this.recomputeMicRequirements()
1515
1715
  this.updateCloudSubscriptions()
@@ -1527,6 +1727,37 @@ class LocalMiniappRuntime {
1527
1727
  this.emitInitialSnapshots(packageName, streams)
1528
1728
  }
1529
1729
 
1730
+ private async handleCalendarListEvents(
1731
+ packageName: string,
1732
+ payload: Record<string, unknown>,
1733
+ requestId?: string,
1734
+ ): Promise<void> {
1735
+ const app = this.connectedApps.get(packageName)
1736
+ const declared = app?.installedManifest?.permissions?.some(
1737
+ (permission) => permission.type?.toUpperCase() === "CALENDAR",
1738
+ )
1739
+ if (!declared) {
1740
+ logPermissionNotDeclared(packageName, "CALENDAR", "to list calendar events", '{"type": "CALENDAR"}')
1741
+ this.sendResult(packageName, requestId, false, undefined, {
1742
+ code: MiniappErrorCode.PERMISSION_NOT_DECLARED,
1743
+ message: 'CALENDAR permission not declared in miniapp.json. Add {"type": "CALENDAR"} to the permissions array.',
1744
+ permission: "CALENDAR",
1745
+ })
1746
+ return
1747
+ }
1748
+
1749
+ try {
1750
+ const result = await listPhoneCalendarEvents(payload)
1751
+ this.sendResult(packageName, requestId, true, result)
1752
+ } catch (error) {
1753
+ const known = error instanceof PhoneCalendarError
1754
+ this.sendResult(packageName, requestId, false, undefined, {
1755
+ code: known ? error.code : MiniappErrorCode.INTERNAL,
1756
+ message: error instanceof Error ? error.message : "Failed to read phone calendar events",
1757
+ })
1758
+ }
1759
+ }
1760
+
1530
1761
  /**
1531
1762
  * For state-bearing streams (battery, connection), deliver the current value
1532
1763
  * immediately on subscribe. Other streams (button press, transcription, etc.)
@@ -1725,6 +1956,7 @@ class LocalMiniappRuntime {
1725
1956
  const volume = typeof payload.volume === "number" ? payload.volume : 1.0
1726
1957
  const stopOtherAudio = payload.stopOtherAudio !== false
1727
1958
 
1959
+ this.cancelSpeech(packageName)
1728
1960
  this.setSpeakerState(packageName, "loading")
1729
1961
  audioPlaybackService.play(
1730
1962
  {requestId: audioRequestId, audioUrl, appId: packageName, volume, stopOtherAudio},
@@ -1760,6 +1992,7 @@ class LocalMiniappRuntime {
1760
1992
  }
1761
1993
 
1762
1994
  private handleStopAudio(packageName: string, _payload: Record<string, unknown>, requestId?: string): void {
1995
+ this.cancelSpeech(packageName)
1763
1996
  audioPlaybackService.stopForApp(packageName)
1764
1997
  this.setSpeakerState(packageName, "stopped")
1765
1998
  this.sendResult(packageName, requestId, true)
@@ -1965,108 +2198,221 @@ class LocalMiniappRuntime {
1965
2198
  }
1966
2199
 
1967
2200
  private async handleSpeak(packageName: string, payload: Record<string, unknown>, requestId?: string): Promise<void> {
1968
- const text = payload.text as string | undefined
1969
- if (!text) {
2201
+ const rawText = payload.text
2202
+ const rawSentences =
2203
+ typeof rawText === "string"
2204
+ ? [rawText.trim()].filter(Boolean)
2205
+ : Array.isArray(rawText)
2206
+ ? rawText
2207
+ .filter((sentence): sentence is string => typeof sentence === "string")
2208
+ .map((sentence) => sentence.trim())
2209
+ .filter(Boolean)
2210
+ : []
2211
+ const enableSanitization = payload.enableSanitization !== false
2212
+ const sentences = prepareTtsSentences(rawSentences, enableSanitization)
2213
+ if (sentences.length === 0) {
1970
2214
  this.sendResult(packageName, requestId, false, undefined, {
1971
2215
  code: MiniappErrorCode.INTERNAL,
1972
- message: "speak requires text",
2216
+ message: "speak requires text or a non-empty sentence list with speakable content",
1973
2217
  })
1974
2218
  return
1975
2219
  }
2220
+ const cloudText = sentences.join(" ")
1976
2221
 
1977
- try {
1978
- const voice = ((payload.voice_id ?? payload.voice) as string) || "default"
1979
- const audioRequestId = requestId || `tts_${Date.now()}`
1980
- const volume = typeof payload.volume === "number" ? payload.volume : 1.0
1981
- const stopOtherAudio = payload.stopOtherAudio !== false
1982
- const voiceSettings =
1983
- payload.voice_settings && typeof payload.voice_settings === "object"
1984
- ? (payload.voice_settings as Record<string, unknown>)
1985
- : undefined
1986
- const speed = typeof voiceSettings?.speed === "number" ? voiceSettings.speed : undefined
1987
-
1988
- this.setSpeakerState(packageName, "loading")
1989
-
1990
- const voiceExplicit = payload.voice_id !== undefined || payload.voice !== undefined
1991
- const offlineSupportsVoice =
1992
- !voiceExplicit || voice === "default" || ttsModelManager.getAvailableLanguages().some((l) => l.code === voice)
1993
-
1994
- const modelId = typeof payload.model_id === "string" ? payload.model_id : undefined
1995
- const cloudConnected = cloudClientService.isConnected()
1996
- console.log(
1997
- `${LOG_TAG}: TTS decision for ${packageName}: cloudConnected=${cloudConnected}, runtimeTts=yes, offlineVoice=${offlineSupportsVoice}`,
1998
- )
2222
+ const voice = ((payload.voice_id ?? payload.voice) as string) || "default"
2223
+ const audioRequestId = requestId || `tts_${Date.now()}`
2224
+ const volume = typeof payload.volume === "number" ? payload.volume : 1.0
2225
+ const stopOtherAudio = payload.stopOtherAudio !== false
2226
+ const voiceSettings =
2227
+ payload.voice_settings && typeof payload.voice_settings === "object"
2228
+ ? (payload.voice_settings as Record<string, unknown>)
2229
+ : undefined
2230
+ const speed = typeof voiceSettings?.speed === "number" ? voiceSettings.speed : undefined
2231
+ const run: SpeechRun = {id: audioRequestId, cancelled: false}
2232
+
2233
+ this.cancelSpeech(packageName)
2234
+ this.speechRuns.set(packageName, run)
2235
+ this.setSpeakerState(packageName, "loading")
1999
2236
 
2000
- let terminalSent = false
2001
- const sendPlaybackResult = (
2002
- success: boolean,
2003
- error: string | null,
2004
- duration: number | null,
2005
- fallbackErrorMessage: string,
2006
- ) => {
2007
- if (terminalSent) return
2008
- terminalSent = true
2237
+ let terminalSent = false
2238
+ const sendPlaybackResult = (
2239
+ success: boolean,
2240
+ error: string | null,
2241
+ duration: number | null,
2242
+ fallbackErrorMessage: string,
2243
+ completed = success,
2244
+ errorCode: MiniappErrorCode = MiniappErrorCode.TTS_UPSTREAM_ERROR,
2245
+ ) => {
2246
+ if (terminalSent) return
2247
+ terminalSent = true
2248
+
2249
+ // A superseded run still owns its bridge request, but it no longer owns
2250
+ // the package's shared speaker state.
2251
+ const ownsSpeakerState = this.speechRuns.get(packageName) === run
2252
+ if (ownsSpeakerState) this.speechRuns.delete(packageName)
2253
+
2254
+ if (ownsSpeakerState) {
2009
2255
  if (success) {
2010
2256
  this.setSpeakerState(packageName, "stopped", {durationMs: duration ?? undefined})
2011
2257
  } else {
2012
2258
  this.setSpeakerState(packageName, "error", {
2013
- errorCode: MiniappErrorCode.TTS_UPSTREAM_ERROR,
2259
+ errorCode,
2014
2260
  errorMessage: error ?? fallbackErrorMessage,
2015
2261
  durationMs: duration ?? undefined,
2016
2262
  })
2017
2263
  }
2018
- this.sendResult(
2019
- packageName,
2020
- requestId,
2021
- success,
2022
- {completed: success, duration},
2023
- error
2024
- ? {
2025
- code: MiniappErrorCode.TTS_UPSTREAM_ERROR,
2026
- message: error,
2027
- }
2028
- : undefined,
2029
- )
2030
2264
  }
2031
2265
 
2266
+ this.sendResult(
2267
+ packageName,
2268
+ requestId,
2269
+ success,
2270
+ {completed, duration},
2271
+ error
2272
+ ? {
2273
+ code: errorCode,
2274
+ message: error,
2275
+ }
2276
+ : undefined,
2277
+ )
2278
+ }
2279
+ run.onCancelled = () => sendPlaybackResult(true, null, null, "tts playback stopped", false)
2280
+
2281
+ try {
2282
+ const voiceExplicit = payload.voice_id !== undefined || payload.voice !== undefined
2283
+ const offlineSupportsVoice =
2284
+ !voiceExplicit || voice === "default" || ttsModelManager.getAvailableLanguages().some((l) => l.code === voice)
2285
+
2286
+ const modelId = typeof payload.model_id === "string" ? payload.model_id : undefined
2287
+ const forceLocal = payload.forceLocal === true
2288
+ const cloudConnected = cloudClientService.isConnected()
2289
+ console.log(
2290
+ `${LOG_TAG}: TTS decision for ${packageName}: cloudConnected=${cloudConnected}, runtimeTts=yes, offlineVoice=${offlineSupportsVoice}, forceLocal=${forceLocal}`,
2291
+ )
2292
+
2032
2293
  const playOfflineTts = async (reason?: string): Promise<boolean> => {
2294
+ if (run.cancelled) return true
2033
2295
  if (!offlineSupportsVoice || !(await ttsModelManager.isModelAvailable())) {
2034
2296
  return false
2035
2297
  }
2298
+ if (run.cancelled) return true
2299
+
2300
+ const languageCode = ttsModelManager.getAvailableLanguages().some((l) => l.code === voice) ? voice : undefined
2301
+ const offlineSentences = sentences
2302
+
2303
+ if (offlineSentences.length > 1) {
2304
+ let firstSentence: TtsSynthesisResult
2305
+ try {
2306
+ firstSentence = await ttsModelManager.synthesizeToFile(offlineSentences[0], {languageCode, speed})
2307
+ } catch (offlineErr) {
2308
+ if (run.cancelled) return true
2309
+ console.warn(
2310
+ `${LOG_TAG}: offline sentence TTS synthesize failed${reason ? ` after ${reason}` : ""}:`,
2311
+ offlineErr,
2312
+ )
2313
+ return false
2314
+ }
2315
+
2316
+ if (run.cancelled) {
2317
+ await Promise.resolve(firstSentence.cleanup?.())
2318
+ return true
2319
+ }
2320
+ void runSentenceTtsPipeline({
2321
+ sentences: offlineSentences,
2322
+ firstAudio: firstSentence,
2323
+ run,
2324
+ synthesize: (sentence) => ttsModelManager.synthesizeToFile(sentence, {languageCode, speed}),
2325
+ play: (audio, index) =>
2326
+ new Promise((resolve) => {
2327
+ const sentenceRequestId = `${audioRequestId}_sentence_${index}`
2328
+ run.playbackRequestId = sentenceRequestId
2329
+ audioPlaybackService.play(
2330
+ {
2331
+ requestId: sentenceRequestId,
2332
+ audioUrl: audio.audioUrl,
2333
+ appId: packageName,
2334
+ volume,
2335
+ stopOtherAudio,
2336
+ },
2337
+ (_responseId, success, error, duration, completionReason) => {
2338
+ if (run.playbackRequestId === sentenceRequestId) run.playbackRequestId = undefined
2339
+ resolve({success, completed: completionReason === "completed", error, duration})
2340
+ },
2341
+ )
2342
+ }),
2343
+ onCleanupError: (cleanupError) => {
2344
+ console.warn(`${LOG_TAG}: sentence TTS cleanup failed`, cleanupError)
2345
+ },
2346
+ }).then(
2347
+ ({success, completed, error, duration}) => {
2348
+ sendPlaybackResult(success, error, duration, "offline sentence tts playback failed", completed)
2349
+ },
2350
+ (pipelineError: unknown) => {
2351
+ const message = pipelineError instanceof Error ? pipelineError.message : String(pipelineError)
2352
+ sendPlaybackResult(false, message, null, "offline sentence tts playback failed", false)
2353
+ },
2354
+ )
2355
+ queueMicrotask(() => {
2356
+ if (this.speechRuns.get(packageName) === run) this.setSpeakerState(packageName, "playing")
2357
+ })
2358
+ return true
2359
+ }
2036
2360
 
2037
2361
  let offlineGenerated: TtsSynthesisResult | undefined
2038
2362
 
2039
2363
  try {
2040
- const languageCode = ttsModelManager.getAvailableLanguages().some((l) => l.code === voice) ? voice : undefined
2041
- offlineGenerated = await ttsModelManager.synthesizeToFile(text, {languageCode, speed})
2364
+ offlineGenerated = await ttsModelManager.synthesizeToFile(offlineSentences[0], {languageCode, speed})
2042
2365
  } catch (offlineErr) {
2366
+ if (run.cancelled) return true
2043
2367
  console.warn(`${LOG_TAG}: offline TTS synthesize failed${reason ? ` after ${reason}` : ""}:`, offlineErr)
2044
2368
  return false
2045
2369
  }
2046
2370
 
2047
2371
  const generated = offlineGenerated
2372
+ if (run.cancelled) {
2373
+ await Promise.resolve(generated.cleanup?.())
2374
+ return true
2375
+ }
2376
+ run.playbackRequestId = audioRequestId
2048
2377
  audioPlaybackService.play(
2049
- {requestId: audioRequestId, audioUrl: generated.audioUrl, appId: packageName, volume, stopOtherAudio},
2050
- (_respId, success, error, duration) => {
2378
+ {
2379
+ requestId: audioRequestId,
2380
+ audioUrl: generated.audioUrl,
2381
+ appId: packageName,
2382
+ volume,
2383
+ stopOtherAudio,
2384
+ },
2385
+ (_respId, success, error, duration, completionReason) => {
2386
+ if (run.playbackRequestId === audioRequestId) run.playbackRequestId = undefined
2051
2387
  void Promise.resolve(generated.cleanup?.()).catch((cleanupError) => {
2052
2388
  console.warn(`${LOG_TAG}: offline TTS cleanup failed`, cleanupError)
2053
2389
  })
2054
- sendPlaybackResult(success, error, duration, "offline tts playback failed")
2390
+ sendPlaybackResult(
2391
+ success,
2392
+ error,
2393
+ duration,
2394
+ "offline tts playback failed",
2395
+ completionReason === "completed",
2396
+ )
2055
2397
  },
2056
2398
  )
2057
- queueMicrotask(() => this.setSpeakerState(packageName, "playing"))
2399
+ queueMicrotask(() => {
2400
+ if (this.speechRuns.get(packageName) === run) this.setSpeakerState(packageName, "playing")
2401
+ })
2058
2402
  return true
2059
2403
  }
2060
2404
 
2061
2405
  const playCloudTts = async (fallbackToOffline: boolean): Promise<boolean> => {
2406
+ if (run.cancelled) return true
2062
2407
  let source: Awaited<ReturnType<typeof cloudClientService.tts.speak>>
2063
2408
  try {
2064
- source = await cloudClientService.tts.speak(text, {
2409
+ source = await cloudClientService.tts.speak(cloudText, {
2065
2410
  ...(voiceExplicit && voice !== "default" ? {voice_id: voice} : {}),
2066
2411
  ...(modelId ? {model_id: modelId} : {}),
2067
2412
  ...(voiceSettings ? {voice_settings: voiceSettings} : {}),
2068
2413
  })
2069
2414
  } catch (cloudErr) {
2415
+ if (run.cancelled) return true
2070
2416
  const error = cloudErr instanceof Error ? cloudErr.message : String(cloudErr)
2071
2417
  console.warn(`${LOG_TAG}: cloud TTS source failed: ${error}`)
2072
2418
  if (fallbackToOffline && (await playOfflineTts("cloud tts source failed"))) {
@@ -2076,10 +2422,20 @@ class LocalMiniappRuntime {
2076
2422
  return true
2077
2423
  }
2078
2424
 
2425
+ if (run.cancelled) return true
2426
+
2427
+ run.playbackRequestId = audioRequestId
2079
2428
  await Promise.resolve(
2080
2429
  audioPlaybackService.play(
2081
- {requestId: audioRequestId, audioUrl: source.audioUrl, appId: packageName, volume, stopOtherAudio},
2082
- (_respId, success, error, duration) => {
2430
+ {
2431
+ requestId: audioRequestId,
2432
+ audioUrl: source.audioUrl,
2433
+ appId: packageName,
2434
+ volume,
2435
+ stopOtherAudio,
2436
+ },
2437
+ (_respId, success, error, duration, completionReason) => {
2438
+ if (run.playbackRequestId === audioRequestId) run.playbackRequestId = undefined
2083
2439
  if (!success && fallbackToOffline) {
2084
2440
  void playOfflineTts("cloud tts playback failed").then((started) => {
2085
2441
  if (!started) {
@@ -2088,14 +2444,26 @@ class LocalMiniappRuntime {
2088
2444
  })
2089
2445
  return
2090
2446
  }
2091
- sendPlaybackResult(success, error, duration, "tts failed")
2447
+ sendPlaybackResult(success, error, duration, "tts failed", completionReason === "completed")
2092
2448
  },
2093
2449
  ),
2094
2450
  )
2095
- queueMicrotask(() => this.setSpeakerState(packageName, "playing"))
2451
+ queueMicrotask(() => {
2452
+ if (this.speechRuns.get(packageName) === run) this.setSpeakerState(packageName, "playing")
2453
+ })
2096
2454
  return true
2097
2455
  }
2098
2456
 
2457
+ if (forceLocal) {
2458
+ if (await playOfflineTts()) {
2459
+ return
2460
+ }
2461
+ if (run.cancelled) return
2462
+ const message = "tts unavailable: forceLocal requested but offline TTS model is not available"
2463
+ sendPlaybackResult(false, message, null, message, false, MiniappErrorCode.TTS_LOCAL_UNAVAILABLE)
2464
+ return
2465
+ }
2466
+
2099
2467
  if (cloudConnected && (await playCloudTts(true))) {
2100
2468
  return
2101
2469
  }
@@ -2111,28 +2479,23 @@ class LocalMiniappRuntime {
2111
2479
  }
2112
2480
 
2113
2481
  const message = "tts unavailable: no cloud TTS URL or offline TTS model"
2114
- this.setSpeakerState(packageName, "error", {
2115
- errorCode: MiniappErrorCode.TTS_UPSTREAM_ERROR,
2116
- errorMessage: message,
2117
- })
2118
- this.sendResult(packageName, requestId, false, undefined, {
2119
- code: MiniappErrorCode.TTS_UPSTREAM_ERROR,
2120
- message,
2121
- })
2482
+ sendPlaybackResult(false, message, null, message, false)
2122
2483
  } catch (err) {
2123
2484
  console.error(`${LOG_TAG}: speak error:`, err)
2124
2485
  const message = err instanceof Error ? err.message : "TTS error"
2125
- this.setSpeakerState(packageName, "error", {
2126
- errorCode: MiniappErrorCode.TTS_UPSTREAM_ERROR,
2127
- errorMessage: message,
2128
- })
2129
- this.sendResult(packageName, requestId, false, undefined, {
2130
- code: MiniappErrorCode.TTS_UPSTREAM_ERROR,
2131
- message,
2132
- })
2486
+ sendPlaybackResult(false, message, null, "TTS error", false)
2133
2487
  }
2134
2488
  }
2135
2489
 
2490
+ private cancelSpeech(packageName: string): void {
2491
+ const run = this.speechRuns.get(packageName)
2492
+ if (!run) return
2493
+ run.cancelled = true
2494
+ this.speechRuns.delete(packageName)
2495
+ if (run.playbackRequestId) audioPlaybackService.cancelPlayback(run.playbackRequestId)
2496
+ run.onCancelled?.()
2497
+ }
2498
+
2136
2499
  private async handleRgbLed(packageName: string, payload: Record<string, unknown>, requestId?: string): Promise<void> {
2137
2500
  const coerceNumber = (value: unknown, fallback: number): number => {
2138
2501
  const coerced = Number(value)
@@ -2164,7 +2527,17 @@ class LocalMiniappRuntime {
2164
2527
 
2165
2528
  private async handleLocationPoll(packageName: string, requestId?: string): Promise<void> {
2166
2529
  try {
2167
- const {status} = await Location.requestForegroundPermissionsAsync()
2530
+ // Expo's Android requestForegroundPermissionsAsync always delegates to
2531
+ // Activity.requestPermissions(), even when permission is already granted.
2532
+ // Calling it after the user presses Home therefore waits until the Activity
2533
+ // resumes. Read the current grant first, and only enter the prompt flow
2534
+ // while the Activity is actually foregrounded.
2535
+ const permissionStartedAt = Date.now()
2536
+ const permission = await resolveForegroundLocationPermission(Location, () => AppState.currentState)
2537
+ const {status} = permission
2538
+ console.log(
2539
+ `${LOG_TAG}: location poll permission status=${status} appState=${AppState.currentState} elapsed=${Date.now() - permissionStartedAt}ms`,
2540
+ )
2168
2541
  if (status !== "granted") {
2169
2542
  this.sendResult(packageName, requestId, false, undefined, {
2170
2543
  code: MiniappErrorCode.PERMISSION_NOT_DECLARED,
@@ -2177,8 +2550,11 @@ class LocalMiniappRuntime {
2177
2550
  // a fresh fix if the cache is empty. Use Low accuracy on the fresh
2178
2551
  // path so we get a cell/wifi-tower fix in ~1s instead of waiting
2179
2552
  // for full GPS warm-up.
2553
+ const locationStartedAt = Date.now()
2180
2554
  const cached = await Location.getLastKnownPositionAsync({maxAge: 60_000})
2555
+ console.log(`${LOG_TAG}: location poll cache hit=${cached !== null} elapsed=${Date.now() - locationStartedAt}ms`)
2181
2556
  const location = cached ?? (await Location.getCurrentPositionAsync({accuracy: Location.Accuracy.Low}))
2557
+ console.log(`${LOG_TAG}: location poll resolved elapsed=${Date.now() - locationStartedAt}ms`)
2182
2558
 
2183
2559
  this.sendResult(packageName, requestId, true, {
2184
2560
  lat: location.coords.latitude,
@@ -2220,10 +2596,70 @@ class LocalMiniappRuntime {
2220
2596
  private readonly blobStore = new BlobStore({
2221
2597
  sendResult: (packageName, requestId, ok, result, error) =>
2222
2598
  this.sendResult(packageName, requestId, ok, result, error),
2223
- getUserId: () =>
2224
- (useSettingsStore.getState().getSetting(ISLAND_SETTINGS_KEYS.coreToken) as string | undefined) || "anonymous",
2599
+ getUserId: () => cloudClientService.getMentraUserId(),
2600
+ })
2601
+ private readonly blobRequestQueues = new Map<string, Promise<void>>()
2602
+
2603
+ private readonly simpleStorage = new LocalMiniappStorage({
2604
+ backend: localMiniappStorageBackend,
2605
+ getUserId: () => cloudClientService.resolveMentraUserId(),
2225
2606
  })
2226
2607
 
2608
+ /**
2609
+ * BlobStore's file helpers are synchronous once a user namespace is selected,
2610
+ * but first-boot Core auth is not. Queue requests per app behind stable
2611
+ * identity resolution so CREATE/WRITE/COMMIT ordering survives that wait.
2612
+ */
2613
+ private enqueueBlobRequest(
2614
+ packageName: string,
2615
+ requestId: string | undefined,
2616
+ operation: () => Promise<void> | void,
2617
+ ): void {
2618
+ const app = this.connectedApps.get(packageName)
2619
+ const previous = this.blobRequestQueues.get(packageName) ?? Promise.resolve()
2620
+ const queued = previous.then(async () => {
2621
+ try {
2622
+ await cloudClientService.resolveMentraUserId()
2623
+ } catch (err) {
2624
+ console.error(`${LOG_TAG}: blob identity error:`, err)
2625
+ this.sendResult(packageName, requestId, false, undefined, {
2626
+ code: MiniappErrorCode.NOT_CONNECTED,
2627
+ message: "Blob storage is unavailable before sign-in completes",
2628
+ })
2629
+ return
2630
+ }
2631
+
2632
+ // unregisterApp() or a crash-respawn may have replaced this app while
2633
+ // identity resolution was pending. Never replay an old context's request
2634
+ // against its replacement.
2635
+ if (!app || this.connectedApps.get(packageName) !== app) return
2636
+
2637
+ // Invoke in request order, but do not keep the queue occupied for the
2638
+ // lifetime of an async picker, download, or share sheet. CREATE/WRITE/
2639
+ // COMMIT are synchronous and therefore still dispatch strictly in order.
2640
+ try {
2641
+ const pending = operation()
2642
+ if (pending) {
2643
+ void pending.catch((err) => this.handleBlobRequestError(packageName, requestId, err))
2644
+ }
2645
+ } catch (err) {
2646
+ this.handleBlobRequestError(packageName, requestId, err)
2647
+ }
2648
+ })
2649
+ this.blobRequestQueues.set(packageName, queued)
2650
+ void queued.finally(() => {
2651
+ if (this.blobRequestQueues.get(packageName) === queued) this.blobRequestQueues.delete(packageName)
2652
+ })
2653
+ }
2654
+
2655
+ private handleBlobRequestError(packageName: string, requestId: string | undefined, err: unknown): void {
2656
+ console.error(`${LOG_TAG}: blob request error:`, err)
2657
+ this.sendResult(packageName, requestId, false, undefined, {
2658
+ code: MiniappErrorCode.INTERNAL,
2659
+ message: err instanceof Error ? err.message : "Blob storage error",
2660
+ })
2661
+ }
2662
+
2227
2663
  /**
2228
2664
  * Heading is a sensor stream — start the native compass when any mini
2229
2665
  * app is subscribed to "heading_update", stop it when none are.
@@ -2299,12 +2735,6 @@ class LocalMiniappRuntime {
2299
2735
  // Storage helpers
2300
2736
  // ---------------------------------------------------------------------------
2301
2737
 
2302
- private getStorageKeyPrefix(packageName: string): string {
2303
- const userId =
2304
- (useSettingsStore.getState().getSetting(ISLAND_SETTINGS_KEYS.coreToken) as string | undefined) || "anonymous"
2305
- return `mentraos_localstorage_${userId}_${packageName}_`
2306
- }
2307
-
2308
2738
  private async handleStorageGet(
2309
2739
  packageName: string,
2310
2740
  payload: Record<string, unknown>,
@@ -2319,9 +2749,8 @@ class LocalMiniappRuntime {
2319
2749
  })
2320
2750
  return
2321
2751
  }
2322
- const fullKey = this.getStorageKeyPrefix(packageName) + key
2323
- const result = mmkvStorage.load<unknown>(fullKey)
2324
- this.sendResult(packageName, requestId, true, {key, value: result.is_ok() ? result.value : null})
2752
+ const value = await this.simpleStorage.get(packageName, key)
2753
+ this.sendResult(packageName, requestId, true, {key, value})
2325
2754
  } catch (err) {
2326
2755
  console.error(`${LOG_TAG}: storage_get error:`, err)
2327
2756
  this.sendResult(packageName, requestId, false, undefined, {
@@ -2345,8 +2774,7 @@ class LocalMiniappRuntime {
2345
2774
  })
2346
2775
  return
2347
2776
  }
2348
- const fullKey = this.getStorageKeyPrefix(packageName) + key
2349
- mmkvStorage.save(fullKey, payload.value ?? null)
2777
+ await this.simpleStorage.set(packageName, key, payload.value ?? null)
2350
2778
  this.sendResult(packageName, requestId, true)
2351
2779
  } catch (err) {
2352
2780
  console.error(`${LOG_TAG}: storage_set error:`, err)
@@ -2371,8 +2799,7 @@ class LocalMiniappRuntime {
2371
2799
  })
2372
2800
  return
2373
2801
  }
2374
- const fullKey = this.getStorageKeyPrefix(packageName) + key
2375
- mmkvStorage.remove(fullKey)
2802
+ await this.simpleStorage.delete(packageName, key)
2376
2803
  this.sendResult(packageName, requestId, true)
2377
2804
  } catch (err) {
2378
2805
  console.error(`${LOG_TAG}: storage_delete error:`, err)
@@ -2389,9 +2816,7 @@ class LocalMiniappRuntime {
2389
2816
  requestId?: string,
2390
2817
  ): Promise<void> {
2391
2818
  try {
2392
- const prefix = this.getStorageKeyPrefix(packageName)
2393
- const allKeys = mmkvStorage.getAllKeys()
2394
- const keys = allKeys.filter((k) => k.startsWith(prefix)).map((k) => k.slice(prefix.length))
2819
+ const keys = await this.simpleStorage.keys(packageName)
2395
2820
  this.sendResult(packageName, requestId, true, {keys})
2396
2821
  } catch (err) {
2397
2822
  console.error(`${LOG_TAG}: storage_list error:`, err)
@@ -2404,11 +2829,7 @@ class LocalMiniappRuntime {
2404
2829
 
2405
2830
  private async handleStorageClear(packageName: string, requestId?: string): Promise<void> {
2406
2831
  try {
2407
- const prefix = this.getStorageKeyPrefix(packageName)
2408
- const allKeys = mmkvStorage.getAllKeys()
2409
- for (const k of allKeys) {
2410
- if (k.startsWith(prefix)) mmkvStorage.remove(k)
2411
- }
2832
+ await this.simpleStorage.clear(packageName)
2412
2833
  this.sendResult(packageName, requestId, true)
2413
2834
  } catch (err) {
2414
2835
  console.error(`${LOG_TAG}: storage_clear error:`, err)
@@ -2433,9 +2854,8 @@ class LocalMiniappRuntime {
2433
2854
  })
2434
2855
  return
2435
2856
  }
2436
- const fullKey = this.getStorageKeyPrefix(packageName) + key
2437
- const result = mmkvStorage.load<unknown>(fullKey)
2438
- this.sendResult(packageName, requestId, true, {has: result.is_ok()})
2857
+ const has = await this.simpleStorage.has(packageName, key)
2858
+ this.sendResult(packageName, requestId, true, {has})
2439
2859
  } catch (err) {
2440
2860
  console.error(`${LOG_TAG}: storage_has error:`, err)
2441
2861
  this.sendResult(packageName, requestId, false, undefined, {
@@ -2447,17 +2867,7 @@ class LocalMiniappRuntime {
2447
2867
 
2448
2868
  private async handleStorageGetAll(packageName: string, requestId?: string): Promise<void> {
2449
2869
  try {
2450
- const prefix = this.getStorageKeyPrefix(packageName)
2451
- const allKeys = mmkvStorage.getAllKeys()
2452
- const values: Record<string, string> = {}
2453
- for (const k of allKeys) {
2454
- if (!k.startsWith(prefix)) continue
2455
- const r = mmkvStorage.load<unknown>(k)
2456
- if (r.is_ok()) {
2457
- const v = r.value
2458
- values[k.slice(prefix.length)] = typeof v === "string" ? v : String(v ?? "")
2459
- }
2460
- }
2870
+ const values = await this.simpleStorage.getAll(packageName)
2461
2871
  this.sendResult(packageName, requestId, true, {values})
2462
2872
  } catch (err) {
2463
2873
  console.error(`${LOG_TAG}: storage_get_all error:`, err)
@@ -2482,10 +2892,7 @@ class LocalMiniappRuntime {
2482
2892
  })
2483
2893
  return
2484
2894
  }
2485
- const prefix = this.getStorageKeyPrefix(packageName)
2486
- for (const [key, value] of Object.entries(values)) {
2487
- mmkvStorage.save(prefix + key, value ?? null)
2488
- }
2895
+ await this.simpleStorage.setMultiple(packageName, values)
2489
2896
  this.sendResult(packageName, requestId, true)
2490
2897
  } catch (err) {
2491
2898
  console.error(`${LOG_TAG}: storage_set_multiple error:`, err)
@@ -2496,6 +2903,36 @@ class LocalMiniappRuntime {
2496
2903
  }
2497
2904
  }
2498
2905
 
2906
+ // ---------------------------------------------------------------------------
2907
+ // Wi-Fi ADB
2908
+ // ---------------------------------------------------------------------------
2909
+
2910
+ private async handleSetWifiAdbState(
2911
+ packageName: string,
2912
+ payload: Record<string, unknown>,
2913
+ requestId?: string,
2914
+ ): Promise<void> {
2915
+ const result = await handleWifiAdbRequest(packageName, payload, {
2916
+ isSystemPackage: (candidate) => this.isSystemPackage(candidate),
2917
+ setWifiAdbState: async (enabled) => {
2918
+ console.log(`${LOG_TAG}: set_wifi_adb_state enabled=${enabled} from ${packageName}`)
2919
+ try {
2920
+ await BluetoothSdk.setWifiAdbState(enabled)
2921
+ } catch (error) {
2922
+ console.error(`${LOG_TAG}: set_wifi_adb_state error:`, error)
2923
+ throw error
2924
+ }
2925
+ },
2926
+ })
2927
+
2928
+ if (result.ok) {
2929
+ this.sendResult(packageName, requestId, true)
2930
+ return
2931
+ }
2932
+
2933
+ this.sendResult(packageName, requestId, false, undefined, result.error)
2934
+ }
2935
+
2499
2936
  // ---------------------------------------------------------------------------
2500
2937
  // Camera FOV
2501
2938
  // ---------------------------------------------------------------------------
@@ -2524,17 +2961,7 @@ class LocalMiniappRuntime {
2524
2961
  "preset" in request ? `preset=${request.preset}` : `fov=${request.fov} roi=${request.roiPosition ?? "center"}`
2525
2962
  console.log(`${LOG_TAG}: camera_fov_set ${description}`)
2526
2963
 
2527
- // Camera FOV is a direct btsdk call now (was the host-injected `cameraSettings`
2528
- // runtime hook — a pure BluetoothSdk.setCameraFov passthrough with no host coupling).
2529
- const result = await BluetoothSdk.setCameraFov(request)
2530
-
2531
- useSettingsStore
2532
- .getState()
2533
- .setSetting(
2534
- ISLAND_SETTINGS_KEYS.cameraFov,
2535
- {fov: result.fov, roi_position: CAMERA_ROI_POSITION_VALUES[result.roiPosition]},
2536
- false,
2537
- )
2964
+ const result = await phoneCameraFovCoordinator.setOverride(packageName, request)
2538
2965
  this.sendResult(packageName, requestId, true, result)
2539
2966
  } catch (err) {
2540
2967
  console.error(`${LOG_TAG}: camera_fov error:`, err)
@@ -2571,6 +2998,124 @@ class LocalMiniappRuntime {
2571
2998
  }
2572
2999
  }
2573
3000
 
3001
+ /**
3002
+ * Lifecycle-scoped glasses-side VAD override from a miniapp
3003
+ * (session.mic.setVoiceActivityDetectionEnabled). Requires MICROPHONE in the
3004
+ * miniapp manifest. Mentra Live only today — other models no-op via DeviceStore.
3005
+ */
3006
+ private async handleMicSetVadEnabled(
3007
+ packageName: string,
3008
+ payload: Record<string, unknown>,
3009
+ requestId?: string,
3010
+ ): Promise<void> {
3011
+ const app = this.connectedApps.get(packageName)
3012
+ const hasMicPermission = app?.installedManifest?.permissions?.some((p) => p.type === "MICROPHONE")
3013
+ if (!hasMicPermission) {
3014
+ logPermissionNotDeclared(packageName, "MICROPHONE", "to set VAD enabled", `{"type": "MICROPHONE"}`)
3015
+ this.sendResult(packageName, requestId, false, undefined, {
3016
+ code: MiniappErrorCode.PERMISSION_NOT_DECLARED,
3017
+ message: `MICROPHONE permission not declared in miniapp.json. Add {"type": "MICROPHONE"} to the "permissions" array.`,
3018
+ permission: "MICROPHONE",
3019
+ operation: MiniappRequestType.MIC_SET_VAD_ENABLED,
3020
+ })
3021
+ return
3022
+ }
3023
+
3024
+ if (typeof payload.enabled !== "boolean") {
3025
+ this.sendResult(packageName, requestId, false, undefined, {
3026
+ code: MiniappErrorCode.INVALID_ARGUMENT,
3027
+ message: "enabled must be a boolean",
3028
+ })
3029
+ return
3030
+ }
3031
+
3032
+ try {
3033
+ const enabled = payload.enabled
3034
+ console.log(`${LOG_TAG}: mic_set_vad_enabled ${enabled} (by ${packageName})`)
3035
+ await micStateCoordinator.setMiniappGateOverride(packageName, "vad", enabled, this.getConfiguredMicGates())
3036
+ this.sendResult(packageName, requestId, true)
3037
+ } catch (err) {
3038
+ console.error(`${LOG_TAG}: mic_set_vad_enabled error:`, err)
3039
+ this.sendResult(packageName, requestId, false, undefined, {
3040
+ code: MiniappErrorCode.INTERNAL,
3041
+ message: err instanceof Error ? err.message : "VAD set-enabled error",
3042
+ })
3043
+ }
3044
+ }
3045
+
3046
+ /**
3047
+ * Lifecycle-scoped center-mic loudness gate ("Barrier") override from a miniapp
3048
+ * (session.mic.setLoudnessGateEnabled). Requires MICROPHONE in the miniapp
3049
+ * manifest. Mentra Live only today — other models no-op via DeviceStore.
3050
+ */
3051
+ private async handleMicSetLoudnessGateEnabled(
3052
+ packageName: string,
3053
+ payload: Record<string, unknown>,
3054
+ requestId?: string,
3055
+ ): Promise<void> {
3056
+ const app = this.connectedApps.get(packageName)
3057
+ const hasMicPermission = app?.installedManifest?.permissions?.some((p) => p.type === "MICROPHONE")
3058
+ if (!hasMicPermission) {
3059
+ logPermissionNotDeclared(packageName, "MICROPHONE", "to set loudness gate enabled", `{"type": "MICROPHONE"}`)
3060
+ this.sendResult(packageName, requestId, false, undefined, {
3061
+ code: MiniappErrorCode.PERMISSION_NOT_DECLARED,
3062
+ message: `MICROPHONE permission not declared in miniapp.json. Add {"type": "MICROPHONE"} to the "permissions" array.`,
3063
+ permission: "MICROPHONE",
3064
+ operation: MiniappRequestType.MIC_SET_LOUDNESS_GATE_ENABLED,
3065
+ })
3066
+ return
3067
+ }
3068
+
3069
+ if (typeof payload.enabled !== "boolean") {
3070
+ this.sendResult(packageName, requestId, false, undefined, {
3071
+ code: MiniappErrorCode.INVALID_ARGUMENT,
3072
+ message: "enabled must be a boolean",
3073
+ })
3074
+ return
3075
+ }
3076
+
3077
+ try {
3078
+ const enabled = payload.enabled
3079
+ console.log(`${LOG_TAG}: mic_set_loudness_gate_enabled ${enabled} (by ${packageName})`)
3080
+ await micStateCoordinator.setMiniappGateOverride(
3081
+ packageName,
3082
+ "loudnessGate",
3083
+ enabled,
3084
+ this.getConfiguredMicGates(),
3085
+ )
3086
+ this.sendResult(packageName, requestId, true)
3087
+ } catch (err) {
3088
+ console.error(`${LOG_TAG}: mic_set_loudness_gate_enabled error:`, err)
3089
+ this.sendResult(packageName, requestId, false, undefined, {
3090
+ code: MiniappErrorCode.INTERNAL,
3091
+ message: err instanceof Error ? err.message : "Loudness gate set-enabled error",
3092
+ })
3093
+ }
3094
+ }
3095
+
3096
+ /**
3097
+ * session.system.scanQr — host camera overlay. Must not clear miniapp
3098
+ * foreground; the host seam is responsible for presenting a Modal on top.
3099
+ */
3100
+ private async handleScanQr(
3101
+ packageName: string,
3102
+ payload: Record<string, unknown>,
3103
+ requestId?: string,
3104
+ ): Promise<void> {
3105
+ try {
3106
+ const result = await invokeScanQrSeam(getUiSeams().scanQr, payload)
3107
+ this.sendResult(packageName, requestId, true, result)
3108
+ } catch (err) {
3109
+ if (!getUiSeams().scanQr) {
3110
+ console.warn(`${LOG_TAG}: scanQr seam missing — QR overlay is not registered`)
3111
+ }
3112
+ this.sendResult(packageName, requestId, false, undefined, {
3113
+ code: (err as {code?: string} | null | undefined)?.code || MiniappErrorCode.INTERNAL,
3114
+ message: err instanceof Error ? err.message : "QR scan failed",
3115
+ })
3116
+ }
3117
+ }
3118
+
2574
3119
  // ---------------------------------------------------------------------------
2575
3120
  // System utilities (share, open URL, clipboard, download)
2576
3121
  // ---------------------------------------------------------------------------
@@ -2715,10 +3260,20 @@ class LocalMiniappRuntime {
2715
3260
  const result = await phonePhotoCoordinator.takePhoto(packageName, {
2716
3261
  size: payload.size as "low" | "medium" | "high" | "max" | "small" | "large" | "full" | undefined,
2717
3262
  mode: payload.mode as "photo" | "text" | undefined,
3263
+ transferMethod: payload.transferMethod as "auto" | "direct" | "ble" | undefined,
2718
3264
  compress: payload.compress as "none" | "low" | "medium" | "high" | undefined,
2719
3265
  sound: payload.sound as boolean | undefined,
2720
3266
  saveToGallery: payload.saveToGallery as boolean | undefined,
2721
3267
  exposureTimeNs: payload.exposureTimeNs as number | undefined,
3268
+ iso: payload.iso as number | null | undefined,
3269
+ aeExposureDivisor: payload.aeExposureDivisor as number | undefined,
3270
+ isoCap: payload.isoCap as number | undefined,
3271
+ noiseReduction: payload.noiseReduction as boolean | undefined,
3272
+ edgeEnhancement: payload.edgeEnhancement as boolean | undefined,
3273
+ zsl: payload.zsl as boolean | undefined,
3274
+ mfnr: payload.mfnr as boolean | undefined,
3275
+ ispDigitalGain: payload.ispDigitalGain as number | undefined,
3276
+ ispAnalogGain: payload.ispAnalogGain as string | undefined,
2722
3277
  })
2723
3278
  this.sendResult(packageName, requestId, true, result)
2724
3279
  } catch (err) {
@@ -2753,8 +3308,11 @@ class LocalMiniappRuntime {
2753
3308
  try {
2754
3309
  await phonePhotoCoordinator.warmUpCamera(packageName, {
2755
3310
  size: payload.size as "low" | "medium" | "high" | "max" | undefined,
3311
+ mode: payload.mode as "photo" | "text" | undefined,
2756
3312
  exposureTimeNs: payload.exposureTimeNs as number | undefined,
2757
3313
  durationMs: payload.durationMs as number | undefined,
3314
+ zsl: payload.zsl as boolean | undefined,
3315
+ mfnr: payload.mfnr as boolean | undefined,
2758
3316
  })
2759
3317
  this.sendResult(packageName, requestId, true)
2760
3318
  } catch (err) {
@@ -2847,6 +3405,7 @@ class LocalMiniappRuntime {
2847
3405
  video: normalizeStreamVideoConfig(payload.video),
2848
3406
  audio: normalizeStreamAudioConfig(payload.audio),
2849
3407
  sound: payload.sound as boolean | undefined,
3408
+ authToken: typeof payload.authToken === "string" ? payload.authToken : undefined,
2850
3409
  })
2851
3410
  this.sendResult(packageName, requestId, true, result)
2852
3411
  } catch (err) {
@@ -2942,7 +3501,7 @@ class LocalMiniappRuntime {
2942
3501
  return
2943
3502
  }
2944
3503
  try {
2945
- await requestWifiSetup(payload.reason as string | undefined)
3504
+ await requestWifiSetup(payload.reason as string | undefined, packageName)
2946
3505
  this.sendResult(packageName, requestId, true)
2947
3506
  } catch (err) {
2948
3507
  this.sendResult(packageName, requestId, false, undefined, {
@@ -3006,27 +3565,12 @@ class LocalMiniappRuntime {
3006
3565
  }
3007
3566
  }
3008
3567
 
3009
- // Remove old subscriptions for this app
3010
- for (const oldStream of app.subscriptions) {
3011
- const subs = this.streamSubscribers.get(oldStream)
3012
- if (subs) {
3013
- subs.delete(packageName)
3014
- if (subs.size === 0) {
3015
- this.streamSubscribers.delete(oldStream)
3016
- }
3017
- }
3018
- }
3019
-
3020
3568
  // Add new subscriptions
3569
+ const previousSubscriptions = app.subscriptions
3021
3570
  app.subscriptions = new Set(streams)
3022
- for (const stream of streams) {
3023
- let subs = this.streamSubscribers.get(stream)
3024
- if (!subs) {
3025
- subs = new Set()
3026
- this.streamSubscribers.set(stream, subs)
3027
- }
3028
- subs.add(packageName)
3029
- }
3571
+ app.forceLocalTranscriptionStreams.clear()
3572
+ app.cloudTranscriptionStreams = new Set(streams.filter((stream) => stream.startsWith("transcription:")))
3573
+ this.replaceStreamSubscribers(packageName, previousSubscriptions, app.subscriptions)
3030
3574
 
3031
3575
  this.recomputeMicRequirements()
3032
3576
  this.updateCloudSubscriptions()
@@ -3037,6 +3581,16 @@ class LocalMiniappRuntime {
3037
3581
  // Mic requirements
3038
3582
  // ===========================================================================
3039
3583
 
3584
+ private getConfiguredMicGates(): {vadEnabled: boolean | null; loudnessGateEnabled: boolean | null} {
3585
+ const bluetoothSettings = useSettingsStore.getState().getBluetoothSettings()
3586
+ const configuredVad = bluetoothSettings.voice_activity_detection_enabled
3587
+ const configuredLoudnessGate = bluetoothSettings.loudness_gate_enabled
3588
+ return {
3589
+ vadEnabled: typeof configuredVad === "boolean" ? configuredVad : null,
3590
+ loudnessGateEnabled: typeof configuredLoudnessGate === "boolean" ? configuredLoudnessGate : null,
3591
+ }
3592
+ }
3593
+
3040
3594
  private recomputeMicRequirements(): void {
3041
3595
  let anyPcm = false
3042
3596
  let anyLc3 = false
@@ -3045,7 +3599,11 @@ class LocalMiniappRuntime {
3045
3599
  if (stream === "audio_chunk") anyPcm = true
3046
3600
  if (stream.startsWith("transcription:") || stream.startsWith("translation:") || stream === "vad") anyLc3 = true
3047
3601
  }
3048
- micStateCoordinator.setLocalRequirements({pcm: anyPcm, lc3: anyLc3})
3602
+ micStateCoordinator.setLocalRequirements({
3603
+ pcm: anyPcm,
3604
+ lc3: anyLc3,
3605
+ ...this.getConfiguredMicGates(),
3606
+ })
3049
3607
  }
3050
3608
 
3051
3609
  /**
@@ -3058,19 +3616,34 @@ class LocalMiniappRuntime {
3058
3616
  private updateCloudSubscriptions(): void {
3059
3617
  const cloudStreams = new Set<string>()
3060
3618
  let transcriptionLang: string | null = null
3619
+ let forceLocalTranscriptionLang: string | null = null
3620
+ let hasForceLocalTranscription = false
3061
3621
  for (const [stream, subscribers] of this.streamSubscribers) {
3062
3622
  if (subscribers.size === 0) continue
3063
- // Only transcription / translation need cloud delivery. Location,
3064
- // notifications, and calendar events are sourced natively on the phone
3065
- // and forwarded to miniapps directly via MantleManager — no cloud hop.
3066
- if (stream.startsWith("transcription:") || stream.startsWith("translation:")) {
3623
+ // Only transcription / translation need cloud delivery. Location and
3624
+ // notifications are sourced natively on the phone — no cloud hop.
3625
+ if (stream.startsWith("translation:")) {
3067
3626
  cloudStreams.add(stream)
3068
3627
  }
3069
- if (stream.startsWith("transcription:") && transcriptionLang === null) {
3070
- transcriptionLang = stream.substring("transcription:".length)
3628
+ if (stream.startsWith("transcription:")) {
3629
+ const language = stream.substring("transcription:".length)
3630
+ if (transcriptionLang === null) transcriptionLang = language
3631
+
3632
+ const routes = summarizeTranscriptionRoutes(subscribers, (packageName) =>
3633
+ this.appTranscriptionRoutesForSubscription(packageName, stream),
3634
+ )
3635
+ if (routes.hasForceLocalSubscriber) {
3636
+ hasForceLocalTranscription = true
3637
+ if (forceLocalTranscriptionLang === null) forceLocalTranscriptionLang = language
3638
+ }
3639
+ if (routes.hasCloudSubscriber) cloudStreams.add(stream)
3071
3640
  }
3072
3641
  }
3073
- localSttFallbackCoordinator.onSubscriptionChange(transcriptionLang !== null, transcriptionLang)
3642
+ localSttFallbackCoordinator.onSubscriptionChange(
3643
+ transcriptionLang !== null,
3644
+ forceLocalTranscriptionLang ?? transcriptionLang,
3645
+ hasForceLocalTranscription,
3646
+ )
3074
3647
 
3075
3648
  // Mirror the same set as typed AudioSubscription[] and push it to the cloud
3076
3649
  // runtime.
@@ -3108,12 +3681,21 @@ class LocalMiniappRuntime {
3108
3681
  */
3109
3682
  private buildCloudAudioSubscriptions(cloudStreams: Set<string>): AudioSubscription[] {
3110
3683
  const subs: AudioSubscription[] = []
3684
+ // Union of every connected app's TRANSCRIPTION_CONFIG hints. Hints only
3685
+ // apply to the auto-mode TRANSCRIPTION subscription (the wire schema has
3686
+ // no hints field on specific-language sources — the language IS the hint
3687
+ // there), and are deliberately NOT attached to translation sources.
3688
+ const hintUnion = [...new Set([...this.transcriptionHintsByApp.values()].flat())].sort()
3111
3689
  const langSource = (code: string): LanguageSource =>
3112
3690
  code === "auto" || code === "*" ? {mode: "auto"} : {mode: "specific", code}
3113
3691
  for (const stream of cloudStreams) {
3114
3692
  if (stream.startsWith("transcription:")) {
3115
3693
  const lang = stream.substring("transcription:".length)
3116
- subs.push({kind: "transcription", language: langSource(lang)})
3694
+ const source = langSource(lang)
3695
+ subs.push({
3696
+ kind: "transcription",
3697
+ language: source.mode === "auto" && hintUnion.length > 0 ? {mode: "auto", hints: hintUnion} : source,
3698
+ })
3117
3699
  } else if (stream.startsWith("translation:")) {
3118
3700
  const parts = stream.split(":")
3119
3701
  // Only `translation:<source>:<target>` maps cleanly; a concrete target
@@ -3168,21 +3750,25 @@ class LocalMiniappRuntime {
3168
3750
  } text="${d.text.slice(0, 48)}"`,
3169
3751
  )
3170
3752
  }
3171
- this.forwardEvent(`transcription:${d.resolvedLanguage}`, {
3172
- type: "transcription",
3173
- text: d.text,
3174
- isFinal: d.isFinal,
3175
- utteranceId: d.utteranceId,
3176
- transcribeLanguage: d.resolvedLanguage,
3177
- detectedLanguage: d.languageDetected ? d.resolvedLanguage : undefined,
3178
- startTime: d.startMs,
3179
- endTime: d.endMs,
3180
- speakerId: d.speakerId,
3181
- duration: d.durationMs,
3182
- provider: d.provider,
3183
- confidence: d.confidence,
3184
- __hostReceivedAt: receivedAt,
3185
- })
3753
+ this.forwardEvent(
3754
+ `transcription:${d.resolvedLanguage}`,
3755
+ {
3756
+ type: "transcription",
3757
+ text: d.text,
3758
+ isFinal: d.isFinal,
3759
+ utteranceId: d.utteranceId,
3760
+ transcribeLanguage: d.resolvedLanguage,
3761
+ detectedLanguage: d.languageDetected ? d.resolvedLanguage : undefined,
3762
+ startTime: d.startMs,
3763
+ endTime: d.endMs,
3764
+ speakerId: d.speakerId,
3765
+ duration: d.durationMs,
3766
+ provider: d.provider,
3767
+ confidence: d.confidence,
3768
+ __hostReceivedAt: receivedAt,
3769
+ },
3770
+ "cloud",
3771
+ )
3186
3772
  })
3187
3773
 
3188
3774
  cloudClientService.onTranslation((d: TranslationData) => {
@@ -3265,7 +3851,7 @@ class LocalMiniappRuntime {
3265
3851
  * - Cloud sends "head_up" → miniapp protocol uses "head_position" (HEAD_POSITION)
3266
3852
  * - Cloud sends "VAD" (uppercase) → miniapp protocol uses "vad" (lowercase)
3267
3853
  */
3268
- public forwardEvent(streamType: string, data: unknown): void {
3854
+ public forwardEvent(streamType: string, data: unknown, transcriptionSource?: TranscriptionEventSource): void {
3269
3855
  // Translate cloud event names to miniapp protocol stream types
3270
3856
  const normalizedStream = this.normalizeStreamType(streamType)
3271
3857
 
@@ -3340,16 +3926,14 @@ class LocalMiniappRuntime {
3340
3926
 
3341
3927
  if (matchedSubs.size === 0 && !perGestureStream) return
3342
3928
 
3343
- // BGCAP: count transcripts only once we know they're actually being handed
3344
- // to a subscriber. If the captions miniapp is unregistered/respawning (or
3345
- // hasn't subscribed yet) the cloud may still push transcripts that match no
3346
- // one — counting those would falsely show "forwarded" during the exact gap
3347
- // the diagnostic is meant to expose.
3348
- if (BGCAP_TELEMETRY && normalizedStream.startsWith("transcription:")) {
3349
- bgcapNoteTranscriptForward(!!(data as {isFinal?: boolean} | null)?.isFinal)
3350
- }
3351
-
3352
3929
  for (const packageName of matchedSubs) {
3930
+ let transcriptionRoute: "default" | "forceLocal" | "all" | undefined
3931
+ if (normalizedStream.startsWith("transcription:") && transcriptionSource) {
3932
+ const routes = this.appTranscriptionRoutesForEvent(packageName, normalizedStream)
3933
+ transcriptionRoute =
3934
+ transcriptionDeliveryRoute(transcriptionSource, routes, cloudClientService.isConnected()) ?? undefined
3935
+ if (!transcriptionRoute) continue
3936
+ }
3353
3937
  if (TRANSCRIPT_TIMING_TELEMETRY && normalizedStream.startsWith("transcription:")) {
3354
3938
  const transcript = data as {text?: string; isFinal?: boolean; __hostReceivedAt?: number} | null
3355
3939
  const now = Date.now()
@@ -3371,6 +3955,7 @@ class LocalMiniappRuntime {
3371
3955
  type: MiniappResponseType.EVENT,
3372
3956
  streamType: normalizedStream,
3373
3957
  data: outboundData,
3958
+ ...(transcriptionRoute ? {transcriptionRoute} : {}),
3374
3959
  })
3375
3960
  }
3376
3961
 
@@ -3390,6 +3975,38 @@ class LocalMiniappRuntime {
3390
3975
  }
3391
3976
  }
3392
3977
 
3978
+ private appTranscriptionRoutesForSubscription(
3979
+ packageName: string,
3980
+ stream: string,
3981
+ ): {
3982
+ cloud: boolean
3983
+ forceLocal: boolean
3984
+ } {
3985
+ const app = this.connectedApps.get(packageName)
3986
+ return {
3987
+ cloud: app?.cloudTranscriptionStreams.has(stream) === true,
3988
+ forceLocal: app?.forceLocalTranscriptionStreams.has(stream) === true,
3989
+ }
3990
+ }
3991
+
3992
+ private appTranscriptionRoutesForEvent(
3993
+ packageName: string,
3994
+ stream: string,
3995
+ ): {
3996
+ cloud: boolean
3997
+ forceLocal: boolean
3998
+ } {
3999
+ const app = this.connectedApps.get(packageName)
4000
+ const autoStream = `${MiniappStreamType.TRANSCRIPTION}:auto`
4001
+ return {
4002
+ cloud:
4003
+ app?.cloudTranscriptionStreams.has(stream) === true || app?.cloudTranscriptionStreams.has(autoStream) === true,
4004
+ forceLocal:
4005
+ app?.forceLocalTranscriptionStreams.has(stream) === true ||
4006
+ app?.forceLocalTranscriptionStreams.has(autoStream) === true,
4007
+ }
4008
+ }
4009
+
3393
4010
  /**
3394
4011
  * Translate cloud event names to miniapp stream type values.
3395
4012
  */
@@ -3934,7 +4551,6 @@ class LocalMiniappRuntime {
3934
4551
  // ===========================================================================
3935
4552
 
3936
4553
  private ensurePingLoop(): void {
3937
- bgcapStartHeartbeat() // BGCAP diagnostic
3938
4554
  if (this.pingIntervalId !== null) return
3939
4555
 
3940
4556
  this.pingIntervalId = BgTimer.setInterval(() => {
@@ -3943,7 +4559,6 @@ class LocalMiniappRuntime {
3943
4559
  }
3944
4560
 
3945
4561
  private stopPingLoop(): void {
3946
- bgcapStopHeartbeat() // BGCAP diagnostic
3947
4562
  if (this.pingIntervalId !== null) {
3948
4563
  BgTimer.clearInterval(this.pingIntervalId)
3949
4564
  this.pingIntervalId = null
@@ -3951,23 +4566,18 @@ class LocalMiniappRuntime {
3951
4566
  }
3952
4567
 
3953
4568
  private doPingRound(): void {
3954
- const now = Date.now()
3955
- const staleThreshold = PING_INTERVAL_MS * PING_TIMEOUT_THRESHOLD
3956
-
3957
4569
  const toRemove: string[] = []
3958
4570
 
3959
4571
  for (const [packageName, app] of this.connectedApps) {
3960
- if (now - app.lastPongAt > staleThreshold) {
4572
+ const liveness = advanceMiniappPingLiveness(app.unansweredPingRounds, PING_TIMEOUT_THRESHOLD)
4573
+ if (liveness.shouldUnregister) {
3961
4574
  console.warn(`${LOG_TAG}: ${packageName} missed ${PING_TIMEOUT_THRESHOLD} pings, unregistering`)
3962
4575
  toRemove.push(packageName)
3963
4576
  continue
3964
4577
  }
4578
+ app.unansweredPingRounds = liveness.unansweredPingRounds
3965
4579
 
3966
4580
  // Send PING — SDK auto-replies with PONG
3967
- // BGCAP: stamp the OLDEST outstanding ping only — don't overwrite, or a
3968
- // background stall (pings keep firing on the native BgTimer while the
3969
- // miniapp engine is frozen) would reset the clock and hide its duration.
3970
- if (BGCAP_TELEMETRY && app.bgcapLastPingSentAt == null) app.bgcapLastPingSentAt = now
3971
4581
  this.sendToMiniapp(packageName, {
3972
4582
  type: MiniappRequestType.PING,
3973
4583
  })
@@ -3989,6 +4599,7 @@ class LocalMiniappRuntime {
3989
4599
  const app = this.connectedApps.get(packageName)
3990
4600
  if (app) {
3991
4601
  app.lastPongAt = Date.now()
4602
+ app.unansweredPingRounds = 0
3992
4603
  this.clearForegroundProbe(packageName)
3993
4604
  }
3994
4605
  }
@@ -4016,7 +4627,17 @@ class LocalMiniappRuntime {
4016
4627
 
4017
4628
  // Belt-and-suspenders: clear any remaining state
4018
4629
  this.pendingCloudRequests.clear()
4630
+ const hadButtonPressSubscribers = this.getButtonPressSubscribers().length > 0
4019
4631
  this.streamSubscribers.clear()
4632
+ if (hadButtonPressSubscribers) {
4633
+ for (const listener of this.buttonPressSubscriberListeners) {
4634
+ try {
4635
+ listener([])
4636
+ } catch (error) {
4637
+ console.warn(`${LOG_TAG}: button press subscriber listener threw during cleanup:`, error)
4638
+ }
4639
+ }
4640
+ }
4020
4641
  this.connectedApps.clear()
4021
4642
  for (const timerId of this.foregroundProbeTimers.values()) {
4022
4643
  BgTimer.clearTimeout(timerId)