@mentra/bluetooth-sdk 0.1.21-beta.0 → 0.1.21-beta.2

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 (89) hide show
  1. package/README.md +22 -11
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/AndroidManifest.xml +3 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +34 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +43 -3
  7. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +7 -2
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +82 -6
  9. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +32 -3
  11. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
  12. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +17 -4
  13. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +161 -14
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +3 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +41 -1
  25. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
  29. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +30 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
  36. package/build/BluetoothSdk.types.d.ts +58 -25
  37. package/build/BluetoothSdk.types.d.ts.map +1 -1
  38. package/build/BluetoothSdk.types.js +2 -1
  39. package/build/BluetoothSdk.types.js.map +1 -1
  40. package/build/_internal.d.ts +1 -0
  41. package/build/_internal.d.ts.map +1 -1
  42. package/build/_internal.js +1 -0
  43. package/build/_internal.js.map +1 -1
  44. package/build/_private/BluetoothSdkModule.d.ts +8 -2
  45. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  46. package/build/_private/BluetoothSdkModule.js +6 -3
  47. package/build/_private/BluetoothSdkModule.js.map +1 -1
  48. package/build/_private/cameraRequestPayload.d.ts +1 -1
  49. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  50. package/build/_private/cameraRequestPayload.js +6 -0
  51. package/build/_private/cameraRequestPayload.js.map +1 -1
  52. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  53. package/build/_private/photoRequestPayload.js +10 -1
  54. package/build/_private/photoRequestPayload.js.map +1 -1
  55. package/build/_private/sdkOtaManifest.d.ts +20 -0
  56. package/build/_private/sdkOtaManifest.d.ts.map +1 -0
  57. package/build/_private/sdkOtaManifest.js +28 -0
  58. package/build/_private/sdkOtaManifest.js.map +1 -0
  59. package/build/index.d.ts +1 -1
  60. package/build/index.d.ts.map +1 -1
  61. package/build/index.js +7 -1
  62. package/build/index.js.map +1 -1
  63. package/ios/BluetoothSdkModule.swift +41 -2
  64. package/ios/Source/BluetoothSdkDefaults.swift +2 -1
  65. package/ios/Source/Bridge.swift +15 -3
  66. package/ios/Source/DeviceManager.swift +67 -17
  67. package/ios/Source/DeviceStore.swift +5 -1
  68. package/ios/Source/MentraBluetoothSDK.swift +37 -8
  69. package/ios/Source/OtaManifest.swift +47 -4
  70. package/ios/Source/camera/CameraModels.swift +20 -5
  71. package/ios/Source/internal/BleTraceLogger.swift +1 -1
  72. package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
  73. package/ios/Source/sgcs/Ar99.swift +2451 -0
  74. package/ios/Source/sgcs/MentraLive.swift +174 -38
  75. package/ios/Source/sgcs/SGCManager.swift +8 -0
  76. package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
  77. package/ios/Source/streaming/StreamModels.swift +71 -7
  78. package/ios/Source/types/DeviceModels.swift +13 -0
  79. package/ios/Source/utils/BleJsonCompact.swift +8 -0
  80. package/ios/Source/utils/Constants.swift +2 -0
  81. package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
  82. package/package.json +1 -1
  83. package/src/BluetoothSdk.types.ts +61 -28
  84. package/src/_internal.ts +1 -0
  85. package/src/_private/BluetoothSdkModule.ts +20 -6
  86. package/src/_private/cameraRequestPayload.ts +8 -2
  87. package/src/_private/photoRequestPayload.ts +12 -2
  88. package/src/_private/sdkOtaManifest.ts +31 -0
  89. package/src/index.ts +9 -1
package/README.md CHANGED
@@ -152,11 +152,13 @@ reordering rows just because RSSI metadata arrives later.
152
152
 
153
153
  ## Mentra SDK Usage Analytics
154
154
 
155
- The SDK sends two anonymous usage events to Mentra's PostHog project by default
156
- so Mentra can understand SDK adoption and successful glasses connections:
155
+ The SDK sends three usage events to Mentra's PostHog project by default so
156
+ Mentra can understand SDK adoption, successful glasses connections, and
157
+ enterprise device deployments:
157
158
 
158
159
  - `bluetooth_sdk_started`: sent once per app runtime after the native SDK starts.
159
160
  - `bluetooth_sdk_glasses_connected`: sent when SDK status transitions from not connected to connected.
161
+ - `bluetooth_sdk_glasses_identified`: sent once per connection after the SDK receives a valid manufacturing serial from the glasses. This fires for every supported model that reports a serial. G1 and Ar99 decode the serial from the glasses' BLE advertisement, so they emit this event as soon as this SDK version ships. Mentra Live instead reports the serial provisioned in BES NV storage, which requires compatible BES firmware and `asg_client` software.
160
162
 
161
163
  Analytics delivery is fire-and-forget: events are submitted asynchronously, do
162
164
  not block Bluetooth SDK behavior, and are not retried if delivery fails.
