@mentra/bluetooth-sdk 0.1.21-beta.3 → 3.1.0-beta.100

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 (144) hide show
  1. package/README.md +67 -25
  2. package/android/build.gradle +7 -4
  3. package/android/lc3Lib/build.gradle +18 -4
  4. package/android/silero/build.gradle +9 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +16 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +35 -4
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +216 -23
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +7 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +127 -8
  12. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
  13. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
  14. package/android/src/main/java/com/mentra/bluetoothsdk/ReleaseChangelog.kt +61 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
  22. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuard.kt +49 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1393 -284
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  29. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  30. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  31. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  32. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/ReleaseChangelogTest.kt +13 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
  39. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +133 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  42. package/build/BluetoothSdk.types.d.ts +84 -2
  43. package/build/BluetoothSdk.types.d.ts.map +1 -1
  44. package/build/BluetoothSdk.types.js.map +1 -1
  45. package/build/_internal.d.ts +2 -0
  46. package/build/_internal.d.ts.map +1 -1
  47. package/build/_internal.js +2 -0
  48. package/build/_internal.js.map +1 -1
  49. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  50. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  51. package/build/_private/BluetoothSdkModule.js +1 -1
  52. package/build/_private/BluetoothSdkModule.js.map +1 -1
  53. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  54. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  55. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  56. package/build/_private/sdkOtaManifest.d.ts +3 -12
  57. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  58. package/build/_private/sdkOtaManifest.js +5 -17
  59. package/build/_private/sdkOtaManifest.js.map +1 -1
  60. package/build/changelogs.d.ts +7 -0
  61. package/build/changelogs.d.ts.map +1 -0
  62. package/build/changelogs.js +44 -0
  63. package/build/changelogs.js.map +1 -0
  64. package/build/generated/changelogCatalog.d.ts +6 -0
  65. package/build/generated/changelogCatalog.d.ts.map +1 -0
  66. package/build/generated/changelogCatalog.js +8 -0
  67. package/build/generated/changelogCatalog.js.map +1 -0
  68. package/build/generated/releaseMetadata.d.ts +12 -0
  69. package/build/generated/releaseMetadata.d.ts.map +1 -0
  70. package/build/generated/releaseMetadata.js +10 -0
  71. package/build/generated/releaseMetadata.js.map +1 -0
  72. package/build/index.d.ts +1 -1
  73. package/build/index.d.ts.map +1 -1
  74. package/build/index.js +24 -2
  75. package/build/index.js.map +1 -1
  76. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  77. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  78. package/build/ota-server/MentraOtaServer.types.js +2 -0
  79. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  80. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  81. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  82. package/build/ota-server/MentraOtaServerModule.js +3 -0
  83. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  84. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  85. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  86. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  87. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  88. package/build/ota-server/index.d.ts +3 -0
  89. package/build/ota-server/index.d.ts.map +1 -0
  90. package/build/ota-server/index.js +3 -0
  91. package/build/ota-server/index.js.map +1 -0
  92. package/build/ota-transport/index.d.ts +67 -0
  93. package/build/ota-transport/index.d.ts.map +1 -0
  94. package/build/ota-transport/index.js +58 -0
  95. package/build/ota-transport/index.js.map +1 -0
  96. package/expo-module.config.json +3 -2
  97. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  98. package/ios/BluetoothSdkModule.swift +16 -2
  99. package/ios/LocalIPv4.swift +150 -0
  100. package/ios/LocalOtaServer.swift +309 -0
  101. package/ios/MentraBluetoothSDK.podspec +5 -1
  102. package/ios/MentraOtaServerModule.swift +147 -0
  103. package/ios/MentraPhotoReceiverModule.swift +1 -81
  104. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  105. package/ios/Source/Bridge.swift +43 -4
  106. package/ios/Source/DeviceManager.swift +154 -19
  107. package/ios/Source/DeviceStore.swift +2 -0
  108. package/ios/Source/GeneratedChangelogCatalog.swift +6 -0
  109. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  110. package/ios/Source/MentraBluetoothSDK.swift +91 -6
  111. package/ios/Source/OtaManifest.swift +5 -7
  112. package/ios/Source/ReleaseChangelog.swift +65 -0
  113. package/ios/Source/audio/AudioModels.swift +17 -0
  114. package/ios/Source/camera/CameraModels.swift +1 -0
  115. package/ios/Source/events/BluetoothEvents.swift +27 -0
  116. package/ios/Source/sgcs/MentraLive.swift +512 -78
  117. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  118. package/ios/Source/sgcs/SGCManager.swift +11 -0
  119. package/ios/Source/status/DeviceStatus.swift +23 -0
  120. package/ios/Source/types/DeviceModels.swift +23 -2
  121. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  122. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +160 -0
  123. package/ios/Tests/ReleaseChangelogTests.swift +13 -0
  124. package/package.json +10 -1
  125. package/plugin/build/withIos.d.ts +3 -1
  126. package/plugin/build/withIos.js +22 -10
  127. package/scripts/public-ota-api.test.mjs +37 -0
  128. package/scripts/verify-release-package.mjs +34 -0
  129. package/scripts/write-release-metadata.mjs +154 -0
  130. package/scripts/write-release-metadata.test.mjs +29 -0
  131. package/src/BluetoothSdk.types.ts +97 -3
  132. package/src/_internal.ts +2 -0
  133. package/src/_private/BluetoothSdkModule.ts +5 -5
  134. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  135. package/src/_private/sdkOtaManifest.ts +5 -17
  136. package/src/changelogs.ts +44 -0
  137. package/src/generated/changelogCatalog.ts +9 -0
  138. package/src/generated/releaseMetadata.ts +20 -0
  139. package/src/index.ts +43 -2
  140. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  141. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  142. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  143. package/src/ota-server/index.ts +2 -0
  144. package/src/ota-transport/index.ts +116 -0
