@mentra/bluetooth-sdk 3.2.1-dev.314 → 3.3.0-beta.942

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 (191) hide show
  1. package/README.md +72 -10
  2. package/android/build.gradle +15 -0
  3. package/android/lc3Lib/build.gradle +6 -1
  4. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  5. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +4 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +8 -2
  11. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +133 -26
  15. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  16. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +3 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  18. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +227 -36
  19. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  22. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  24. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  31. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
  32. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +194 -40
  33. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  34. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  35. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +247 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  37. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  42. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  43. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  45. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  48. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  50. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  51. package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
  52. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
  53. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  54. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  55. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  56. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
  57. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  58. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
  59. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
  62. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  63. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  64. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
  65. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  66. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  67. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  68. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  69. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +233 -0
  70. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  71. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  72. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  73. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  74. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  75. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  76. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  77. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  78. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  79. package/build/BluetoothSdk.types.d.ts +16 -0
  80. package/build/BluetoothSdk.types.d.ts.map +1 -1
  81. package/build/BluetoothSdk.types.js +1 -0
  82. package/build/BluetoothSdk.types.js.map +1 -1
  83. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  84. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  85. package/build/_private/BluetoothSdkModule.js +5 -1
  86. package/build/_private/BluetoothSdkModule.js.map +1 -1
  87. package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
  88. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  89. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  90. package/build/_private/nativeLogConsole.d.ts +19 -0
  91. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  92. package/build/_private/nativeLogConsole.js +12 -0
  93. package/build/_private/nativeLogConsole.js.map +1 -1
  94. package/build/generated/changelogCatalog.d.ts +5 -2
  95. package/build/generated/changelogCatalog.d.ts.map +1 -1
  96. package/build/generated/changelogCatalog.js +6 -2
  97. package/build/generated/changelogCatalog.js.map +1 -1
  98. package/build/generated/releaseMetadata.js +6 -6
  99. package/build/generated/releaseMetadata.js.map +1 -1
  100. package/build/index.d.ts.map +1 -1
  101. package/build/index.js +1 -0
  102. package/build/index.js.map +1 -1
  103. package/build/ota-transport/index.d.ts +4 -0
  104. package/build/ota-transport/index.d.ts.map +1 -1
  105. package/build/ota-transport/index.js +2 -0
  106. package/build/ota-transport/index.js.map +1 -1
  107. package/ios/BluetoothSdkModule.swift +17 -4
  108. package/ios/MentraBluetoothSDK.podspec +3 -2
  109. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  110. package/ios/Source/BluetoothSdkDefaults.swift +7 -2
  111. package/ios/Source/Bridge.swift +15 -2
  112. package/ios/Source/DeviceManager.swift +102 -66
  113. package/ios/Source/DeviceStore.swift +26 -7
  114. package/ios/Source/GeneratedChangelogCatalog.swift +3 -2
  115. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  116. package/ios/Source/MentraBluetoothSDK.swift +313 -54
  117. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  118. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  119. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  120. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  121. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  122. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  123. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  124. package/ios/Source/requests/DisplayRequests.swift +1 -1
  125. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  126. package/ios/Source/sgcs/Ar99.swift +1 -2
  127. package/ios/Source/sgcs/G1.swift +68 -38
  128. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  129. package/ios/Source/sgcs/G2.swift +261 -247
  130. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  131. package/ios/Source/sgcs/Mach1.swift +19 -7
  132. package/ios/Source/sgcs/MentraLive.swift +272 -197
  133. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  134. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  135. package/ios/Source/sgcs/MentraNex.swift +8 -31
  136. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  137. package/ios/Source/sgcs/Nimo.swift +280 -408
  138. package/ios/Source/sgcs/NimoCanvasCodec.swift +272 -0
  139. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  140. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  141. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  142. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  143. package/ios/Source/sgcs/SGCManager.swift +25 -1
  144. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  145. package/ios/Source/status/DeviceStatus.swift +1 -0
  146. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  147. package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
  148. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  149. package/ios/Source/utils/G1Text.swift +2 -24
  150. package/ios/Source/utils/G1TextSanitizer.swift +27 -0
  151. package/ios/Source/utils/G2Text.swift +11 -0
  152. package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
  153. package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
  154. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  155. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  156. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  157. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  158. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  159. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  160. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  161. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  162. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  163. package/ios/Tests/G1PairingTests.swift +162 -0
  164. package/ios/Tests/G1TextSanitizerTests.swift +32 -0
  165. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  166. package/ios/Tests/G2TextTests.swift +10 -0
  167. package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
  168. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  169. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  170. package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
  171. package/ios/Tests/NimoCanvasCodecTests.swift +217 -0
  172. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  173. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  174. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  175. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  176. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  177. package/ios/Tests/VersionInfoCommandTests.swift +194 -0
  178. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  179. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  180. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  181. package/package.json +2 -2
  182. package/plugin/build/withIos.d.ts +2 -0
  183. package/plugin/build/withIos.js +12 -1
  184. package/src/BluetoothSdk.types.ts +15 -0
  185. package/src/_private/BluetoothSdkModule.ts +10 -1
  186. package/src/_private/MentraLocalNetworkModule.ts +28 -0
  187. package/src/_private/nativeLogConsole.ts +19 -0
  188. package/src/generated/changelogCatalog.ts +6 -2
  189. package/src/generated/releaseMetadata.ts +6 -6
  190. package/src/index.ts +1 -0
  191. package/src/ota-transport/index.ts +10 -1