@@ -190,14 +192,23 @@ write token, not a private PostHog personal API key. Apps do not configure the
190
192
  analytics destination; these SDK usage events are always sent to Mentra's
191
193
  PostHog project unless analytics are disabled.
192
194
 
193
- Captured properties are limited to non-sensitive SDK/app metadata:
194
- `event_source`, `sdk_platform`, `sdk_surface`, `sdk_version`, the app package or
195
- bundle identifier, OS platform/version, `event_kind`, and for connection events
196
- only `fully_booted` plus a glasses model value when the SDK knows it. The SDK
197
- does not upload BLE MAC addresses, CoreBluetooth identifiers, serial numbers,
198
- Bluetooth device names, user ids, tokens, Wi-Fi credentials, microphone data,
199
- photos, or transcripts. PostHog receives a locally generated anonymous SDK
200
- install id as `distinct_id`, and events include `$process_person_profile: false`.
195
+ Captured properties include `event_source`, `sdk_platform`, `sdk_surface`,
196
+ `sdk_version`, `app_identifier` (the Android package or iOS bundle identifier),
197
+ the platform-specific `app_package` or `app_bundle_identifier`, OS
198
+ platform/version, and `event_kind`. Connection events also include
199
+ `fully_booted` and a glasses model value when known. The identification event
200
+ intentionally includes the glasses manufacturing serial as `glasses_device_id`,
201
+ with `glasses_device_id_type=manufacturing_serial`, so Mentra can correlate
202
+ fleet deployments across supported models. This serial identifies the glasses
203
+ hardware, not the user or the host phone. Its source depends on the model:
204
+ Mentra Live reports the serial provisioned in BES NV storage, while G1 and Ar99
205
+ decode it from the glasses' BLE advertisement / manufacturer data.
206
+
207
+ Apart from that glasses serial, the SDK does not upload BLE MAC addresses,
208
+ CoreBluetooth identifiers, the host phone's Android device serial, Bluetooth
209
+ device names, user ids, tokens, Wi-Fi credentials, microphone data, photos, or
210
+ transcripts. PostHog receives a locally generated anonymous SDK install id as
211
+ `distinct_id`, and events include `$process_person_profile: false`.
201
212
 
202
213
  ## React Hooks
203
214
 
@@ -408,7 +419,7 @@ await BluetoothSdk.stopStream()
408
419
  ```
409
420
 
410
421
  Use `rtmp://` or `rtmps://` for RTMP, `srt://` for SRT, and `http://` or `https://` for WHIP/WebRTC ingest. `startStream()` resolves with the correlated `stream_status` event once the glasses report `status: "streaming"`; `stopStream()` resolves when the glasses report `status: "stopped"` or confirms the stream was already stopped / not streaming. `stopStream()` returns a normalized stopped event for that already-stopped case. Stream starts reject if the glasses report an error before streaming; stream stops reject for real stop errors, send failure, another stop in flight, or timeout. Stream video input fields are `width`, `height`, `bitrate`, and `fps`. The SDK sends stream keep-alives automatically while streaming and reports keep-alive failures through `stream_status`. The camera light is always enabled while streaming.
411
- `stream_status` events may include `resolvedConfig`, which reports the effective transport, video, and audio settings after glasses defaults, clamps, and camera preflight. The resolved effective frame rate is reported as `resolvedConfig.video.fps`.
422
+ `stream_status` events may include `resolvedConfig`, which reports the effective transport, video, and audio settings after glasses defaults, clamps, and camera preflight. The resolved effective frame rate is reported as `resolvedConfig.video.fps`. While live, supported firmware emits periodic `streaming` events whose `stats` object contains `bitrate` (bits per second), `fps`, `droppedFrames`, `duration` (seconds), and `temperatureC` when the CPU sensor is available.
412
423
 
413
424
  ## Events
414
425
 