@@ -1,7 +1,10 @@
1
1
  package com.mentra.bluetoothsdk
2
2
 
3
+ import android.app.Activity
4
+ import android.app.Application
3
5
  import android.bluetooth.BluetoothManager
4
6
  import android.content.Context
7
+ import android.os.Bundle
5
8
  import android.os.Handler
6
9
  import android.os.Looper
7
10
  import com.mentra.bluetoothsdk.utils.ControllerTypes
@@ -30,6 +33,23 @@ class MentraBluetoothSdk private constructor(
30
33
  private val discoveredDeviceNames = mutableSetOf<String>()
31
34
  private val bridgeEventSinkId: String
32
35
  private val storeListenerId: String
36
+ private val activityLifecycleCallbacks =
37
+ object : Application.ActivityLifecycleCallbacks {
38
+ override fun onActivityResumed(activity: Activity) {
39
+ // Match the Mentra App's OnActivityEntersForeground hook. This runs after
40
+ // runtime permission dialogs in native host apps, allowing the service to
41
+ // replace its temporary dataSync type with connectedDevice/microphone/location.
42
+ deviceManager.refreshForegroundServiceTypes()
43
+ }
44
+
45
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
46
+ override fun onActivityStarted(activity: Activity) = Unit
47
+ override fun onActivityPaused(activity: Activity) = Unit
48
+ override fun onActivityStopped(activity: Activity) = Unit
49
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
50
+ override fun onActivityDestroyed(activity: Activity) = Unit
51
+ }
52
+ private var activityLifecycleCallbacksRegistered = false
33
53
  private var suppressDefaultDeviceEvents = false
34
54
  private val streamKeepAliveLock = Any()
35
55
  private var activeStreamKeepAlive: ActiveStreamKeepAlive? = null
@@ -57,6 +77,10 @@ class MentraBluetoothSdk private constructor(
57
77
  listeners.add(listener)
58
78
  Bridge.initialize(appContext)
59
79
  deviceManager = DeviceManager.getInstance()
80
+ (appContext as? Application)?.let { application ->
81
+ application.registerActivityLifecycleCallbacks(activityLifecycleCallbacks)
82
+ activityLifecycleCallbacksRegistered = true
83
+ }
60
84
  bridgeEventSinkId = Bridge.addEventSink { eventName, data -> dispatchBridgeEvent(eventName, data) }
61
85
  // Baseline the analytics connection state before subscribing to the store:
62
86
  // store updates invoke listeners synchronously on the updating thread, so a
@@ -258,6 +282,9 @@ class MentraBluetoothSdk private constructor(
258
282
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_name", device.name)
259
283
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_address", device.address ?: "")
260
284
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "project_name", device.projectName ?: "")
285
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
286
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
287
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_secure_pairing_capable", "")
261
288
  } finally {
262
289
  suppressDefaultDeviceEvents = false
263
290
  }
@@ -271,6 +298,9 @@ class MentraBluetoothSdk private constructor(
271
298
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_name", "")
272
299
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_address", "")
273
300
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "project_name", "")
301
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
302
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
303
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_secure_pairing_capable", "")
274
304
  } finally {
275
305
  suppressDefaultDeviceEvents = false
276
306
  }
@@ -383,8 +413,29 @@ class MentraBluetoothSdk private constructor(
383
413
  cancelConnectionAttempt()
384
414
  }
385
415
  }