@@ -8,6 +8,7 @@ import android.content.Context
8
8
  import android.os.Bundle
9
9
  import android.os.Handler
10
10
  import android.os.Looper
11
+ import android.util.Log
11
12
  import com.mentra.bluetoothsdk.streaming.StreamSessionState
12
13
  import com.mentra.bluetoothsdk.utils.ControllerTypes
13
14
  import com.mentra.bluetoothsdk.utils.PhoneAudioMonitor
@@ -34,6 +35,21 @@ class MentraBluetoothSdk private constructor(
34
35
  private val listeners =
35
36
  Collections.synchronizedSet(mutableSetOf<MentraBluetoothSdkListener>())
36
37
  private val analytics = BluetoothSdkAnalytics(appContext, config.analytics)
38
+ private val updateCheck =
39
+ BluetoothSdkUpdateCheck(appContext, config.analytics.surface) { banner, result ->
40
+ Log.w(UPDATE_LOG_TAG, banner)
41
+ dispatchToListeners {
42
+ it.onRawEvent(
43
+ "sdk_update_notice",
44
+ buildMap {
45
+ put("message", banner)
46
+ put("status", result.status)
47
+ put("installedVersion", BuildConfig.SDK_VERSION)
48
+ result.latestVersion?.let { latest -> put("latestVersion", latest) }
49
+ },
50
+ )
51
+ }
52
+ }
37
53
  private val discoveredDeviceNames = mutableSetOf<String>()
38
54
  private val bridgeEventSinkId: String
39
55
  private val storeListenerId: String
@@ -99,6 +115,7 @@ class MentraBluetoothSdk private constructor(
99
115
  analytics.initializeGlassesStatus(getRawGlassesStatus())
100
116
  storeListenerId = DeviceStore.store.addListener { category, changes -> dispatchStoreUpdate(category, changes) }
101
117
  analytics.captureStarted()
118
+ updateCheck.start()
102
119
  }
103
120
 
104
121
  companion object {
@@ -106,6 +123,8 @@ class MentraBluetoothSdk private constructor(
106
123
  private val SCAN_STATE_KEYS = setOf("searching", "searchingController", "searchResults")
107
124
  private const val DEFAULT_SCAN_TIMEOUT_MS = 15_000L
108
125
  private const val DEFAULT_REQUEST_TIMEOUT_MS = 15_000L
126
+ private const val UPDATE_LOG_TAG = "MentraBluetoothSDK"
127
+ private val MIC_STATE_KEYS = setOf("currentMic", "should_send_transcript", "local_stt_fallback_active")
109
128
  // Mirrors AsgConstants: one active + one coalesced pending FOV update, plus BLE margin.
110
129
  // Keep in sync with ios/Source/MentraBluetoothSDK.swift and the ASG readiness budget.
111
130
  private const val CAMERA_FOV_READY_TIMEOUT_MS = 20_000L
@@ -240,8 +259,10 @@ class MentraBluetoothSdk private constructor(
240
259
  // (expo.modules.AsyncFunctionQueue), so a blocked await here froze all
241
260
  // miniapp event delivery (Crust.mentraJsDispatchToJs) until the glasses
242
261
  // responded. resolve()/reject() stay callable from any thread.
243
- private class PendingResponse<T>(
262
+ private inner class PendingResponse<T>(
244
263
  private val operation: String,
264
+ // The operation without per-request ids, so timeouts group in analytics.
265
+ private val telemetryOperation: String = operation,
245
266
  ) {
246
267
  private val deferred = CompletableDeferred<T>()
247
268
 
@@ -258,10 +279,13 @@ class MentraBluetoothSdk private constructor(
258
279
  // can only mean the deadline elapsed. A rejected deferred rethrows
259
280
  // its error from await() and propagates out unchanged.
260
281
  return withTimeoutOrNull(timeoutMs) { deferred.await() }
261
- ?: throw BluetoothSdkException(
262
- "request_timeout",
263
- "$operation timed out waiting for glasses response.",
264
- )
282
+ ?: run {
283
+ analytics.recordUsage { requestTimedOut(telemetryOperation) }
284
+ throw BluetoothSdkException(
285
+ "request_timeout",
286
+ "$operation timed out waiting for glasses response.",
287
+ )
288
+ }
265
289
  }
266
290
 
267
291
  }
@@ -503,6 +527,7 @@ class MentraBluetoothSdk private constructor(
503
527
  }
504
528
 
505
529
  fun cancelConnectionAttempt() {
530
+ analytics.noteDisconnectRequested(BluetoothSdkAnalyticsTracker.DISCONNECT_CANCEL)
506
531
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
507
532
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
508
533
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_secure_pairing_capable", "")
@@ -514,6 +539,7 @@ class MentraBluetoothSdk private constructor(
514
539
  }
515
540
 
516
541
  fun disconnect() {
542
+ analytics.noteDisconnectRequested(BluetoothSdkAnalyticsTracker.DISCONNECT_USER)
517
543
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
518
544
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
519
545
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_secure_pairing_capable", "")
@@ -521,6 +547,7 @@ class MentraBluetoothSdk private constructor(
521
547
  }
522
548
 
523
549
  fun forget() {
550
+ analytics.noteDisconnectRequested(BluetoothSdkAnalyticsTracker.DISCONNECT_FORGET)
524
551
  deviceManager.forget()
525
552
  }
526
553
 
@@ -651,6 +678,10 @@ class MentraBluetoothSdk private constructor(
651
678
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "loudness_gate_enabled", enabled)
652
679
  }
653
680
 
681
+ fun setAutoPowerOffEnabled(enabled: Boolean) {
682
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "auto_power_off_enabled", enabled)
683
+ }
684
+
654
685
  @Deprecated(
655
686
  message =
656
687
  "Sticky action-button photo presets are deprecated. Prefer per-request " +
@@ -943,8 +974,12 @@ class MentraBluetoothSdk private constructor(
943
974
  pendingWifiStatus = PendingWifiStatusRequest(ssid, pending)
944
975
  }
945
976
  try {
946
- deviceManager.sendWifiCredentials(ssid, password)
947
- return pending.await()
977
+ return withUsageOutcome(
978
+ { latencyMs, errorCode, _ -> analytics.recordUsage { wifiSetupSettled(latencyMs, errorCode) } },
979
+ ) {
980
+ deviceManager.sendWifiCredentials(ssid, password)
981
+ pending.await()
982
+ }
948
983
  } finally {
949
984
  synchronized(oneShotLock) {
950
985
  if (pendingWifiStatus?.pending === pending) {
@@ -1058,8 +1093,14 @@ class MentraBluetoothSdk private constructor(
1058
1093
  pendingHotspotStatus = PendingHotspotStatusRequest(enabled, pending)
1059
1094
  }
1060
1095
  try {
1061
- deviceManager.setHotspotState(enabled)
1062
- pending.await()
1096
+ withUsageOutcome(
1097
+ { latencyMs, errorCode, _ ->
1098
+ if (enabled) analytics.recordUsage { hotspotStartSettled(latencyMs, errorCode) }
1099
+ },
1100
+ ) {
1101
+ deviceManager.setHotspotState(enabled)
1102
+ pending.await()
1103
+ }
1063
1104
  } finally {
1064
1105
  synchronized(oneShotLock) {
1065
1106
  if (pendingHotspotStatus?.pending === pending) {
@@ -1086,13 +1127,31 @@ class MentraBluetoothSdk private constructor(
1086
1127
  Bridge.log(
1087
1128
  "NATIVE: PHOTO PIPELINE [3b/6] MentraBluetoothSdk.requestPhoto requestId=${routedRequest.requestId}"
1088
1129
  )
1089
- val pending = PendingResponse<PhotoResponseEvent>("photo request ${routedRequest.requestId}")
1130
+ val pending = PendingResponse<PhotoResponseEvent>("photo request ${routedRequest.requestId}", "photo request")
1090
1131
  pendingPhotoRequests[routedRequest.requestId] = pending
1091
1132
  try {
1092
- deviceManager.requestPhoto(routedRequest)
1093
- return pending.await(
1094
- if (routedRequest.presendThumbnail) PHOTO_THUMBNAIL_REQUEST_TIMEOUT_MS else PHOTO_REQUEST_TIMEOUT_MS,
1095
- )
1133
+ return withUsageOutcome(
1134
+ { latencyMs, errorCode, event ->
1135
+ val fileSizeBytes = (event?.response as? PhotoResponse.Success)?.fileSizeBytes
1136
+ analytics.recordUsage {
1137
+ photoSettled(
1138
+ size = routedRequest.size.value,
1139
+ mode = routedRequest.mode.value,
1140
+ compress = routedRequest.compress.value,
1141
+ save = routedRequest.save,
1142
+ transferMethod = routedRequest.transferMethod,
1143
+ latencyMs = latencyMs,
1144
+ fileSizeBytes = fileSizeBytes,
1145
+ errorCode = errorCode,
1146
+ )
1147
+ }
1148
+ },
1149
+ ) {
1150
+ deviceManager.requestPhoto(routedRequest)
1151
+ pending.await(
1152
+ if (routedRequest.presendThumbnail) PHOTO_THUMBNAIL_REQUEST_TIMEOUT_MS else PHOTO_REQUEST_TIMEOUT_MS,
1153
+ )
1154
+ }
1096
1155
  } finally {
1097
1156
  pendingPhotoRequests.remove(routedRequest.requestId, pending)
1098
1157
  }
@@ -1108,7 +1167,7 @@ class MentraBluetoothSdk private constructor(
1108
1167
  mfnr: Boolean? = null,
1109
1168
  ): CameraStatusEvent {
1110
1169
  val effectiveRequestId = nonBlankRequestId(requestId) ?: generatedCameraRequestId("warm")
1111
- val pending = PendingResponse<CameraStatusEvent>("camera warm up $effectiveRequestId")
1170
+ val pending = PendingResponse<CameraStatusEvent>("camera warm up $effectiveRequestId", "camera warm up")
1112
1171
  // Guard against a concurrent warm-up reusing the same requestId: a blind put would overwrite
1113
1172
  // (and strand) the first caller's continuation until it times out. Reject the duplicate.
1114
1173
  if (pendingCameraStatusRequests.putIfAbsent(effectiveRequestId, pending) != null) {
@@ -1155,35 +1214,55 @@ class MentraBluetoothSdk private constructor(
1155
1214
  }
1156
1215
 
1157
1216
  suspend fun startStream(request: StreamRequest): StreamStatusEvent {
1158
- requireGlassesConnected("start stream")
1159
- if (!streamSession.supported) {
1160
- throw BluetoothSdkException("stream_control_unsupported", "Update the glasses software before starting a stream.")
1161
- }
1162
1217
  val message = request.toMap().toMutableMap()
1163
1218
  val streamId = (message["streamId"] as? String)?.takeIf { it.isNotBlank() }
1164
1219
  ?: "sdk-${UUID.randomUUID()}"
1165
1220
  message["streamId"] = streamId
1166
- val pending = PendingResponse<StreamStatusEvent>("start stream $streamId")
1167
- var start: PendingStreamStart? = null
1168
- try {
1169
- // seq assignment and the BLE hand-off must be one critical section:
1170
- // rejectPreemptedStreamStarts only works if seq order equals the
1171
- // order the commands reach the glasses' FIFO.
1172
- synchronized(streamStartOrderLock) {
1173
- val registered = PendingStreamStart(streamStartSeq.incrementAndGet(), pending)
1174
- pendingStreamStarts[streamId] = registered
1175
- start = registered
1176
- deviceManager.startStream(message)
1221
+ return withUsageOutcome(
1222
+ { latencyMs, errorCode, _ ->
1223
+ val glassesNetwork = glassesNetwork()
1224
+ analytics.recordUsage {
1225
+ streamStartSettled(
1226
+ streamUrl = request.streamUrl,
1227
+ width = request.video?.width,
1228
+ height = request.video?.height,
1229
+ fps = request.video?.fps,
1230
+ bitrate = request.video?.bitrate,
1231
+ captureAudio = request.captureAudio,
1232
+ glassesNetwork = glassesNetwork,
1233
+ latencyMs = latencyMs,
1234
+ errorCode = errorCode,
1235
+ )
1236
+ }
1237
+ },
1238
+ ) {
1239
+ requireGlassesConnected("start stream")
1240
+ if (!streamSession.supported) {
1241
+ throw BluetoothSdkException("stream_control_unsupported", "Update the glasses software before starting a stream.")
1242
+ }
1243
+ val pending = PendingResponse<StreamStatusEvent>("start stream $streamId", "start stream")
1244
+ val requestedAt = System.currentTimeMillis()
1245
+ analytics.recordUsage { streamRequested(streamId, request.streamUrl, requestedAt) }
1246
+ var start: PendingStreamStart? = null
1247
+ try {
1248
+ // seq assignment and the BLE hand-off must be one critical section:
1249
+ // rejectPreemptedStreamStarts only works if seq order equals the
1250
+ // order the commands reach the glasses' FIFO.
1251
+ synchronized(streamStartOrderLock) {
1252
+ val registered = PendingStreamStart(streamStartSeq.incrementAndGet(), pending)
1253
+ pendingStreamStarts[streamId] = registered
1254
+ start = registered
1255
+ deviceManager.startStream(message)
1256
+ }
1257
+ pending.await(STREAM_START_TIMEOUT_MS)
1258
+ } finally {
1259
+ start?.let { pendingStreamStarts.remove(streamId, it) }
1177
1260
  }
1178
- val event = pending.await(STREAM_START_TIMEOUT_MS)
1179
- return event
1180
- } finally {
1181
- start?.let { pendingStreamStarts.remove(streamId, it) }
1182
1261
  }
1183
1262
  }
1184
1263
 
1185
1264
  suspend fun rgbLedControl(request: RgbLedRequest): RgbLedControlResponseEvent {
1186
- val pending = PendingResponse<RgbLedControlResponseEvent>("RGB LED command ${request.requestId}")
1265
+ val pending = PendingResponse<RgbLedControlResponseEvent>("RGB LED command ${request.requestId}", "RGB LED command")
1187
1266
  pendingRgbLedRequests[request.requestId] = pending
1188
1267
  try {
1189
1268
  deviceManager.rgbLedControl(
@@ -1202,6 +1281,7 @@ class MentraBluetoothSdk private constructor(
1202
1281
  }
1203
1282
 
1204
1283
  suspend fun stopStream(): StreamStatusEvent {
1284
+ analytics.updateUsage { streamStopRequested() }
1205
1285
  val pending = PendingResponse<StreamStatusEvent>("stop stream")
1206
1286
  val targetStreamId = streamSession.currentStreamId
1207
1287
  try {
@@ -1243,6 +1323,16 @@ class MentraBluetoothSdk private constructor(
1243
1323
  "A video recording command is already waiting for requestId ${request.requestId}.",
1244
1324
  )
1245
1325
  }
1326
+ analytics.updateUsage {
1327
+ videoRequested(
1328
+ request.requestId,
1329
+ request.width,
1330
+ request.height,
1331
+ request.fps,
1332
+ request.save,
1333
+ request.maxRecordingTimeMinutes,
1334
+ )
1335
+ }
1246
1336
  try {
1247
1337
  deviceManager.startVideoRecording(
1248
1338
  request.requestId,
@@ -1281,6 +1371,7 @@ class MentraBluetoothSdk private constructor(
1281
1371
  "A video recording command is already waiting for requestId $requestId.",
1282
1372
  )
1283
1373
  }
1374
+ analytics.updateUsage { videoStopRequested(requestId) }
1284
1375
  try {
1285
1376
  deviceManager.stopVideoRecording(requestId, webhookUrl, authToken)
1286
1377
  val timeoutMs =
@@ -1587,6 +1678,7 @@ class MentraBluetoothSdk private constructor(
1587
1678
  Bridge.removeEventSink(bridgeEventSinkId)
1588
1679
  DeviceStore.store.removeListener(storeListenerId)
1589
1680
  analytics.shutdown()
1681
+ updateCheck.stop()
1590
1682
  listeners.clear()
1591
1683
  }
1592
1684
 
@@ -1596,7 +1688,7 @@ class MentraBluetoothSdk private constructor(
1596
1688
  if (changes["connected"] == false) {
1597
1689
  resetWifiProtocolSession("", "wifi_session_disconnected")
1598
1690
  }
1599
- analytics.observeGlassesStatus(getRawGlassesStatus())
1691
+ analytics.observeGlassesStatus(getRawGlassesStatus(), ::isPhoneBluetoothOff)
1600
1692
  val state = getState()
1601
1693
  dispatchToListeners {
1602
1694
  it.onStateChanged(state)
@@ -1604,6 +1696,12 @@ class MentraBluetoothSdk private constructor(
1604
1696
  }
1605
1697
  }
1606
1698
  ObservableStore.BLUETOOTH_CATEGORY -> {
1699
+ if (analytics.isEnabled && changes.keys.any { it in MIC_STATE_KEYS }) {
1700
+ val bluetooth = getRawBluetoothStatus()
1701
+ val transcribing = bluetooth.shouldSendTranscript || bluetooth.localSttFallbackActive
1702
+ val now = System.currentTimeMillis()
1703
+ analytics.recordUsage { observeMic(bluetooth.currentMic, transcribing, now) }
1704
+ }
1607
1705
  val state = getState()
1608
1706
  val scanChanged = changes.keys.any { it in SCAN_STATE_KEYS }
1609
1707
  dispatchToListeners {
@@ -1870,6 +1968,10 @@ class MentraBluetoothSdk private constructor(
1870
1968
  "photo_response" -> {
1871
1969
  val event = PhotoResponseEvent(data)
1872
1970
  handlePhotoResponseForRequests(event)
1971
+ val response = event.response
1972
+ if (response is PhotoResponse.Error && event.requestId == BluetoothSdkUsageTracker.LOCAL_PHOTO_REQUEST_ID) {
1973
+ analytics.recordUsage { buttonPhotoFailed(response.errorCode) }
1974
+ }
1873
1975
  dispatchToListeners { it.onPhotoResponse(event) }
1874
1976
  }
1875
1977
  "photo_status" -> dispatchToListeners { it.onPhotoStatus(PhotoStatusEvent(data)) }
@@ -1881,6 +1983,8 @@ class MentraBluetoothSdk private constructor(
1881
1983
  "video_recording_status" -> {
1882
1984
  val event = VideoRecordingStatusEvent(data)
1883
1985
  handleVideoRecordingStatusForRequests(event)
1986
+ val now = System.currentTimeMillis()
1987
+ analytics.recordUsage { videoStatus(event.requestId, event.status, event.success, now) }
1884
1988
  dispatchToListeners { it.onVideoRecordingStatus(event) }
1885
1989
  }
1886
1990
  "media_success", "media_error" -> {
@@ -1900,6 +2004,7 @@ class MentraBluetoothSdk private constructor(
1900
2004
  if (data.containsKey("revision") && !streamSession.accept(data)) return
1901
2005
  val event = StreamStatusEvent(data)
1902
2006
  handleStreamStatusForRequests(event)
2007
+ recordStreamStatus(event)
1903
2008
  dispatchToListeners { it.onStreamStatus(event) }
1904
2009
  }
1905
2010
  "keep_alive_ack" -> {
@@ -1924,6 +2029,23 @@ class MentraBluetoothSdk private constructor(
1924
2029
  )
1925
2030
  }
1926
2031
  }
2032
+ if (analytics.isEnabled) {
2033
+ val glasses = getRawGlassesStatus()
2034
+ val now = System.currentTimeMillis()
2035
+ analytics.recordUsage {
2036
+ otaStatus(
2037
+ sessionId = event.sessionId,
2038
+ status = event.status,
2039
+ stepType = event.stepType,
2040
+ totalSteps = event.totalSteps,
2041
+ errorMessage = event.errorMessage,
2042
+ fromBuildNumber = glasses.buildNumber,
2043
+ fromBesFirmwareVersion = glasses.besFirmwareVersion,
2044
+ fromMtkFirmwareVersion = glasses.mtkFirmwareVersion,
2045
+ nowMs = now,
2046
+ )
2047
+ }
2048
+ }
1927
2049
  dispatchToListeners { it.onOtaStatus(event) }
1928
2050
  }
1929
2051
  "mic_health" -> {
@@ -1979,6 +2101,75 @@ class MentraBluetoothSdk private constructor(
1979
2101
  }
1980
2102
  }
1981
2103
 
2104
+ private fun recordStreamStatus(event: StreamStatusEvent) {
2105
+ if (!analytics.isEnabled) return
2106
+ val status = event.status
2107
+ val attempt =
2108
+ when (status) {
2109
+ is StreamStatus.Reconnecting -> status.attempt
2110
+ is StreamStatus.Reconnected -> status.attempt
2111
+ else -> null
2112
+ }
2113
+ val errorDetails = event.errorDetails ?: (status as? StreamStatus.Error)?.errorDetails
2114
+ val now = System.currentTimeMillis()
2115
+ analytics.recordUsage {
2116
+ streamStatus(
2117
+ streamId = event.streamId,
2118
+ state = event.state.value,
2119
+ terminal = event.terminal,
2120
+ reconnectAttempt = attempt,
2121
+ bitrate = event.stats?.bitrate,
2122
+ fps = event.stats?.fps,
2123
+ droppedFrames = event.stats?.droppedFrames,
2124
+ temperatureC = event.stats?.temperatureC,
2125
+ errorDetails = errorDetails,
2126
+ nowMs = now,
2127
+ )
2128
+ }
2129
+ }
2130
+
2131
+ private fun glassesNetwork(): String {
2132
+ val glasses = getRawGlassesStatus()
2133
+ return when {
2134
+ glasses.hotspot is HotspotStatus.Enabled -> "hotspot"
2135
+ glasses.wifi is WifiStatus.Connected -> "wifi"
2136
+ else -> "none"
2137
+ }
2138
+ }
2139
+
2140
+ private fun isPhoneBluetoothOff(): Boolean =
2141
+ try {
2142
+ (appContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter?.isEnabled == false
2143
+ } catch (_: Exception) {
2144
+ false
2145
+ }
2146
+
2147
+ /**
2148
+ * Runs a glasses command and reports its latency and outcome to usage
2149
+ * analytics. Cancellation is the caller giving up, not a command outcome, so
2150
+ * it is not reported.
2151
+ */
2152
+ private inline fun <T> withUsageOutcome(
2153
+ record: (latencyMs: Long, errorCode: String?, result: T?) -> Unit,
2154
+ block: () -> T,
2155
+ ): T {
2156
+ val startedAt = System.currentTimeMillis()
2157
+ val result =
2158
+ try {
2159
+ block()
2160
+ } catch (error: CancellationException) {
2161
+ throw error
2162
+ } catch (error: Exception) {
2163
+ if (analytics.isEnabled) {
2164
+ val errorCode = (error as? BluetoothSdkException)?.code ?: error.javaClass.simpleName
2165
+ record(System.currentTimeMillis() - startedAt, errorCode, null)
2166
+ }
2167
+ throw error
2168
+ }
2169
+ if (analytics.isEnabled) record(System.currentTimeMillis() - startedAt, null, result)
2170
+ return result
2171
+ }
2172
+
1982
2173
  private fun dispatchToListeners(callback: (MentraBluetoothSdkListener) -> Unit) {
1983
2174
  val snapshot = synchronized(listeners) { listeners.toList() }
1984
2175
  val deliver = {
@@ -41,6 +41,30 @@ class MentraLocalNetworkModule : Module() {
41
41
  Name("MentraLocalNetwork")
42
42
  Events("downloadProgress", "networkLost")
43
43
 
44
+ Function("getNetworkDiagnostics") {
45
+ val manager = connectivityManager()
46
+ // Sample time marks the beginning of these sequential local OS reads.
47
+ val sampledAtMs = System.currentTimeMillis()
48
+ networkDiagnostics(
49
+ sampledAtMs,
50
+ manager.boundNetworkForProcess,
51
+ manager.activeNetwork,
52
+ synchronized(lock) { localNetwork },
53
+ ) { network ->
54
+ manager.getNetworkCapabilities(network)?.let { capabilities ->
55
+ mapOf(
56
+ "wifi" to capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI),
57
+ "cellular" to capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR),
58
+ "ethernet" to capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET),
59
+ "vpn" to capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN),
60
+ "internet" to capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET),
61
+ "validated" to capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
62
+ "captivePortal" to capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL),
63
+ )
64
+ }
65
+ }
66
+ }
67
+
44
68
  AsyncFunction("connect") { ssid: String, password: String, promise: Promise ->
45
69
  connect(ssid, password, promise)
46
70
  }
@@ -0,0 +1,25 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ /** Only routing relationships and capabilities cross the bridge, never network identities. */
4
+ internal fun <N> networkDiagnostics(
5
+ sampledAtMs: Long,
6
+ processBound: N?,
7
+ activeNetwork: N?,
8
+ glassesLocal: N?,
9
+ capabilities: (N) -> Map<String, Boolean>?,
10
+ ): Map<String, Any?> {
11
+ fun observation(network: N?): Map<String, Any?> = mapOf(
12
+ "present" to (network != null),
13
+ "capabilities" to network?.let(capabilities),
14
+ )
15
+ fun same(first: N?, second: N?): Boolean = first != null && first == second
16
+ return mapOf(
17
+ "sampledAtMs" to sampledAtMs,
18
+ "processBound" to observation(processBound),
19
+ "activeNetwork" to observation(activeNetwork),
20
+ "glassesLocal" to observation(glassesLocal),
21
+ "processBoundMatchesActive" to same(processBound, activeNetwork),
22
+ "processBoundMatchesGlassesLocal" to same(processBound, glassesLocal),
23
+ "activeNetworkMatchesGlassesLocal" to same(activeNetwork, glassesLocal),
24
+ )
25
+ }
@@ -6,13 +6,14 @@ internal sealed interface VersionInfoAccumulatorOutcome {
6
6
  data class Complete(val result: VersionInfoResult) : VersionInfoAccumulatorOutcome
7
7
  }
8
8
 
9
- /** One request, explicit modern completion, or the deployed legacy chunk-3 terminal boundary. */
9
+ /** One request, explicit modern completion, or the legacy firmware's terminal chunk. */
10
10
  internal class VersionInfoResponseAccumulator(private val expectedRequestId: String) {
11
11
  private val values = mutableMapOf<String, Any>()
12
12
  private val chunks = mutableMapOf<Int, Map<String, Any>>()
13
13
  private var count: Int? = null
14
14
  private var sid: String? = null
15
15
  private var legacyStarted = false
16
+ private var legacyTwoChunkResponse = false
16
17
  private var completed = false
17
18
 
18
19
  fun accept(event: Map<String, Any>): VersionInfoAccumulatorOutcome {
@@ -41,10 +42,20 @@ internal class VersionInfoResponseAccumulator(private val expectedRequestId: Str
41
42
  if (count != null) return VersionInfoAccumulatorOutcome.Ignored
42
43
  when (event[RESPONSE_CHUNK_KEY] as? String ?: "version_info") {
43
44
  "version_info" -> { values.clear(); merge(event); return finish() }
44
- "version_info_1" -> { values.clear(); legacyStarted = true; merge(event) }
45
+ "version_info_1" -> {
46
+ values.clear()
47
+ legacyStarted = true
48
+ legacyTwoChunkResponse = event["buildNumber"] == "27"
49
+ merge(event)
50
+ }
45
51
  "version_info_2" -> {
46
52
  if (!legacyStarted) return VersionInfoAccumulatorOutcome.Ignored
53
+ // Factory ASG27 sends only chunks 1 and 2. Its second chunk carries the
54
+ // OTA URL and legacy firmware version; ASG31+ moves firmware to chunk 3.
47
55
  merge(event)
56
+ if (legacyTwoChunkResponse && values["buildNumber"] == "27" &&
57
+ !(event["otaVersionUrl"] as? String).isNullOrBlank()
58
+ ) return finish()
48
59
  }
49
60
  "version_info_3" -> {
50
61
  if (!legacyStarted) return VersionInfoAccumulatorOutcome.Ignored
@@ -108,6 +108,9 @@ object BleTraceLogger {
108
108
  }
109
109
 
110
110
  private fun sanitizeValue(key: String?, value: Any?): Any? {
111
+ // Scan security metadata is a boolean, not a credential. Keep the exact
112
+ // typed field while still redacting string/unknown values under this key.
113
+ if (key == "requiresPassword" && value is Boolean) return value
111
114
  if (key != null && sensitiveKeyParts.any { key.contains(it, ignoreCase = true) }) {
112
115
  return "<redacted>"
113
116
  }
@@ -1,7 +1,7 @@
1
1
  package com.mentra.bluetoothsdk
2
2
 
3
3
  internal object DashboardContentFormatter {
4
- const val STATUS_HEADER = "\$TIME12$ \$DATE$ \$GBATT$ \$CONNECTION_STATUS$"
4
+ const val STATUS_HEADER = "\$TIME12$ \$DATE$ \$GBATT$"
5
5
 
6
6
  fun template(content: String): String =
7
7
  if (content.isEmpty()) STATUS_HEADER else "$STATUS_HEADER\n\n$content"
@@ -13,6 +13,8 @@ import android.os.IBinder
13
13
  import androidx.core.app.NotificationCompat
14
14
  import androidx.core.content.ContextCompat
15
15
  import com.mentra.bluetoothsdk.Bridge
16
+ import com.mentra.bluetoothsdk.DeviceManager
17
+ import com.mentra.bluetoothsdk.DeviceStore
16
18
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
17
19
 
18
20
  class ForegroundService : Service() {
@@ -107,9 +109,40 @@ class ForegroundService : Service() {
107
109
  }
108
110
  // Re-check permissions in case they changed
109
111
  startForegroundWithAutoDetectedType()
112
+ // A null intent is Android restarting START_STICKY after process death.
113
+ // Do not wait for an Activity/Expo module to restore the native BLE owner.
114
+ if (intent == null && !DeviceManager.isInitialized()) {
115
+ val settings = G2ConnectionRecovery(this).read()
116
+ if (settings != null && !startHostRuntimeRecovery()) {
117
+ Bridge.initialize(applicationContext)
118
+ settings.forEach { (key, value) -> DeviceStore.set("bluetooth", key, value) }
119
+ Bridge.log("ForegroundService: restoring G2 connection after process restart")
120
+ try {
121
+ DeviceManager.getInstance().connectDefault()
122
+ } catch (error: Exception) {
123
+ Bridge.log("ForegroundService: G2 recovery failed: ${error.message}")
124
+ }
125
+ }
126
+ }
110
127
  return START_STICKY
111
128
  }
112
129
 
130
+ private fun startHostRuntimeRecovery(): Boolean {
131
+ // Keep the standalone SDK free of React dependencies. The Mentra App
132
+ // opts in with a private headless service declared by its host module.
133
+ val serviceName = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
134
+ .metaData?.getString("com.mentra.bluetoothsdk.RUNTIME_RECOVERY_SERVICE")
135
+ ?: return false
136
+ return try {
137
+ if (startService(Intent().setClassName(packageName, serviceName)) == null) return false
138
+ Bridge.log("ForegroundService: requested host JavaScript runtime recovery")
139
+ true
140
+ } catch (error: Exception) {
141
+ Bridge.log("ForegroundService: host runtime recovery could not start: ${error.message}")
142
+ false
143
+ }
144
+ }
145
+
113
146
  private fun startForegroundWithAutoDetectedType() {
114
147
  startForegroundWithType(detectServiceType())
115
148
  }
@@ -0,0 +1,47 @@
1
+ package com.mentra.bluetoothsdk.services
2
+
3
+ import android.content.Context
4
+ import com.mentra.bluetoothsdk.utils.DeviceTypes
5
+ import org.json.JSONObject
6
+
7
+ /** Native recovery of the last active G2 link when Android restarts our sticky service. */
8
+ internal class G2ConnectionRecovery(context: Context) {
9
+ private val preferences = context.getSharedPreferences("g2_connection_recovery", Context.MODE_PRIVATE)
10
+
11
+ companion object {
12
+ // Deliberately exclude transient connection flags, mic intent, tokens and pending pairing.
13
+ internal val keys = setOf(
14
+ "default_wearable", "device_name", "device_address", "project_name",
15
+ "brightness", "auto_brightness", "dashboard_height", "dashboard_depth",
16
+ "head_up_angle", "use_native_dashboard", "twelve_hour_time", "metric_system",
17
+ "screen_disabled", "power_saving_mode", "lc3_frame_size",
18
+ )
19
+ }
20
+
21
+ fun save(settings: Map<String, Any>) {
22
+ if (settings["default_wearable"] != DeviceTypes.G2 ||
23
+ (settings["device_name"] as? String).isNullOrBlank()) {
24
+ clear()
25
+ return
26
+ }
27
+ val snapshot = settings.filterKeys { it in keys }
28
+ preferences.edit().putString("snapshot", JSONObject(snapshot).toString()).apply()
29
+ }
30
+
31
+ fun clear() {
32
+ preferences.edit().remove("snapshot").apply()
33
+ }
34
+
35
+ fun read(): Map<String, Any>? {
36
+ val raw = preferences.getString("snapshot", null) ?: return null
37
+ return try {
38
+ val json = JSONObject(raw)
39
+ if (json.optString("default_wearable") != DeviceTypes.G2 ||
40
+ json.optString("device_name").isBlank()) return null
41
+ keys.filter { json.has(it) && !json.isNull(it) }.associateWith { json.get(it) }
42
+ } catch (_: Exception) {
43
+ clear()
44
+ null
45
+ }
46
+ }
47
+ }