@@ -279,6 +279,9 @@ dependencies {
279
279
  // Local photo upload receiver
280
280
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
281
281
 
282
+ // AR99 GATT Opus to PCM decoder.
283
+ implementation 'io.github.jaredmdobson:concentus:1.0.1'
284
+
282
285
  // LC3 audio codec library
283
286
  implementation project(':lc3Lib')
284
287
 
@@ -28,6 +28,8 @@
28
28
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
29
29
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
30
30
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
31
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
32
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
31
33
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
32
34
 
33
35
  <!-- Other permissions -->
@@ -35,6 +37,6 @@
35
37
  <uses-permission android:name="android.permission.VIBRATE"/>
36
38
 
37
39
  <application>
38
- <service android:name="com.mentra.bluetoothsdk.services.ForegroundService" android:exported="false" android:foregroundServiceType="dataSync|connectedDevice|microphone"/>
40
+ <service android:name="com.mentra.bluetoothsdk.services.ForegroundService" android:exported="false" android:foregroundServiceType="dataSync|connectedDevice|location|mediaPlayback|microphone"/>
39
41
  </application>
40
42
  </manifest>
@@ -48,10 +48,17 @@ internal class BluetoothSdkAnalytics(
48
48
  private val config = initialConfig.toRuntimeConfig().resolvedForApp(appContext)
49
49
  private var startedCaptured = false
50
50
  private var lastConnected = false
51
+ private var identifiedCapturedForConnection = false
51
52
 
52
53
  @Synchronized
53
54
  fun initializeGlassesStatus(status: GlassesStatus) {
54
55
  lastConnected = status.analyticsConnected
56
+ // Only treat identification as already captured when a valid serial is
57
+ // present at init. If the glasses are connected but the serial has not
58
+ // arrived yet (Mentra Live fills it via version_info after connect), leave
59
+ // this false so the identify event still fires once the serial arrives.
60
+ identifiedCapturedForConnection =
61
+ status.analyticsConnected && status.serialNumber.validManufacturingSerial() != null
55
62
  }
56
63
 
57
64
  @Synchronized
@@ -67,7 +74,12 @@ internal class BluetoothSdkAnalytics(
67
74
  val wasConnected = lastConnected
68
75
  lastConnected = isConnected
69
76
  if (!config.isReady) return
77
+ if (!isConnected) {
78
+ identifiedCapturedForConnection = false
79
+ return
80
+ }
70
81
  if (isConnected && !wasConnected) {
82
+ identifiedCapturedForConnection = false
71
83
  capture(
72
84
  "bluetooth_sdk_glasses_connected",
73
85
  buildMap {
@@ -76,6 +88,24 @@ internal class BluetoothSdkAnalytics(
76
88
  status.deviceModel.takeIf { it.isNotBlank() }?.let { put("glasses_model", it) }
77
89
  },
78
90
  )
91
+ // Fall through: a serial already present at connect time (G1/Ar99 report
92
+ // it in the advertisement) should be identified now rather than waiting
93
+ // for some later, unrelated glasses-store update to run.
94
+ }
95
+
96
+ val serialNumber = status.serialNumber.validManufacturingSerial() ?: return
97
+ if (!identifiedCapturedForConnection) {
98
+ identifiedCapturedForConnection = true
99
+ capture(
100
+ "bluetooth_sdk_glasses_identified",
101
+ buildMap {
102
+ put("event_kind", "glasses_identified")
103
+ put("fully_booted", status.fullyBooted)
104
+ put("glasses_device_id", serialNumber)
105
+ put("glasses_device_id_type", "manufacturing_serial")
106
+ status.deviceModel.takeIf { it.isNotBlank() }?.let { put("glasses_model", it) }
107
+ },
108
+ )
79
109
  }
80
110
  }
81
111
 
@@ -131,6 +161,7 @@ internal class BluetoothSdkAnalytics(
131
161
  put("sdk_platform", "android")
132
162
  put("sdk_surface", activeConfig.surface)
133
163
  put("sdk_version", BuildConfig.SDK_VERSION)
164
+ put("app_identifier", appContext.packageName)
134
165
  put("app_package", appContext.packageName)
135
166
  put("os_platform", "android")
136
167
  put("os_version", Build.VERSION.SDK_INT)
@@ -180,3 +211,6 @@ private fun BluetoothSdkAnalyticsRuntimeConfig.resolvedForApp(context: Context):
180
211
 
181
212
  private val GlassesStatus.analyticsConnected: Boolean
182
213
  get() = connectionState.isConnected || connected || fullyBooted
214
+
215
+ private fun String.validManufacturingSerial(): String? =
216
+ trim().takeIf { it.isNotEmpty() && !it.matches(Regex("0+")) }
@@ -3,4 +3,5 @@ package com.mentra.bluetoothsdk
3
3
  /** Defaults for the public Bluetooth SDK surface. */
4
4
  object BluetoothSdkDefaults {
5
5
  const val VOICE_ACTIVITY_DETECTION_ENABLED = false
6
+ const val LOUDNESS_GATE_ENABLED = true
6
7
  }
@@ -2,6 +2,8 @@
2
2
 
3
3
  package com.mentra.bluetoothsdk
4
4
 
5
+ import androidx.lifecycle.Lifecycle
6
+ import androidx.lifecycle.LifecycleOwner
5
7
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
6
8
  import com.mentra.bluetoothsdk.utils.DeviceTypes
7
9
  import com.mentra.bluetoothsdk.utils.audio.PcmStreamManager
@@ -12,6 +14,7 @@ import expo.modules.kotlin.modules.ModuleDefinition
12
14
  import expo.modules.kotlin.modules.ModuleDefinitionBuilder
13
15
  import kotlinx.coroutines.Dispatchers
14
16
  import kotlinx.coroutines.withContext
17
+ import java.security.MessageDigest
15
18
 
16
19
  // Expo has no module-level error mapper, so SDK-backed registrations translate
17
20
  // core exceptions here while keeping Expo types out of the native SDK API.
@@ -365,10 +368,12 @@ class BluetoothSdkModule : Module() {
365
368
  "stream_status",
366
369
  "keep_alive_ack",
367
370
  "mtk_update_complete",
371
+ "glasses_session_changed",
368
372
  "ota_progress",
369
373
  "ota_start_ack",
370
374
  "ota_status",
371
- // Nex / BLE debug (NexEventUtils → Bridge.sendTypedMessage)
375
+ "ar99_ota_status",
376
+ // Nex / BLE debug (NexEventUtils —Bridge.sendTypedMessage)
372
377
  "send_command_to_ble",
373
378
  "receive_command_from_ble",
374
379
  "miniapp_selected",
@@ -391,6 +396,21 @@ class BluetoothSdkModule : Module() {
391
396
  sdkListener,
392
397
  )
393
398
  deviceManager = DeviceManager.getInstance()
399
+ val activity = appContext.currentActivity
400
+ val activityIsResumed =
401
+ (activity as? LifecycleOwner)
402
+ ?.lifecycle
403
+ ?.currentState
404
+ ?.isAtLeast(Lifecycle.State.RESUMED) == true
405
+ if (activityIsResumed) {
406
+ deviceManager?.refreshForegroundServiceTypes()
407
+ }
408
+ }
409
+
410
+ OnActivityEntersForeground {
411
+ // Re-run after runtime permission dialogs and every app resume. This is the safe
412
+ // point to add Android's while-in-use location foreground-service type.
413
+ deviceManager?.refreshForegroundServiceTypes()
394
414
  }
395
415
 
396
416
  OnDestroy {
@@ -565,6 +585,10 @@ class BluetoothSdkModule : Module() {
565
585
  sdk?.setVoiceActivityDetectionEnabled(enabled)
566
586
  }
567
587
 
588
+ SdkAsyncFunction("setLoudnessGateEnabled") { enabled: Boolean ->
589
+ sdk?.setLoudnessGateEnabled(enabled)
590
+ }
591
+
568
592
  @Suppress("DEPRECATION")
569
593
  SdkCoroutineFunction("setPhotoCaptureDefaults") { params: Map<String, Any?> ->
570
594
  requireSdk().setPhotoCaptureDefaults(params.toPhotoCaptureDefaults()).values
@@ -643,7 +667,9 @@ class BluetoothSdkModule : Module() {
643
667
  if (exposureRaw != null && exposureRaw.isFinite() && exposureRaw > 0) exposureRaw.toLong() else null
644
668
  val durationRaw = (params["durationMs"] as? Number)?.toInt() ?: 0
645
669
  val durationMs = if (durationRaw > 0) durationRaw else 15000
646
- requireSdk().warmUpCamera(requestId, size, mode, exposureTimeNs, durationMs).values
670
+ val zsl = params["zsl"] as? Boolean
671
+ val mfnr = params["mfnr"] as? Boolean
672
+ requireSdk().warmUpCamera(requestId, size, mode, exposureTimeNs, durationMs, zsl, mfnr).values
647
673
  }
648
674
 
649
675
  SdkAsyncFunction("stopCameraWarmUp") { requestId: String ->
@@ -675,6 +701,18 @@ class BluetoothSdkModule : Module() {
675
701
 
676
702
  SdkCoroutineFunction("sendOtaQueryStatus") { -> requireSdk().sendOtaQueryStatus().values }
677
703
 
704
+ AsyncFunction("startAr99OtaFromFile") { path: String -> requireSdk().startAr99OtaFromFile(path) }
705
+
706
+ AsyncFunction("cancelAr99Ota") { requireSdk().cancelAr99Ota() }
707
+
708
+ AsyncFunction("sendAr99FactoryReset") { requireSdk().sendAr99FactoryReset() }
709
+
710
+
711
+ Function("buildAr99OtaSignature") { secret: String, appName: String, currentVersion: String, serialNumber: String, nonce: String ->
712
+ val raw = secret + appName + "juxinOTA" + currentVersion + serialNumber.trim() + nonce
713
+ val digest = MessageDigest.getInstance("MD5").digest(raw.toByteArray(Charsets.UTF_8))
714
+ digest.joinToString("") { "%02x".format(it.toInt() and 0xff) }
715
+ }
678
716
  // MARK: - Version Info Commands
679
717
 
680
718
  SdkCoroutineFunction("requestVersionInfo") { -> requireSdk().requestVersionInfo().toMap() }
@@ -858,7 +896,7 @@ class BluetoothSdkModule : Module() {
858
896
  }
859
897
 
860
898
  // Runs on Dispatchers.IO, not the shared Expo AsyncFunctionQueue: bz2/tar
861
- // extraction of the 100–350MB model is a multi-minute, CPU-bound job. On the
899
+ // extraction of the 100—50MB model is a multi-minute, CPU-bound job. On the
862
900
  // shared queue it froze every other native call in the app until it finished.
863
901
  AsyncFunction("extractTarBz2") Coroutine { sourcePath: String, destinationPath: String ->
864
902
  withContext(Dispatchers.IO) {
@@ -935,12 +973,14 @@ private fun Map<String, Any>?.toMentraDevice(): Device? {
935
973
  val model = values["model"] as? String ?: return null
936
974
  val name = values["name"] as? String ?: return null
937
975
  val address = values["address"] as? String
976
+ val projectName = values["projectName"] as? String
938
977
  val rssi = (values["rssi"] as? Number)?.toInt()
939
978
  val id = values["id"] as? String
940
979
  return Device(
941
980
  model = DeviceModel.fromDeviceType(model),
942
981
  name = name,
943
982
  address = address?.takeIf { it.isNotBlank() },
983
+ projectName = projectName?.takeIf { it.isNotBlank() },
944
984
  rssi = rssi,
945
985
  id = id?.takeIf { it.isNotBlank() } ?: address?.takeIf { it.isNotBlank() } ?: "$model:$name",
946
986
  )
@@ -250,7 +250,8 @@ public class Bridge private constructor() {
250
250
  deviceModel: String,
251
251
  deviceName: String,
252
252
  deviceAddress: String = "",
253
- rssi: Int? = null
253
+ rssi: Int? = null,
254
+ projectName: String? = null
254
255
  ) {
255
256
  val searchResults =
256
257
  (DeviceStore.store.getCategory("bluetooth")["searchResults"] as? List<*>)
@@ -262,7 +263,7 @@ public class Bridge private constructor() {
262
263
  ?.toMap()
263
264
  }
264
265
  ?: emptyList()
265
- val id = "$deviceModel:$deviceName"
266
+ val id = listOfNotNull(deviceModel, projectName?.takeIf { it.isNotBlank() }, deviceName).joinToString(":")
266
267
  val newResult =
267
268
  buildMap<String, Any> {
268
269
  put("id", id)
@@ -271,6 +272,7 @@ public class Bridge private constructor() {
271
272
  if (deviceAddress.isNotBlank()) {
272
273
  put("address", deviceAddress)
273
274
  }
275
+ projectName?.takeIf { it.isNotBlank() }?.let { put("projectName", it) }
274
276
  rssi?.let { put("rssi", it) }
275
277
  }
276
278
  // Keep the public searchResults array stable as glasses are added or removed.
@@ -872,3 +874,6 @@ public class Bridge private constructor() {
872
874
  }
873
875
  }
874
876
  }
877
+
878
+
879
+
@@ -14,6 +14,7 @@ import com.mentra.bluetoothsdk.controllers.ControllerManager
14
14
  import com.mentra.bluetoothsdk.controllers.R1
15
15
  import com.mentra.bluetoothsdk.services.ForegroundService
16
16
  import com.mentra.bluetoothsdk.services.PhoneMic
17
+ import com.mentra.bluetoothsdk.sgcs.Ar99
17
18
  import com.mentra.bluetoothsdk.sgcs.G1
18
19
  import com.mentra.bluetoothsdk.sgcs.G2
19
20
  import com.mentra.bluetoothsdk.sgcs.SceneElement
@@ -28,6 +29,7 @@ import com.mentra.bluetoothsdk.utils.ControllerTypes
28
29
  import com.mentra.bluetoothsdk.utils.DeviceTypes
29
30
  import com.mentra.bluetoothsdk.utils.MicMap
30
31
  import com.mentra.bluetoothsdk.utils.MicTypes
32
+ import com.mentra.bluetoothsdk.utils.PhoneAudioMonitor
31
33
  import com.mentra.lc3Lib.Lc3Cpp
32
34
  import com.mentra.bluetoothsdk.stt.SherpaOnnxTranscriber
33
35
  import kotlinx.coroutines.CoroutineScope
@@ -334,6 +336,11 @@ class DeviceManager {
334
336
  return
335
337
  }
336
338
 
339
+ if (PhoneAudioMonitor.getInstance(Bridge.getContext()).isOwnAppAudioPlaying()) {
340
+ Bridge.log("MAN: Mentra audio is playing; skipping glasses mic recovery")
341
+ return
342
+ }
343
+
337
344
  // When no frame has ever been received, treat elapsed as "forever" so we
338
345
  // actually attempt recovery (was 0 before, which made the watchdog a no-op).
339
346
  val timeSinceLastLc3Event = System.currentTimeMillis() - (lastLc3Event ?: 0L)
@@ -576,6 +583,36 @@ class DeviceManager {
576
583
  }
577
584
  }
578
585
 
586
+ /**
587
+ * Re-evaluate the active foreground-service types while the host Activity is visible.
588
+ *
589
+ * Location is a while-in-use permission, so Android 14+ only allows the service to add
590
+ * the location type from the foreground. Sending a command to the existing service
591
+ * updates its type mask without tearing down the glasses connection.
592
+ */
593
+ fun refreshForegroundServiceTypes() {
594
+ val context = Bridge.getContext()
595
+
596
+ try {
597
+ Bridge.log("MAN: Refreshing foreground service types")
598
+ val serviceIntent =
599
+ Intent(context, ForegroundService::class.java).apply {
600
+ action = ForegroundService.ACTION_REFRESH_TYPES
601
+ }
602
+
603
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
604
+ context.startForegroundService(serviceIntent)
605
+ } else {
606
+ context.startService(serviceIntent)
607
+ }
608
+
609
+ serviceStarted = true
610
+ Bridge.log("MAN: Foreground service types refreshed")
611
+ } catch (e: Exception) {
612
+ Bridge.log("MAN: Failed to refresh foreground service types: ${e.message}")
613
+ }
614
+ }
615
+
579
616
  private fun restartForegroundService() {
580
617
  val context = Bridge.getContext()
581
618
 
@@ -1122,6 +1159,8 @@ class DeviceManager {
1122
1159
  sgc = MentraLive()
1123
1160
  } else if (wearable.contains(DeviceTypes.NEX)) {
1124
1161
  sgc = MentraNex()
1162
+ } else if (wearable.contains(DeviceTypes.AR99)) {
1163
+ sgc = Ar99()
1125
1164
  } else if (wearable.contains(DeviceTypes.MACH1)) {
1126
1165
  sgc = createOptionalMach1Sgc(DeviceTypes.MACH1)
1127
1166
  } else if (wearable.contains(DeviceTypes.Z100)) {
@@ -1199,6 +1238,10 @@ class DeviceManager {
1199
1238
  }
1200
1239
  )
1201
1240
  nextTranscriber.initialize()
1241
+ if (!nextTranscriber.isInitialized()) {
1242
+ Bridge.log("SherpaOnnxTranscriber initialize() returned without becoming ready")
1243
+ return false
1244
+ }
1202
1245
  transcriber = nextTranscriber
1203
1246
  Bridge.log("SherpaOnnxTranscriber fully initialized")
1204
1247
  true
@@ -1285,6 +1328,10 @@ class DeviceManager {
1285
1328
  Bridge.saveSetting("default_wearable", defaultWearable)
1286
1329
  Bridge.saveSetting("device_name", deviceName)
1287
1330
  Bridge.saveSetting("device_address", deviceAddress)
1331
+ if (defaultWearable.contains(DeviceTypes.AR99)) {
1332
+ val projectName = (DeviceStore.store.get("bluetooth", "project_name") as? String)?.trim().orEmpty()
1333
+ Bridge.saveSetting("project_name", projectName)
1334
+ }
1288
1335
  }
1289
1336
 
1290
1337
  private fun syncSystemTimeOnceForConnection(connectionKey: String) {
@@ -1611,6 +1658,20 @@ class DeviceManager {
1611
1658
  (sgc as? MentraLive)?.sendOtaQueryStatus()
1612
1659
  }
1613
1660
 
1661
+ fun startAr99OtaFromFile(path: String): Boolean {
1662
+ val ar99 = sgc as? Ar99 ?: throw IllegalStateException("unsupported_device")
1663
+ return ar99.startOtaFromFile(path)
1664
+ }
1665
+
1666
+ fun cancelAr99Ota() {
1667
+ (sgc as? Ar99)?.cancelAr99Ota()
1668
+ }
1669
+
1670
+ fun sendAr99FactoryReset() {
1671
+ val ar99 = sgc as? Ar99 ?: throw IllegalStateException("unsupported_device")
1672
+ ar99.sendFactoryReset()
1673
+ }
1674
+
1614
1675
  fun sendGalleryMode(requestId: String, enabled: Boolean) {
1615
1676
  val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1616
1677
  live.sendGalleryMode(requestId, enabled)
@@ -1697,6 +1758,8 @@ class DeviceManager {
1697
1758
  mode: PhotoMode = PhotoMode.PHOTO,
1698
1759
  exposureTimeNs: Long?,
1699
1760
  durationMs: Int,
1761
+ zsl: Boolean? = null,
1762
+ mfnr: Boolean? = null,
1700
1763
  ) {
1701
1764
  // Fail fast like other camera commands so the SDK promise rejects immediately instead of
1702
1765
  // hanging until the request timeout with no camera_status.
@@ -1706,7 +1769,7 @@ class DeviceManager {
1706
1769
  "unsupported_device",
1707
1770
  "This command requires Mentra Live glasses.",
1708
1771
  )
1709
- live.warmUpCamera(requestId, size, mode, exposureTimeNs, durationMs)
1772
+ live.warmUpCamera(requestId, size, mode, exposureTimeNs, durationMs, zsl, mfnr)
1710
1773
  }
1711
1774
 
1712
1775
  fun stopCameraWarmUp(requestId: String) {
@@ -1715,7 +1778,7 @@ class DeviceManager {
1715
1778
  }
1716
1779
 
1717
1780
  /**
1718
- * Read glasses media step volume (0–15) via K900 on Mentra Live only. Blocks until response,
1781
+ * Read glasses media step volume (0—5) via K900 on Mentra Live only. Blocks until response,
1719
1782
  * error, or timeout (used from JS AsyncFunction on a worker thread).
1720
1783
  */
1721
1784
  fun getGlassesMediaVolumeBlocking(): Map<String, Any> {
@@ -1741,7 +1804,7 @@ class DeviceManager {
1741
1804
  return result ?: throw IllegalStateException("glasses_volume_empty")
1742
1805
  }
1743
1806
 
1744
- /** Set glasses media step volume (0–15) via K900 on Mentra Live only. */
1807
+ /** Set glasses media step volume (0—5) via K900 on Mentra Live only. */
1745
1808
  fun setGlassesMediaVolumeBlocking(level: Int): Map<String, Any> {
1746
1809
  val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1747
1810
  val latch = CountDownLatch(1)
@@ -1873,8 +1936,14 @@ class DeviceManager {
1873
1936
  Bridge.log("MAN: No default wearable, returning")
1874
1937
  return
1875
1938
  }
1876
- if (deviceName.isEmpty()) {
1877
- Bridge.log("MAN: No device name, returning")
1939
+ val reconnectTarget =
1940
+ if (defaultWearable.contains(DeviceTypes.AR99) && deviceAddress.isNotBlank()) {
1941
+ deviceAddress
1942
+ } else {
1943
+ deviceName
1944
+ }
1945
+ if (reconnectTarget.isEmpty()) {
1946
+ Bridge.log("MAN: No reconnect target, returning")
1878
1947
  return
1879
1948
  }
1880
1949
  if (!hasBluetoothPermissions()) {
@@ -1886,7 +1955,7 @@ class DeviceManager {
1886
1955
  }
1887
1956
  initSGC(defaultWearable)
1888
1957
  searching = true
1889
- sgc?.connectById(deviceName)
1958
+ sgc?.connectById(reconnectTarget)
1890
1959
  connectDefaultController()
1891
1960
  }
1892
1961
 
@@ -1981,6 +2050,11 @@ class DeviceManager {
1981
2050
  shouldSendBootingMessage = true // Reset for next first connect
1982
2051
  // clear glasses properties:
1983
2052
  DeviceStore.apply("glasses", "deviceModel", "")
2053
+ // A manufacturing serial is session-bound. Clear it on every disconnect so a
2054
+ // previously connected pair's serial can never be reported for the next
2055
+ // connection (e.g. switching from G1/Ar99, which populate it from the
2056
+ // advertisement, to a model that never writes it, like G2).
2057
+ DeviceStore.apply("glasses", "serialNumber", "")
1984
2058
  DeviceStore.apply("glasses", "fullyBooted", false)
1985
2059
  DeviceStore.apply("glasses", "connected", false)
1986
2060
  DeviceStore.apply(
@@ -2019,6 +2093,7 @@ class DeviceManager {
2019
2093
  Bridge.saveSetting("default_wearable", "")
2020
2094
  Bridge.saveSetting("device_name", "")
2021
2095
  Bridge.saveSetting("device_address", "")
2096
+ Bridge.saveSetting("project_name", "")
2022
2097
  }
2023
2098
 
2024
2099
  fun forgetController() {
@@ -2089,3 +2164,4 @@ class DeviceManager {
2089
2164
  }
2090
2165
  }
2091
2166
  }
2167
+
@@ -125,10 +125,11 @@ object DeviceStore {
125
125
  store.set("bluetooth", "contextual_dashboard", true)
126
126
  store.set("bluetooth", "gallery_mode", true)
127
127
  store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
128
+ store.set("bluetooth", "loudness_gate_enabled", BluetoothSdkDefaults.LOUDNESS_GATE_ENABLED)
128
129
  store.set("bluetooth", "screen_disabled", false)
129
130
  store.set("bluetooth", "button_photo_size", "max")
130
131
  store.set("bluetooth", "button_max_recording_time", 10)
131
- store.set("bluetooth", "camera_fov", mapOf("fov" to 102, "roi_position" to 0))
132
+ store.set("bluetooth", "camera_fov", mapOf("fov" to 118, "roi_position" to 0))
132
133
  store.set("bluetooth", "button_video_width", 1280)
133
134
  store.set("bluetooth", "button_video_height", 720)
134
135
  store.set("bluetooth", "button_video_fps", 30)
@@ -268,6 +269,9 @@ object DeviceStore {
268
269
  "bluetooth" to "voice_activity_detection_enabled" -> {
269
270
  DeviceManager.getInstance().sgc?.sendVoiceActivityDetectionSetting()
270
271
  }
272
+ "bluetooth" to "loudness_gate_enabled" -> {
273
+ DeviceManager.getInstance().sgc?.sendLoudnessGateSetting()
274
+ }
271
275
  "bluetooth" to "nex_lc3_audio_playback" -> {
272
276
  (value as? Boolean)?.let { enabled ->
273
277
  Bridge.log("DeviceStore: nex_lc3_audio_playback changed to $enabled")
@@ -68,7 +68,7 @@ class MentraBluetoothSdk private constructor(
68
68
  }
69
69
 
70
70
  companion object {
71
- private val DEFAULT_DEVICE_KEYS = setOf("default_wearable", "device_name", "device_address")
71
+ private val DEFAULT_DEVICE_KEYS = setOf("default_wearable", "device_name", "device_address", "project_name")
72
72
  private val SCAN_STATE_KEYS = setOf("searching", "searchingController", "searchResults")
73
73
  private const val DEFAULT_SCAN_TIMEOUT_MS = 15_000L
74
74
  private const val DEFAULT_REQUEST_TIMEOUT_MS = 15_000L
@@ -257,6 +257,7 @@ class MentraBluetoothSdk private constructor(
257
257
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "default_wearable", device.model.deviceType)
258
258
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_name", device.name)
259
259
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_address", device.address ?: "")
260
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "project_name", device.projectName ?: "")
260
261
  } finally {
261
262
  suppressDefaultDeviceEvents = false
262
263
  }
@@ -269,6 +270,7 @@ class MentraBluetoothSdk private constructor(
269
270
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "default_wearable", "")
270
271
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_name", "")
271
272
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_address", "")
273
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "project_name", "")
272
274
  } finally {
273
275
  suppressDefaultDeviceEvents = false
274
276
  }
@@ -513,10 +515,14 @@ class MentraBluetoothSdk private constructor(
513
515
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "voice_activity_detection_enabled", enabled)
514
516
  }
515
517
 
518
+ fun setLoudnessGateEnabled(enabled: Boolean) {
519
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "loudness_gate_enabled", enabled)
520
+ }
521
+
516
522
  @Deprecated(
517
523
  message =
518
524
  "Sticky action-button photo presets are deprecated. Prefer per-request " +
519
- "requestPhoto(...) options (e.g. mode=TEXT for AE ÷3, or explicit per-shot fields). " +
525
+ "requestPhoto(...) options (e.g. mode=TEXT for text sensor size/crop, or explicit per-shot fields). " +
520
526
  "This method still works but will be removed in a future release.",
521
527
  )
522
528
  suspend fun setPhotoCaptureDefaults(settings: PhotoCaptureDefaults): SettingsAckEvent =
@@ -526,6 +532,7 @@ class MentraBluetoothSdk private constructor(
526
532
  if (settings.resetCaptureTuning) {
527
533
  // Clear all stored scan-tuning keys from phone cache
528
534
  listOf(
535
+ "button_photo_zsl_mfnr",
529
536
  "button_photo_mfnr",
530
537
  "button_photo_zsl",
531
538
  "button_photo_noise_reduction",
@@ -877,6 +884,8 @@ class MentraBluetoothSdk private constructor(
877
884
  mode: PhotoMode = PhotoMode.PHOTO,
878
885
  exposureTimeNs: Long?,
879
886
  durationMs: Int,
887
+ zsl: Boolean? = null,
888
+ mfnr: Boolean? = null,
880
889
  ): CameraStatusEvent {
881
890
  val effectiveRequestId = nonBlankRequestId(requestId) ?: generatedCameraRequestId("warm")
882
891
  val pending = PendingResponse<CameraStatusEvent>("camera warm up $effectiveRequestId")
@@ -889,7 +898,7 @@ class MentraBluetoothSdk private constructor(
889
898
  )
890
899
  }
891
900
  try {
892
- deviceManager.warmUpCamera(effectiveRequestId, size, mode, exposureTimeNs, durationMs)
901
+ deviceManager.warmUpCamera(effectiveRequestId, size, mode, exposureTimeNs, durationMs, zsl, mfnr)
893
902
  return pending.await()
894
903
  } finally {
895
904
  pendingCameraStatusRequests.remove(effectiveRequestId, pending)
@@ -1200,6 +1209,20 @@ class MentraBluetoothSdk private constructor(
1200
1209
 
1201
1210
  internal suspend fun sendOtaQueryStatus(): OtaQueryResult = queryOtaStatus()
1202
1211
 
1212
+ fun startAr99OtaFromFile(path: String): Boolean {
1213
+ requireGlassesConnected("start AR99 OTA")
1214
+ return deviceManager.startAr99OtaFromFile(path)
1215
+ }
1216
+
1217
+ fun cancelAr99Ota() {
1218
+ deviceManager.cancelAr99Ota()
1219
+ }
1220
+
1221
+ fun sendAr99FactoryReset() {
1222
+ requireGlassesConnected("factory reset AR99")
1223
+ deviceManager.sendAr99FactoryReset()
1224
+ }
1225
+
1203
1226
  private suspend fun getFreshGlassesStatus(): GlassesStatus {
1204
1227
  val status = getRawGlassesStatus()
1205
1228
  if (!status.connected || status.buildNumber.isNotBlank()) {
@@ -1345,10 +1368,12 @@ class MentraBluetoothSdk private constructor(
1345
1368
  val name = core["device_name"] as? String ?: return null
1346
1369
  if (model.isBlank() || name.isBlank()) return null
1347
1370
  val address = (core["device_address"] as? String)?.takeIf { it.isNotBlank() }
1371
+ val projectName = (core["project_name"] as? String)?.takeIf { it.isNotBlank() }
1348
1372
  return Device(
1349
1373
  model = DeviceModel.fromDeviceType(model),
1350
1374
  name = name,
1351
1375
  address = address,
1376
+ projectName = projectName,
1352
1377
  )
1353
1378
  }
1354
1379
 
@@ -2135,3 +2160,7 @@ class MentraBluetoothSdk private constructor(
2135
2160
  }
2136
2161
  }
2137
2162
  }
2163
+
2164
+
2165
+
2166
+