386
- if (options.saveAsDefault && !isController) {
387
- setDefaultDevice(device)
416
+ if (!isController) {
417
+ if (options.saveAsDefault) {
418
+ setDefaultDevice(device)
419
+ } else {
420
+ // Pairing uses saveAsDefault=false so default identity stays on the
421
+ // previous owner until handleDeviceReady. Stash the GATT target separately
422
+ // so MentraLive can connect by MAC without promoting getDefaultDevice().
423
+ DeviceStore.apply(
424
+ ObservableStore.BLUETOOTH_CATEGORY,
425
+ "pending_device_name",
426
+ device.name,
427
+ )
428
+ DeviceStore.apply(
429
+ ObservableStore.BLUETOOTH_CATEGORY,
430
+ "pending_device_address",
431
+ device.address ?: "",
432
+ )
433
+ DeviceStore.apply(
434
+ ObservableStore.BLUETOOTH_CATEGORY,
435
+ "pending_device_secure_pairing_capable",
436
+ device.securePairingCapable ?: "",
437
+ )
438
+ }
388
439
  }
389
440
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_wearable", device.model.deviceType)
390
441
  deviceManager.connectByName(device.name)
@@ -408,6 +459,9 @@ class MentraBluetoothSdk private constructor(
408
459
  }
409
460
 
410
461
  fun cancelConnectionAttempt() {
462
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
463
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
464
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_secure_pairing_capable", "")
411
465
  deviceManager.disconnect()
412
466
  }
413
467
 
@@ -416,6 +470,9 @@ class MentraBluetoothSdk private constructor(
416
470
  }
417
471
 
418
472
  fun disconnect() {
473
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
474
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
475
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_secure_pairing_capable", "")
419
476
  deviceManager.disconnect()
420
477
  }
421
478
 
@@ -856,6 +913,10 @@ class MentraBluetoothSdk private constructor(
856
913
  }
857
914
  }
858
915
 
916
+ fun setWifiAdbState(enabled: Boolean) {
917
+ deviceManager.setWifiAdbState(enabled)
918
+ }
919
+
859
920
  fun setSystemTime(timestampMs: Long) {
860
921
  deviceManager.setSystemTime(timestampMs)
861
922
  }
@@ -1087,6 +1148,25 @@ class MentraBluetoothSdk private constructor(
1087
1148
  }
1088
1149
  }
1089
1150
 
1151
+ suspend fun queryVideoRecordingStatus(requestId: String): VideoRecordingStatusEvent {
1152
+ require(requestId.isNotBlank()) { "requestId is required to query video recording status." }
1153
+ requireGlassesConnected("query video recording status")
1154
+ val pending = PendingResponse<VideoRecordingStatusEvent>("query video recording status")
1155
+ val pendingRequest = PendingVideoRecordingRequest("recording_status", pending)
1156
+ if (pendingVideoRecordingRequests.putIfAbsent(requestId, pendingRequest) != null) {
1157
+ throw BluetoothSdkException(
1158
+ "request_in_flight",
1159
+ "A video recording command is already waiting for requestId $requestId.",
1160
+ )
1161
+ }
1162
+ try {
1163
+ deviceManager.queryVideoRecordingStatus(requestId)
1164
+ return pending.await()
1165
+ } finally {
1166
+ pendingVideoRecordingRequests.remove(requestId, pendingRequest)
1167
+ }
1168
+ }
1169
+
1090
1170
  suspend fun requestVersionInfo(): VersionInfoResult {
1091
1171
  val pending = PendingResponse<VersionInfoResult>("version info request")
1092
1172
  synchronized(oneShotLock) {
@@ -1110,11 +1190,13 @@ class MentraBluetoothSdk private constructor(
1110
1190
  }
1111
1191
  }
1112
1192
 
1113
- internal fun setOtaVersionUrl(otaVersionUrl: String) {
1193
+ /** Select the HTTP(S) manifest used by subsequent OTA checks and installs. */
1194
+ fun setOtaVersionUrl(otaVersionUrl: String) {
1114
1195
  configuredOtaVersionUrl = OtaManifestChecker.normalizeHttpUrl(otaVersionUrl)
1115
1196
  }
1116
1197
 
1117
- internal fun getOtaVersionUrl(): String = configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
1198
+ /** Return the configured OTA manifest, or this SDK release's default manifest. */
1199
+ fun getOtaVersionUrl(): String = configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
1118
1200
 
1119
1201
  /** Fetch the configured OTA manifest and return whether any ASG/BES/MTK update is available. */
1120
1202
  suspend fun checkForOtaUpdate(): Boolean {
@@ -1143,6 +1225,12 @@ class MentraBluetoothSdk private constructor(
1143
1225
  )
1144
1226
  }
1145
1227
 
1228
+ /** Return bundled release changelogs crossed between two coordinated product versions, newest first. */
1229
+ fun getReleaseChangelogs(
1230
+ fromVersion: String? = null,
1231
+ toVersion: String? = null,
1232
+ ): List<ReleaseChangelog> = ReleaseChangelogCatalog.select(fromVersion, toVersion)
1233
+
1146
1234
  /** Ask connected Mentra Live glasses to report the current OTA install/session status. */
1147
1235
  private suspend fun queryOtaStatus(): OtaQueryResult =
1148
1236
  performOtaQuery("OTA status query") {
@@ -1240,6 +1328,12 @@ class MentraBluetoothSdk private constructor(
1240
1328
  systemTimeMs = versionInfo.systemTimeMs ?: status.systemTimeMs,
1241
1329
  otaVersionUrl = versionInfo.otaVersionUrl.ifBlank { status.otaVersionUrl },
1242
1330
  appVersion = versionInfo.appVersion.ifBlank { status.appVersion },
1331
+ hotspotOtaVersion =
1332
+ if (versionInfo.hotspotOtaVersion > 0) {
1333
+ versionInfo.hotspotOtaVersion
1334
+ } else {
1335
+ status.hotspotOtaVersion
1336
+ },
1243
1337
  )
1244
1338
  } catch (cancellation: CancellationException) {
1245
1339
  // Never swallow cancellation into the stale-status fallback: callers
@@ -1323,6 +1417,12 @@ class MentraBluetoothSdk private constructor(
1323
1417
 
1324
1418
  override fun close() {
1325
1419
  stopStreamKeepAliveMonitor()
1420
+ if (activityLifecycleCallbacksRegistered) {
1421
+ (appContext as? Application)?.unregisterActivityLifecycleCallbacks(
1422
+ activityLifecycleCallbacks,
1423
+ )
1424
+ activityLifecycleCallbacksRegistered = false
1425
+ }
1326
1426
  Bridge.removeEventSink(bridgeEventSinkId)
1327
1427
  DeviceStore.store.removeListener(storeListenerId)
1328
1428
  analytics.shutdown()
@@ -1520,6 +1620,9 @@ class MentraBluetoothSdk private constructor(
1520
1620
  }
1521
1621
  dispatchToListeners { it.onGalleryStatus(event) }
1522
1622
  }
1623
+ "pairing_info" -> {
1624
+ dispatchToListeners { it.onRawEvent(eventName, data) }
1625
+ }
1523
1626
  "photo_response" -> {
1524
1627
  val event = PhotoResponseEvent(data)
1525
1628
  handlePhotoResponseForRequests(event)
@@ -1567,10 +1670,19 @@ class MentraBluetoothSdk private constructor(
1567
1670
  }
1568
1671
  "ota_status" -> {
1569
1672
  val resultValues = data + mapOf("type" to "ota_status")
1673
+ val event = OtaStatusEvent.fromMap(resultValues)
1570
1674
  synchronized(oneShotLock) {
1571
1675
  pendingOtaQuery?.resolve(OtaQueryResult(resultValues))
1676
+ otaStartRejectionErrorCode(event)?.let { errorCode ->
1677
+ pendingOtaStart?.reject(
1678
+ BluetoothSdkException(errorCode, "Glasses rejected OTA start: $errorCode")
1679
+ )
1680
+ }
1572
1681
  }
1573
- dispatchToListeners { it.onOtaStatus(OtaStatusEvent.fromMap(resultValues)) }
1682
+ dispatchToListeners { it.onOtaStatus(event) }
1683
+ }
1684
+ "mic_health" -> {
1685
+ dispatchToListeners { it.onMicHealth(MicHealthEvent.fromMap(data)) }
1574
1686
  }
1575
1687
  "settings_ack" -> {
1576
1688
  val event = SettingsAckEvent(data)
@@ -2161,6 +2273,13 @@ class MentraBluetoothSdk private constructor(
2161
2273
  }
2162
2274
  }
2163
2275
 
2164
-
2165
-
2166
-
2276
+ /** OTA status messages are not request-correlated, so only known pre-ack failures settle a start. */
2277
+ internal fun otaStartRejectionErrorCode(event: OtaStatusEvent): String? =
2278
+ if (
2279
+ event.status.equals("failed", ignoreCase = true) &&
2280
+ event.errorMessage.equals("battery_low", ignoreCase = true)
2281
+ ) {
2282
+ "battery_low"
2283
+ } else {
2284
+ null
2285
+ }
@@ -5,7 +5,8 @@ import android.net.Network
5
5
  import android.net.NetworkCapabilities
6
6
  import android.net.NetworkRequest
7
7
  import android.net.wifi.WifiNetworkSpecifier
8
- import android.os.Build
8
+ import android.os.Handler
9
+ import android.os.Looper
9
10
  import android.util.Base64
10
11
  import android.util.Log
11
12
  import expo.modules.kotlin.Promise
@@ -14,6 +15,7 @@ import expo.modules.kotlin.modules.ModuleDefinition
14
15
  import java.io.File
15
16
  import java.io.FileOutputStream
16
17
  import java.net.HttpURLConnection
18
+ import java.net.Inet4Address
17
19
  import java.net.URL
18
20
  import java.util.concurrent.ConcurrentHashMap
19
21
  import java.util.concurrent.Executors
@@ -22,14 +24,18 @@ import java.util.concurrent.Executors
22
24
  class MentraLocalNetworkModule : Module() {
23
25
  private companion object {
24
26
  const val TAG = "MentraLocalNetwork"
27
+ const val NETWORK_READINESS_TIMEOUT_MS = 5_000L
25
28
  }
26
29
 
27
30
  private val lock = Any()
28
31
  private val executor = Executors.newCachedThreadPool()
32
+ private val handler = Handler(Looper.getMainLooper())
29
33
  private val activeConnections = ConcurrentHashMap<String, HttpURLConnection>()
34
+ private val networkReadiness = ScopedNetworkReadiness<Network>()
30
35
  private var localNetwork: Network? = null
31
36
  private var networkCallback: ConnectivityManager.NetworkCallback? = null
32
37
  private var pendingConnectPromise: Promise? = null
38
+ private var pendingReadinessTimeout: Runnable? = null
33
39
 
34
40
  override fun definition() = ModuleDefinition {
35
41
  Name("MentraLocalNetwork")
@@ -74,7 +80,9 @@ class MentraLocalNetworkModule : Module() {
74
80
  AsyncFunction("cancel") { requestId: String -> cancel(requestId) }
75
81
  AsyncFunction("disconnect") { disconnect() }
76
82
 
77
- OnDestroy { disconnect() }
83
+ OnDestroy {
84
+ disconnect()
85
+ }
78
86
  }
79
87
 
80
88
  private fun connectivityManager(): ConnectivityManager =
@@ -83,11 +91,6 @@ class MentraLocalNetworkModule : Module() {
83
91
  }
84
92
 
85
93
  private fun connect(ssid: String, password: String, promise: Promise) {
86
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
87
- promise.reject("ERR_UNSUPPORTED_ANDROID", "Scoped local WiFi requires Android 10 or newer", null)
88
- return
89
- }
90
-
91
94
  disconnect()
92
95
  val manager = connectivityManager()
93
96
  val specifier =
@@ -105,21 +108,55 @@ class MentraLocalNetworkModule : Module() {
105
108
  val callback =
106
109
  object : ConnectivityManager.NetworkCallback() {
107
110
  override fun onAvailable(network: Network) {
108
- val connectPromise: Promise?
109
111
  synchronized(lock) {
110
112
  if (networkCallback !== this) {
111
113
  Log.d(TAG, "Ignoring onAvailable from a stale network request")
112
114
  return
113
115
  }
116
+ networkReadiness.onAvailable(network)
117
+ }
118
+ Log.i(
119
+ TAG,
120
+ "Captured glasses WiFi network=$network; waiting for foreground capability " +
121
+ "processBoundNetwork=${manager.boundNetworkForProcess}",
122
+ )
123
+ scheduleReadinessTimeout(this, ssid)
124
+ }
125
+
126
+ override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
127
+ val connectPromise: Promise?
128
+ synchronized(lock) {
129
+ if (networkCallback !== this || localNetwork != null || pendingConnectPromise == null) return
130
+ if (!networkReadiness.isReady(
131
+ network,
132
+ capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_FOREGROUND),
133
+ )
134
+ ) {
135
+ return
136
+ }
114
137
  localNetwork = network
115
138
  connectPromise = pendingConnectPromise
116
139
  pendingConnectPromise = null
140
+ cancelReadinessTimeoutLocked()
117
141
  }
118
142
  Log.i(
119
143
  TAG,
120
- "Captured glasses WiFi network=$network processBoundNetwork=${manager.boundNetworkForProcess}",
144
+ "Glasses WiFi network=$network is foreground and ready " +
145
+ "processBoundNetwork=${manager.boundNetworkForProcess}",
146
+ )
147
+ val localAddress =
148
+ manager.getLinkProperties(network)
149
+ ?.linkAddresses
150
+ ?.firstOrNull { it.address is Inet4Address }
151
+ ?.address
152
+ ?.hostAddress
153
+ connectPromise?.resolve(
154
+ buildMap<String, Any> {
155
+ put("connected", true)
156
+ put("ssid", ssid)
157
+ if (!localAddress.isNullOrBlank()) put("localAddress", localAddress)
158
+ },
121
159
  )
122
- connectPromise?.resolve(mapOf("connected" to true, "ssid" to ssid))
123
160
  }
124
161
 
125
162
  override fun onUnavailable() {
@@ -135,13 +172,17 @@ class MentraLocalNetworkModule : Module() {
135
172
 
136
173
  override fun onLost(network: Network) {
137
174
  synchronized(lock) {
138
- if (networkCallback !== this || localNetwork != network) {
175
+ if (networkCallback !== this ||
176
+ (localNetwork != network && !networkReadiness.contains(network))
177
+ ) {
139
178
  Log.d(TAG, "Ignoring onLost from a stale network request")
140
179
  return
141
180
  }
142
181
  localNetwork = null
182
+ networkReadiness.clear()
143
183
  }
144
184
  cancelAll()
185
+ rejectPendingConnect("ERR_LOCAL_WIFI_LOST", "Lost connection to $ssid")
145
186
  clearNetworkCallback(this)
146
187
  sendEvent("networkLost", mapOf("ssid" to ssid))
147
188
  }
@@ -289,6 +330,39 @@ class MentraLocalNetworkModule : Module() {
289
330
  activeConnections.keys.toList().forEach(::cancel)
290
331
  }
291
332
 
333
+ private fun scheduleReadinessTimeout(
334
+ callback: ConnectivityManager.NetworkCallback,
335
+ ssid: String,
336
+ ) {
337
+ val timeout =
338
+ Runnable {
339
+ val connectPromise = synchronized(lock) {
340
+ if (networkCallback !== callback || pendingConnectPromise == null) return@Runnable
341
+ pendingReadinessTimeout = null
342
+ val promise = pendingConnectPromise
343
+ pendingConnectPromise = null
344
+ promise
345
+ }
346
+ connectPromise?.reject(
347
+ "ERR_LOCAL_WIFI_NOT_READY",
348
+ "Connected to $ssid but the network did not become ready",
349
+ null,
350
+ )
351
+ clearNetworkCallback(callback)
352
+ }
353
+ synchronized(lock) {
354
+ if (networkCallback !== callback || pendingConnectPromise == null) return
355
+ cancelReadinessTimeoutLocked()
356
+ pendingReadinessTimeout = timeout
357
+ }
358
+ handler.postDelayed(timeout, NETWORK_READINESS_TIMEOUT_MS)
359
+ }
360
+
361
+ private fun cancelReadinessTimeoutLocked() {
362
+ pendingReadinessTimeout?.let(handler::removeCallbacks)
363
+ pendingReadinessTimeout = null
364
+ }
365
+
292
366
  private fun disconnect() {
293
367
  cancelAll()
294
368
  val manager = appContext.reactContext?.getSystemService(ConnectivityManager::class.java)
@@ -297,6 +371,8 @@ class MentraLocalNetworkModule : Module() {
297
371
  callback = networkCallback
298
372
  networkCallback = null
299
373
  localNetwork = null
374
+ networkReadiness.clear()
375
+ cancelReadinessTimeoutLocked()
300
376
  }
301
377
  if (callback != null && manager != null) {
302
378
  try {
@@ -311,7 +387,12 @@ class MentraLocalNetworkModule : Module() {
311
387
  private fun clearNetworkCallback(callback: ConnectivityManager.NetworkCallback) {
312
388
  val manager = appContext.reactContext?.getSystemService(ConnectivityManager::class.java)
313
389
  synchronized(lock) {
314
- if (networkCallback == callback) networkCallback = null
390
+ if (networkCallback == callback) {
391
+ networkCallback = null
392
+ localNetwork = null
393
+ networkReadiness.clear()
394
+ cancelReadinessTimeoutLocked()
395
+ }
315
396
  }
316
397
  if (manager != null) {
317
398
  try {
@@ -7,21 +7,19 @@ import org.json.JSONArray
7
7
  import org.json.JSONObject
8
8
 
9
9
  internal object OtaManifestDefaults {
10
- private const val SDK_OTA_RELEASE_BASE_URL =
11
- "https://github.com/Mentra-Community/MentraOS/releases/download/bluetooth-sdk-ota"
12
10
  // ASG builds before 39 ignore ota_start.ota_version_url, so SDK checks must
13
11
  // use the same legacy production manifest those glasses will install from.
14
12
  const val LEGACY_PROD_OTA_VERSION_URL = "https://ota.mentraglass.com/prod_live_version.json"
15
13
 
16
14
  fun defaultOtaVersionUrl(): String {
17
- val sdkVersion = BuildConfig.SDK_VERSION.trim()
18
- if (sdkVersion.isBlank() || sdkVersion == "unspecified") {
15
+ val manifestUrl = GeneratedReleaseMetadata.OTA_MANIFEST_URL.trim()
16
+ if (manifestUrl.isBlank()) {
19
17
  throw BluetoothSdkException(
20
- "missing_sdk_version",
21
- "Cannot determine Bluetooth SDK version for the default OTA manifest URL.",
18
+ "ota_manifest_unconfigured",
19
+ "This source-built Bluetooth SDK has no embedded OTA manifest. Configure one with the debug API.",
22
20
  )
23
21
  }
24
- return "$SDK_OTA_RELEASE_BASE_URL/bluetooth-sdk-$sdkVersion-version.json"
22
+ return manifestUrl
25
23
  }
26
24
  }
27
25
 
@@ -0,0 +1,61 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ /** One coordinated Mentra release note authored in /changelogs/<version>.md. */
4
+ data class ReleaseChangelog(
5
+ val version: String,
6
+ val markdown: String,
7
+ )
8
+
9
+ internal object ReleaseChangelogCatalog {
10
+ private val baseVersionPattern = Regex("^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:[-+].*)?$")
11
+
12
+ fun select(
13
+ fromVersion: String?,
14
+ toVersion: String?,
15
+ ): List<ReleaseChangelog> {
16
+ val fallbackTarget =
17
+ GeneratedReleaseMetadata.FAMILY_BASE_VERSION.ifBlank {
18
+ GENERATED_RELEASE_CHANGELOGS.firstOrNull()?.version.orEmpty()
19
+ }
20
+ if (fallbackTarget.isBlank() && toVersion == null) return emptyList()
21
+ val target = baseVersion(toVersion ?: fallbackTarget, "toVersion")
22
+ if (GENERATED_RELEASE_CHANGELOGS.none { it.version == target }) {
23
+ throw BluetoothSdkException("missing_changelog", "No changelog is bundled for target version $target.")
24
+ }
25
+ if (fromVersion == null) return GENERATED_RELEASE_CHANGELOGS.filter { it.version == target }
26
+
27
+ val source = baseVersion(fromVersion, "fromVersion")
28
+ val direction = compareVersions(target, source)
29
+ return GENERATED_RELEASE_CHANGELOGS.filter { entry ->
30
+ when {
31
+ direction == 0 -> entry.version == target
32
+ direction > 0 -> compareVersions(entry.version, source) > 0 && compareVersions(entry.version, target) <= 0
33
+ else -> compareVersions(entry.version, source) < 0 && compareVersions(entry.version, target) >= 0
34
+ }
35
+ }
36
+ }
37
+
38
+ private fun baseVersion(
39
+ version: String,
40
+ label: String,
41
+ ): String {
42
+ val match = baseVersionPattern.matchEntire(version.trim())
43
+ ?: throw BluetoothSdkException(
44
+ "invalid_changelog_version",
45
+ "$label must be a semantic version such as 3.1.0, 3.1.0-dev.4, or 3.1.0-beta.2.",
46
+ )
47
+ return "${match.groupValues[1]}.${match.groupValues[2]}.${match.groupValues[3]}"
48
+ }
49
+
50
+ private fun compareVersions(
51
+ left: String,
52
+ right: String,
53
+ ): Int {
54
+ val a = left.split(".").map(String::toInt)
55
+ val b = right.split(".").map(String::toInt)
56
+ for (index in 0..2) {
57
+ if (a[index] != b[index]) return a[index] - b[index]
58
+ }
59
+ return 0
60
+ }
61
+ }
@@ -0,0 +1,17 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ internal class ScopedNetworkReadiness<T> {
4
+ private var candidate: T? = null
5
+
6
+ fun onAvailable(network: T) {
7
+ candidate = network
8
+ }
9
+
10
+ fun isReady(network: T, isForeground: Boolean): Boolean = candidate == network && isForeground
11
+
12
+ fun contains(network: T): Boolean = candidate == network
13
+
14
+ fun clear() {
15
+ candidate = null
16
+ }
17
+ }
@@ -94,6 +94,21 @@ data class MicLc3Event(
94
94
  }
95
95
  }
96
96
 
97
+ internal data class MicHealth(
98
+ val sequenceGapEvents: Long,
99
+ val decodeFailures: Long,
100
+ val lastLc3ReceivedAt: Long?,
101
+ val lastPcmProducedAt: Long?,
102
+ ) {
103
+ fun toMap(): Map<String, Any> =
104
+ buildMap {
105
+ put("sequenceGapEvents", sequenceGapEvents)
106
+ put("decodeFailures", decodeFailures)
107
+ lastLc3ReceivedAt?.let { put("lastLc3ReceivedAt", it) }
108
+ lastPcmProducedAt?.let { put("lastPcmProducedAt", it) }
109
+ }
110
+ }
111
+
97
112
  data class LocalTranscriptionEvent(
98
113
  val text: String,
99
114
  val isFinal: Boolean,
@@ -39,6 +39,29 @@ data class SpeakingStatusEvent(
39
39
  val values: Map<String, Any>,
40
40
  )
41
41
 
42
+ data class MicHealthEvent(
43
+ val reason: String,
44
+ val sequenceGapEvents: Long,
45
+ val decodeFailures: Long,
46
+ val lastLc3ReceivedAt: Long?,
47
+ val lastPcmProducedAt: Long?,
48
+ val timestamp: Long,
49
+ val values: Map<String, Any>,
50
+ ) {
51
+ companion object {
52
+ internal fun fromMap(values: Map<String, Any>): MicHealthEvent =
53
+ MicHealthEvent(
54
+ reason = stringValue(values, "reason") ?: "",
55
+ sequenceGapEvents = longValue(values, "sequenceGapEvents") ?: 0,
56
+ decodeFailures = longValue(values, "decodeFailures") ?: 0,
57
+ lastLc3ReceivedAt = longValue(values, "lastLc3ReceivedAt"),
58
+ lastPcmProducedAt = longValue(values, "lastPcmProducedAt"),
59
+ timestamp = longValue(values, "timestamp") ?: System.currentTimeMillis(),
60
+ values = values,
61
+ )
62
+ }
63
+ }
64
+
42
65
  data class OtaStartAckEvent(
43
66
  val timestamp: Long?,
44
67
  val values: Map<String, Any>,
@@ -144,6 +167,7 @@ interface MentraBluetoothSdkListener {
144
167
  fun onVersionInfo(event: VersionInfoResult) {}
145
168
  fun onMicPcm(event: MicPcmEvent) {}
146
169
  fun onMicLc3(event: MicLc3Event) {}
170
+ fun onMicHealth(event: MicHealthEvent) {}
147
171
  fun onLocalTranscription(event: LocalTranscriptionEvent) {}
148
172
  fun onDefaultDeviceChanged(device: Device?) {}
149
173
  fun onLog(message: String) {}
@@ -0,0 +1,57 @@
1
+ package com.mentra.bluetoothsdk.net
2
+
3
+ import java.net.Inet4Address
4
+ import java.net.NetworkInterface
5
+
6
+ /**
7
+ * Best-effort local IPv4 discovery for phone-hosted servers the glasses reach over WiFi
8
+ * (photo upload receiver, hotspot-served OTA). Prefers WiFi-ish interfaces carrying RFC1918
9
+ * addresses — under a glasses-hotspot session wlan0 holds the 192.168.43.x client address.
10
+ */
11
+ object LocalIpv4 {
12
+ fun bestLocalIpv4Address(): String? {
13
+ val wifiPrivateCandidates = mutableListOf<Inet4Address>()
14
+ val wifiCandidates = mutableListOf<Inet4Address>()
15
+ val privateCandidates = mutableListOf<Inet4Address>()
16
+ val otherCandidates = mutableListOf<Inet4Address>()
17
+ val interfaces = NetworkInterface.getNetworkInterfaces()?.toList().orEmpty()
18
+ for (networkInterface in interfaces) {
19
+ if (!networkInterface.isUp || networkInterface.isLoopback) {
20
+ continue
21
+ }
22
+ val addresses = networkInterface.inetAddresses.toList()
23
+ .filterIsInstance<Inet4Address>()
24
+ .filter { address ->
25
+ !address.isLoopbackAddress &&
26
+ !address.isLinkLocalAddress
27
+ }
28
+ if (isWifiInterface(networkInterface.name)) {
29
+ wifiPrivateCandidates += addresses.filter { isPrivateIpv4(it.hostAddress.orEmpty()) }
30
+ wifiCandidates += addresses
31
+ } else {
32
+ privateCandidates += addresses.filter { isPrivateIpv4(it.hostAddress.orEmpty()) }
33
+ otherCandidates += addresses
34
+ }
35
+ }
36
+
37
+ return (
38
+ wifiPrivateCandidates.firstOrNull() ?:
39
+ wifiCandidates.firstOrNull() ?:
40
+ privateCandidates.firstOrNull() ?:
41
+ otherCandidates.firstOrNull()
42
+ )?.hostAddress
43
+ }
44
+
45
+ private fun isWifiInterface(name: String): Boolean {
46
+ return name.startsWith("wlan") ||
47
+ name.startsWith("p2p") ||
48
+ name.startsWith("ap") ||
49
+ name.startsWith("swlan")
50
+ }
51
+
52
+ private fun isPrivateIpv4(host: String): Boolean {
53
+ return host.startsWith("192.168.") ||
54
+ host.startsWith("10.") ||
55
+ host.matches(Regex("^172\\.(1[6-9]|2[0-9]|3[0-1])\\..*"))
56
+ }
57
+ }