@mentra/bluetooth-sdk 0.1.11 → 0.1.13

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 (80) hide show
  1. package/README.md +67 -11
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +182 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +73 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +10 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +83 -26
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +266 -31
  9. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdkDebug.kt +14 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/ObservableStore.kt +20 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +153 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +93 -10
  13. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +3 -14
  14. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +3 -6
  15. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/services/PhoneMic.kt +90 -4
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +3835 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +20 -23
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/{Mach1.java → Mach1.kt} +517 -560
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +8712 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +11 -1
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +15 -13
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +7 -14
  24. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -1
  25. package/build/BluetoothSdk.types.d.ts +72 -10
  26. package/build/BluetoothSdk.types.d.ts.map +1 -1
  27. package/build/BluetoothSdk.types.js.map +1 -1
  28. package/build/_private/BluetoothSdkModule.d.ts +9 -7
  29. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  30. package/build/_private/BluetoothSdkModule.js +0 -2
  31. package/build/_private/BluetoothSdkModule.js.map +1 -1
  32. package/build/_private/photoRequestPayload.d.ts +3 -1
  33. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  34. package/build/_private/photoRequestPayload.js +43 -1
  35. package/build/_private/photoRequestPayload.js.map +1 -1
  36. package/build/debug.d.ts +3 -0
  37. package/build/debug.d.ts.map +1 -0
  38. package/build/debug.js +8 -0
  39. package/build/debug.js.map +1 -0
  40. package/build/index.d.ts +1 -1
  41. package/build/index.d.ts.map +1 -1
  42. package/build/index.js +16 -5
  43. package/build/index.js.map +1 -1
  44. package/ios/BluetoothSdkModule.swift +59 -64
  45. package/ios/Source/BluetoothSdkDefaults.swift +47 -0
  46. package/ios/Source/Bridge.swift +6 -0
  47. package/ios/Source/DeviceManager.swift +46 -32
  48. package/ios/Source/DeviceStore.swift +5 -1
  49. package/ios/Source/MentraBluetoothSDK.swift +357 -34
  50. package/ios/Source/MentraBluetoothSDKDebug.swift +12 -0
  51. package/ios/Source/ObservableStore.swift +11 -0
  52. package/ios/Source/OtaManifest.swift +170 -0
  53. package/ios/Source/camera/CameraModels.swift +267 -9
  54. package/ios/Source/controllers/ControllerManager.swift +2 -5
  55. package/ios/Source/controllers/R1.swift +2 -5
  56. package/ios/Source/events/BluetoothEvents.swift +3 -0
  57. package/ios/Source/internal/BluetoothAvailability.swift +20 -0
  58. package/ios/Source/internal/BluetoothSdkAnalytics.swift +160 -0
  59. package/ios/Source/sgcs/G1.swift +6 -5
  60. package/ios/Source/sgcs/G2.swift +20 -18
  61. package/ios/Source/sgcs/Mach1.swift +6 -5
  62. package/ios/Source/sgcs/MentraLive.swift +158 -38
  63. package/ios/Source/sgcs/MentraNex.swift +6 -5
  64. package/ios/Source/sgcs/SGCManager.swift +16 -7
  65. package/ios/Source/sgcs/Simulated.swift +7 -3
  66. package/ios/Source/status/DeviceStatus.swift +1 -1
  67. package/ios/Source/types/DeviceModels.swift +5 -1
  68. package/package.json +6 -1
  69. package/plugin/build/index.d.ts +4 -0
  70. package/plugin/build/withAndroid.d.ts +2 -3
  71. package/plugin/build/withAndroid.js +46 -0
  72. package/plugin/build/withIos.d.ts +2 -3
  73. package/plugin/build/withIos.js +32 -0
  74. package/src/BluetoothSdk.types.ts +80 -10
  75. package/src/_private/BluetoothSdkModule.ts +13 -10
  76. package/src/_private/photoRequestPayload.ts +45 -2
  77. package/src/debug.ts +9 -0
  78. package/src/index.ts +25 -6
  79. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +0 -3974
  80. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +0 -7405
@@ -247,6 +247,7 @@ class DeviceManager {
247
247
  private var audioOutputFormat: AudioOutputFormat = AudioOutputFormat.LC3
248
248
  private var lastLc3Event: Long? = null
249
249
  private var micReinitRunnable: Runnable? = null
250
+ private var systemMicAvailabilityRecheckRunnable: Runnable? = null
250
251
 
251
252
  // VAD
252
253
  private val vadBuffer = mutableListOf<ByteArray>()
@@ -358,6 +359,37 @@ class DeviceManager {
358
359
  }
359
360
  }
360
361
 
362
+ private fun scheduleSystemMicAvailabilityRecheck(reason: String) {
363
+ if (systemMicAvailabilityRecheckRunnable != null) {
364
+ return
365
+ }
366
+
367
+ val recheck =
368
+ object : Runnable {
369
+ override fun run() {
370
+ systemMicAvailabilityRecheckRunnable = null
371
+
372
+ if (!micEnabled || !systemMicUnavailable) {
373
+ return
374
+ }
375
+
376
+ val stillBlocked = phoneMic?.hasBlockingMicInterruption() ?: true
377
+ if (stillBlocked) {
378
+ scheduleSystemMicAvailabilityRecheck(reason)
379
+ return
380
+ }
381
+
382
+ systemMicUnavailable = false
383
+ Bridge.log("MAN: MIC_UNAVAILABLE: FALSE recheck_after_$reason")
384
+ appendLog("MAN: MIC_UNAVAILABLE: FALSE recheck_after_$reason")
385
+ updateMicState()
386
+ }
387
+ }
388
+
389
+ systemMicAvailabilityRecheckRunnable = recheck
390
+ mainHandler.postDelayed(recheck, 2_000)
391
+ }
392
+
361
393
  // MARK: - Unique (Android)
362
394
  private fun setupPermissionMonitoring() {
363
395
  val context = Bridge.getContext()
@@ -947,6 +979,7 @@ class DeviceManager {
947
979
  systemMicUnavailable = true
948
980
  Bridge.log("MAN: MIC_UNAVAILABLE: TRUE external_app_recording")
949
981
  appendLog("MAN: MIC_UNAVAILABLE: TRUE external_app_recording")
982
+ scheduleSystemMicAvailabilityRecheck("external_app_recording")
950
983
  }
951
984
  "audio_focus_available" -> {
952
985
  // Audio focus is available again
@@ -965,6 +998,7 @@ class DeviceManager {
965
998
  systemMicUnavailable = true
966
999
  Bridge.log("MAN: MIC_UNAVAILABLE: TRUE phone_call_interruption")
967
1000
  appendLog("MAN: MIC_UNAVAILABLE: TRUE phone_call_interruption")
1001
+ scheduleSystemMicAvailabilityRecheck("phone_call_interruption")
968
1002
  }
969
1003
  "phone_call_ended" -> {
970
1004
  // Phone call ended - mark mic as available again
@@ -977,12 +1011,14 @@ class DeviceManager {
977
1011
  systemMicUnavailable = true
978
1012
  Bridge.log("MAN: MIC_UNAVAILABLE: TRUE phone_call_active")
979
1013
  appendLog("MAN: MIC_UNAVAILABLE: TRUE phone_call_active")
1014
+ scheduleSystemMicAvailabilityRecheck("phone_call_active")
980
1015
  }
981
1016
  "audio_focus_denied" -> {
982
1017
  // Another app has audio focus
983
1018
  systemMicUnavailable = true
984
1019
  Bridge.log("MAN: MIC_UNAVAILABLE: TRUE audio_focus_denied")
985
1020
  appendLog("MAN: MIC_UNAVAILABLE: TRUE audio_focus_denied")
1021
+ scheduleSystemMicAvailabilityRecheck("audio_focus_denied")
986
1022
  }
987
1023
  "permission_denied" -> {
988
1024
  // Microphone permission not granted
@@ -1010,6 +1046,9 @@ class DeviceManager {
1010
1046
  // systemMicUnavailable = false
1011
1047
  Bridge.log("MAN: MIC_UNAVAILABLE: UNKNOWN recording_stopped")
1012
1048
  appendLog("MAN: MIC_UNAVAILABLE: UNKNOWN recording_stopped")
1049
+ if (systemMicUnavailable) {
1050
+ scheduleSystemMicAvailabilityRecheck("recording_stopped")
1051
+ }
1013
1052
  }
1014
1053
  else -> {
1015
1054
  // Other route changes (headset plug/unplug, BT connect/disconnect, etc.)
@@ -1380,9 +1419,9 @@ class DeviceManager {
1380
1419
  * Send OTA start command to glasses. Called when user approves an update (onboarding or
1381
1420
  * background mode). Triggers glasses to begin download and installation.
1382
1421
  */
1383
- fun sendOtaStart() {
1422
+ fun sendOtaStart(otaVersionUrl: String? = null) {
1384
1423
  Bridge.log("MAN: 📱 Sending OTA start command to glasses")
1385
- (sgc as? MentraLive)?.sendOtaStart()
1424
+ (sgc as? MentraLive)?.sendOtaStart(otaVersionUrl)
1386
1425
  }
1387
1426
 
1388
1427
  fun sendOtaQueryStatus() {
@@ -1396,8 +1435,26 @@ class DeviceManager {
1396
1435
  }
1397
1436
 
1398
1437
  fun sendButtonPhotoSettings(requestId: String, size: String) {
1438
+ sendButtonPhotoSettings(requestId, ButtonPhotoSettings(ButtonPhotoSize.fromValue(size)))
1439
+ }
1440
+
1441
+ fun sendButtonPhotoSettings(requestId: String, settings: ButtonPhotoSettings) {
1399
1442
  val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1400
- live.sendButtonPhotoSettings(requestId, size)
1443
+ live.sendButtonPhotoSettings(
1444
+ requestId,
1445
+ settings.size?.value,
1446
+ settings.mfnr,
1447
+ settings.zsl,
1448
+ settings.noiseReduction,
1449
+ settings.edgeEnhancement,
1450
+ settings.ispDigitalGain,
1451
+ settings.ispAnalogGain,
1452
+ settings.aeExposureDivisor,
1453
+ settings.isoCap,
1454
+ settings.compress,
1455
+ settings.sound,
1456
+ settings.resetCaptureTuning == true,
1457
+ )
1401
1458
  }
1402
1459
 
1403
1460
  fun sendButtonVideoRecordingSettings(requestId: String, width: Int, height: Int, fps: Int) {
@@ -1505,17 +1562,22 @@ class DeviceManager {
1505
1562
  width: Int = 0,
1506
1563
  height: Int = 0,
1507
1564
  fps: Int = 0,
1565
+ maxRecordingTimeMinutes: Int = 0,
1508
1566
  ) {
1509
1567
  Bridge.log(
1510
1568
  "MAN: onStartVideoRecording: requestId=$requestId, save=$save, flash=true, sound=$sound, " +
1511
- "resolution=${width}x${height}@${fps}fps"
1569
+ "resolution=${width}x${height}@${fps}fps, maxRecordingTimeMinutes=$maxRecordingTimeMinutes"
1512
1570
  )
1513
- sgc?.startVideoRecording(requestId, save, true, sound, width, height, fps)
1571
+ sgc?.startVideoRecording(
1572
+ requestId, save, true, sound, width, height, fps, maxRecordingTimeMinutes)
1514
1573
  }
1515
1574
 
1516
- fun stopVideoRecording(requestId: String) {
1517
- Bridge.log("MAN: onStopVideoRecording: requestId=$requestId")
1518
- sgc?.stopVideoRecording(requestId)
1575
+ fun stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
1576
+ Bridge.log(
1577
+ "MAN: onStopVideoRecording: requestId=$requestId, webhook=" +
1578
+ if (webhookUrl.isNullOrEmpty()) "none" else "set"
1579
+ )
1580
+ sgc?.stopVideoRecording(requestId, webhookUrl, authToken)
1519
1581
  }
1520
1582
 
1521
1583
  fun setMicState() {
@@ -1533,38 +1595,31 @@ class DeviceManager {
1533
1595
  updateMicState()
1534
1596
  }
1535
1597
 
1536
- fun requestPhoto(
1537
- requestId: String,
1538
- appId: String,
1539
- size: String,
1540
- webhookUrl: String,
1541
- authToken: String?,
1542
- compress: String,
1543
- flash: Boolean,
1544
- save: Boolean,
1545
- sound: Boolean,
1546
- exposureTimeNs: Double? = null,
1547
- iso: Int? = null,
1548
- ) {
1598
+ fun requestPhoto(request: PhotoRequest) {
1549
1599
  val exposureNs: Long? =
1550
- exposureTimeNs?.takeIf { it.isFinite() && it > 0 }?.let { v ->
1600
+ request.exposureTimeNs?.takeIf { it.isFinite() && it > 0 }?.let { v ->
1551
1601
  when {
1552
1602
  v > Long.MAX_VALUE.toDouble() -> Long.MAX_VALUE
1553
1603
  else -> v.toLong()
1554
1604
  }
1555
1605
  }
1556
- val manualIso = if (exposureNs != null) iso?.takeIf { it > 0 } else null
1606
+ val manualIso = if (exposureNs != null) request.iso?.takeIf { it > 0 } else null
1607
+ val routed =
1608
+ request.copy(
1609
+ exposureTimeNs = exposureNs?.toDouble(),
1610
+ iso = manualIso,
1611
+ )
1557
1612
  Bridge.log(
1558
- "MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=$requestId appId=$appId size=$size compress=$compress flash=$flash save=$save sound=$sound exposureTimeNs=$exposureNs iso=${manualIso ?: "auto"} sgc=${sgc?.javaClass?.simpleName ?: "null"}"
1613
+ "MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=${routed.requestId} appId=${routed.appId} size=${routed.size.value} compress=${routed.compress.value} flash=${routed.flash} save=${routed.save} sound=${routed.sound} exposureTimeNs=$exposureNs iso=${manualIso ?: "auto"} aeDivisor=${routed.aeExposureDivisor} isoCap=${routed.isoCap} sgc=${sgc?.javaClass?.simpleName ?: "null"}"
1559
1614
  )
1560
1615
  val activeSgc = sgc
1561
1616
  if (activeSgc == null) {
1562
1617
  Bridge.log(
1563
- "MAN: PHOTO PIPELINE — sgc is null (glasses not connected); dropping requestId=$requestId"
1618
+ "MAN: PHOTO PIPELINE — sgc is null (glasses not connected); dropping requestId=${routed.requestId}"
1564
1619
  )
1565
1620
  return
1566
1621
  }
1567
- activeSgc.requestPhoto(requestId, appId, size, webhookUrl, authToken, compress, flash, save, sound, exposureNs, manualIso)
1622
+ activeSgc.requestPhoto(routed)
1568
1623
  }
1569
1624
 
1570
1625
  fun rgbLedControl(
@@ -1776,6 +1831,8 @@ class DeviceManager {
1776
1831
 
1777
1832
  micReinitRunnable?.let { mainHandler.removeCallbacks(it) }
1778
1833
  micReinitRunnable = null
1834
+ systemMicAvailabilityRecheckRunnable?.let { mainHandler.removeCallbacks(it) }
1835
+ systemMicAvailabilityRecheckRunnable = null
1779
1836
 
1780
1837
  // Clean up transcriber resources
1781
1838
  transcriber?.shutdown()
@@ -126,7 +126,7 @@ object DeviceStore {
126
126
  store.set("bluetooth", "gallery_mode", true)
127
127
  store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
128
128
  store.set("bluetooth", "screen_disabled", false)
129
- store.set("bluetooth", "button_photo_size", "medium")
129
+ store.set("bluetooth", "button_photo_size", "max")
130
130
  store.set("bluetooth", "button_camera_led", true)
131
131
  store.set("bluetooth", "button_max_recording_time", 10)
132
132
  store.set("bluetooth", "camera_fov", mapOf("fov" to 118, "roi_position" to 0))
@@ -23,6 +23,7 @@ class MentraBluetoothSdk private constructor(
23
23
  private val deviceManager: DeviceManager
24
24
  private val listeners =
25
25
  Collections.synchronizedSet(mutableSetOf<MentraBluetoothSdkListener>())
26
+ private val analytics = BluetoothSdkAnalytics(appContext, config.analytics)
26
27
  private val discoveredDeviceNames = mutableSetOf<String>()
27
28
  private val bridgeEventSinkId: String
28
29
  private val storeListenerId: String
@@ -44,13 +45,20 @@ class MentraBluetoothSdk private constructor(
44
45
  private var pendingWifiStatus: PendingWifiStatusRequest? = null
45
46
  private var pendingHotspotStatus: PendingHotspotStatusRequest? = null
46
47
  private var pendingVersionInfo: PendingResponse<VersionInfoResult>? = null
48
+ @Volatile private var configuredOtaVersionUrl: String? = null
47
49
 
48
50
  init {
49
51
  listeners.add(listener)
50
52
  Bridge.initialize(appContext)
51
53
  deviceManager = DeviceManager.getInstance()
52
54
  bridgeEventSinkId = Bridge.addEventSink { eventName, data -> dispatchBridgeEvent(eventName, data) }
55
+ // Baseline the analytics connection state before subscribing to the store:
56
+ // store updates invoke listeners synchronously on the updating thread, so a
57
+ // connected status observed before the baseline would be reported as a fresh
58
+ // bluetooth_sdk_glasses_connected transition.
59
+ analytics.initializeGlassesStatus(getRawGlassesStatus())
53
60
  storeListenerId = DeviceStore.store.addListener { category, changes -> dispatchStoreUpdate(category, changes) }
61
+ analytics.captureStarted()
54
62
  }
55
63
 
56
64
  companion object {
@@ -58,8 +66,12 @@ class MentraBluetoothSdk private constructor(
58
66
  private val SCAN_STATE_KEYS = setOf("searching", "searchingController", "searchResults")
59
67
  private const val DEFAULT_SCAN_TIMEOUT_MS = 15_000L
60
68
  private const val DEFAULT_REQUEST_TIMEOUT_MS = 15_000L
69
+ private const val VIDEO_UPLOAD_STOP_TIMEOUT_MS = 10 * 60 * 1000L
61
70
  private const val STREAM_START_TIMEOUT_MS = 30_000L
62
71
  private const val STREAM_STOP_TIMEOUT_MS = 15_000L
72
+ private const val OTA_BES_VERSION_WAIT_MS = 5_000L
73
+ private const val OTA_MTK_VERSION_WAIT_MS = 2_000L
74
+ private const val OTA_VERSION_POLL_MS = 100L
63
75
  private const val DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS = 5
64
76
  private const val MAX_MISSED_STREAM_KEEP_ALIVE_ACKS = 3
65
77
 
@@ -97,6 +109,9 @@ class MentraBluetoothSdk private constructor(
97
109
  private data class PendingVideoRecordingRequest(
98
110
  val expectedStatus: String,
99
111
  val pending: PendingResponse<VideoRecordingStatusEvent>,
112
+ val waitForUpload: Boolean = false,
113
+ var stoppedEvent: VideoRecordingStatusEvent? = null,
114
+ var uploadSucceeded: Boolean = false,
100
115
  )
101
116
 
102
117
  private data class PendingWifiScan(
@@ -184,6 +199,15 @@ class MentraBluetoothSdk private constructor(
184
199
 
185
200
  fun getDefaultDevice(): Device? = currentDefaultDevice()
186
201
 
202
+ private fun requireGlassesConnected(operation: String) {
203
+ if (!getRawGlassesStatus().connected) {
204
+ throw BluetoothException(
205
+ "glasses_not_connected",
206
+ "Cannot $operation because glasses are not connected.",
207
+ )
208
+ }
209
+ }
210
+
187
211
  fun setDefaultDevice(device: Device?) {
188
212
  if (device == null) {
189
213
  clearDefaultDevice()
@@ -450,16 +474,59 @@ class MentraBluetoothSdk private constructor(
450
474
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "voice_activity_detection_enabled", enabled)
451
475
  }
452
476
 
453
- fun setButtonPhotoSettings(size: ButtonPhotoSize): SettingsAckEvent =
477
+ fun setButtonPhotoSettings(settings: ButtonPhotoSettings): SettingsAckEvent =
454
478
  performSettingsCommand(
455
479
  setting = "button_photo",
456
- updateStore = { _ -> DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_size", size.value) },
457
- send = { requestId -> deviceManager.sendButtonPhotoSettings(requestId, size.value) },
480
+ updateStore = { _ ->
481
+ if (settings.resetCaptureTuning) {
482
+ // Clear all stored scan-tuning keys from phone cache
483
+ listOf(
484
+ "button_photo_mfnr",
485
+ "button_photo_zsl",
486
+ "button_photo_noise_reduction",
487
+ "button_photo_edge_enhancement",
488
+ "button_photo_isp_digital_gain",
489
+ "button_photo_isp_analog_gain",
490
+ "button_photo_ae_exposure_divisor",
491
+ "button_photo_iso_cap",
492
+ "button_photo_compress",
493
+ "button_photo_sound",
494
+ ).forEach { key ->
495
+ DeviceStore.store.remove(ObservableStore.BLUETOOTH_CATEGORY, key)
496
+ }
497
+ }
498
+ // Only update size if explicitly provided; omitted size = leave stored value unchanged
499
+ settings.size?.let { DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_size", it.value) }
500
+ settings.mfnr?.let { DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_mfnr", it) }
501
+ settings.zsl?.let { DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_zsl", it) }
502
+ settings.noiseReduction?.let {
503
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_noise_reduction", it)
504
+ }
505
+ settings.edgeEnhancement?.let {
506
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_edge_enhancement", it)
507
+ }
508
+ settings.ispDigitalGain?.let {
509
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_isp_digital_gain", it)
510
+ }
511
+ settings.ispAnalogGain?.let {
512
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_isp_analog_gain", it)
513
+ }
514
+ settings.aeExposureDivisor?.let {
515
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_ae_exposure_divisor", it)
516
+ }
517
+ settings.isoCap?.let {
518
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_iso_cap", it)
519
+ }
520
+ settings.compress?.let {
521
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_compress", it)
522
+ }
523
+ settings.sound?.let {
524
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_sound", it)
525
+ }
526
+ },
527
+ send = { requestId -> deviceManager.sendButtonPhotoSettings(requestId, settings) },
458
528
  )
459
529
 
460
- fun setButtonPhotoSettings(settings: ButtonPhotoSettings): SettingsAckEvent =
461
- setButtonPhotoSettings(size = settings.size)
462
-
463
530
  fun setButtonVideoRecordingSettings(width: Int, height: Int, fps: Int): SettingsAckEvent =
464
531
  performSettingsCommand(
465
532
  setting = "button_video_recording",
@@ -657,19 +724,7 @@ class MentraBluetoothSdk private constructor(
657
724
  val pending = PendingResponse<PhotoResponseEvent>("photo request ${request.requestId}")
658
725
  pendingPhotoRequests[request.requestId] = pending
659
726
  try {
660
- deviceManager.requestPhoto(
661
- request.requestId,
662
- request.appId,
663
- request.size.value,
664
- request.webhookUrl,
665
- request.authToken,
666
- request.compress.value,
667
- request.flash,
668
- request.save,
669
- request.sound,
670
- request.exposureTimeNs,
671
- request.iso,
672
- )
727
+ deviceManager.requestPhoto(request)
673
728
  return pending.await()
674
729
  } finally {
675
730
  pendingPhotoRequests.remove(request.requestId, pending)
@@ -771,6 +826,7 @@ class MentraBluetoothSdk private constructor(
771
826
 
772
827
  fun startVideoRecording(request: VideoRecordingRequest): VideoRecordingStatusEvent {
773
828
  require(request.requestId.isNotBlank()) { "requestId is required to start video recording." }
829
+ requireGlassesConnected("start video recording")
774
830
  val pending = PendingResponse<VideoRecordingStatusEvent>("start video recording")
775
831
  val pendingRequest = PendingVideoRecordingRequest("recording_started", pending)
776
832
  if (pendingVideoRecordingRequests.putIfAbsent(request.requestId, pendingRequest) != null) {
@@ -787,6 +843,7 @@ class MentraBluetoothSdk private constructor(
787
843
  request.width,
788
844
  request.height,
789
845
  request.fps,
846
+ request.maxRecordingTimeMinutes,
790
847
  )
791
848
  return pending.await()
792
849
  } finally {
@@ -794,10 +851,22 @@ class MentraBluetoothSdk private constructor(
794
851
  }
795
852
  }
796
853
 
797
- fun stopVideoRecording(requestId: String): VideoRecordingStatusEvent {
854
+ @JvmOverloads
855
+ fun stopVideoRecording(
856
+ requestId: String,
857
+ webhookUrl: String? = null,
858
+ authToken: String? = null,
859
+ ): VideoRecordingStatusEvent {
798
860
  require(requestId.isNotBlank()) { "requestId is required to stop video recording." }
861
+ requireGlassesConnected("stop video recording")
799
862
  val pending = PendingResponse<VideoRecordingStatusEvent>("stop video recording")
800
- val pendingRequest = PendingVideoRecordingRequest("recording_stopped", pending)
863
+ val waitForUpload = !webhookUrl.isNullOrBlank()
864
+ val pendingRequest =
865
+ PendingVideoRecordingRequest(
866
+ expectedStatus = "recording_stopped",
867
+ pending = pending,
868
+ waitForUpload = waitForUpload,
869
+ )
801
870
  if (pendingVideoRecordingRequests.putIfAbsent(requestId, pendingRequest) != null) {
802
871
  throw BluetoothException(
803
872
  "request_in_flight",
@@ -805,8 +874,10 @@ class MentraBluetoothSdk private constructor(
805
874
  )
806
875
  }
807
876
  try {
808
- deviceManager.stopVideoRecording(requestId)
809
- return pending.await()
877
+ deviceManager.stopVideoRecording(requestId, webhookUrl, authToken)
878
+ val timeoutMs =
879
+ if (waitForUpload) VIDEO_UPLOAD_STOP_TIMEOUT_MS else DEFAULT_REQUEST_TIMEOUT_MS
880
+ return pending.await(timeoutMs)
810
881
  } finally {
811
882
  pendingVideoRecordingRequests.remove(requestId, pendingRequest)
812
883
  }
@@ -835,8 +906,41 @@ class MentraBluetoothSdk private constructor(
835
906
  }
836
907
  }
837
908
 
838
- /** Ask connected Mentra Live glasses to check/report OTA availability and status. */
839
- fun checkForOtaUpdate(): OtaQueryResult =
909
+ internal fun setOtaVersionUrl(otaVersionUrl: String) {
910
+ configuredOtaVersionUrl = OtaManifestChecker.normalizeHttpUrl(otaVersionUrl)
911
+ }
912
+
913
+ internal fun getOtaVersionUrl(): String = configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
914
+
915
+ /** Fetch the configured OTA manifest and return whether any ASG/BES/MTK update is available. */
916
+ fun checkForOtaUpdate(): Boolean {
917
+ val status = getFreshGlassesStatus()
918
+ if (!status.connected) {
919
+ throw BluetoothException(
920
+ "glasses_not_connected",
921
+ "Cannot check OTA update because glasses are not connected.",
922
+ )
923
+ }
924
+ if (status.buildNumber.isBlank()) {
925
+ throw BluetoothException(
926
+ "missing_glasses_version",
927
+ "Cannot check OTA update because glasses build number is unavailable.",
928
+ )
929
+ }
930
+
931
+ val manifestUrl = resolveOtaVersionUrl(status)
932
+ val manifest = OtaManifestChecker.fetch(manifestUrl)
933
+ val otaStatus = waitForOtaManifestStatus(status, manifest)
934
+ return OtaManifestChecker.hasUpdate(
935
+ otaStatus.buildNumber,
936
+ otaStatus.mtkFirmwareVersion,
937
+ otaStatus.besFirmwareVersion,
938
+ manifest,
939
+ )
940
+ }
941
+
942
+ /** Ask connected Mentra Live glasses to report the current OTA install/session status. */
943
+ private fun queryOtaStatus(): OtaQueryResult =
840
944
  performOtaQuery("OTA status query") {
841
945
  deviceManager.sendOtaQueryStatus()
842
946
  }
@@ -869,6 +973,11 @@ class MentraBluetoothSdk private constructor(
869
973
 
870
974
  /** Start the OTA flow after your app has presented the available update to the user. */
871
975
  fun startOtaUpdate(): OtaStartAckEvent {
976
+ val otaVersionUrl = resolveOtaVersionUrl(getFreshGlassesStatus())
977
+ return startOtaUpdate(otaVersionUrl)
978
+ }
979
+
980
+ private fun startOtaCommand(otaVersionUrl: String): OtaStartAckEvent {
872
981
  val pending = PendingResponse<OtaStartAckEvent>("OTA start command")
873
982
  synchronized(oneShotLock) {
874
983
  if (pendingOtaStart != null) {
@@ -880,7 +989,7 @@ class MentraBluetoothSdk private constructor(
880
989
  pendingOtaStart = pending
881
990
  }
882
991
  try {
883
- deviceManager.sendOtaStart()
992
+ deviceManager.sendOtaStart(otaVersionUrl)
884
993
  return pending.await()
885
994
  } finally {
886
995
  synchronized(oneShotLock) {
@@ -891,15 +1000,98 @@ class MentraBluetoothSdk private constructor(
891
1000
  }
892
1001
  }
893
1002
 
894
- /** Re-run the glasses-side OTA version check, mainly after correcting clock skew/TLS failures. */
895
- fun retryOtaVersionCheck(): OtaQueryResult =
1003
+ internal fun startOtaUpdate(otaVersionUrl: String): OtaStartAckEvent =
1004
+ startOtaCommand(otaVersionUrl)
1005
+
1006
+ internal fun sendOtaQueryStatus(): OtaQueryResult = queryOtaStatus()
1007
+
1008
+ /** Re-run the glasses-side OTA version check after an internal clock-skew recovery. */
1009
+ @JvmSynthetic
1010
+ internal fun retryOtaVersionCheck(): OtaQueryResult =
896
1011
  performOtaQuery("OTA version retry") {
897
1012
  deviceManager.retryOtaVersionCheck()
898
1013
  }
899
1014
 
900
- internal fun sendOtaStart(): OtaStartAckEvent = startOtaUpdate()
1015
+ private fun getFreshGlassesStatus(): GlassesStatus {
1016
+ val status = getRawGlassesStatus()
1017
+ if (!status.connected || status.buildNumber.isNotBlank()) {
1018
+ return status
1019
+ }
1020
+
1021
+ return try {
1022
+ val versionInfo = requestVersionInfo()
1023
+ status.copy(
1024
+ androidVersion = versionInfo.androidVersion.ifBlank { status.androidVersion },
1025
+ firmwareVersion = versionInfo.firmwareVersion.ifBlank { status.firmwareVersion },
1026
+ besFirmwareVersion = versionInfo.besFirmwareVersion.ifBlank { status.besFirmwareVersion },
1027
+ mtkFirmwareVersion = versionInfo.mtkFirmwareVersion.ifBlank { status.mtkFirmwareVersion },
1028
+ buildNumber = versionInfo.buildNumber.ifBlank { status.buildNumber },
1029
+ systemTimeMs = versionInfo.systemTimeMs ?: status.systemTimeMs,
1030
+ otaVersionUrl = versionInfo.otaVersionUrl.ifBlank { status.otaVersionUrl },
1031
+ appVersion = versionInfo.appVersion.ifBlank { status.appVersion },
1032
+ )
1033
+ } catch (_: Throwable) {
1034
+ status
1035
+ }
1036
+ }
1037
+
1038
+ private fun waitForOtaManifestStatus(
1039
+ initialStatus: GlassesStatus,
1040
+ manifest: org.json.JSONObject,
1041
+ ): GlassesStatus {
1042
+ var status = initialStatus
1043
+ if (OtaManifestChecker.hasBesFirmware(manifest) && status.besFirmwareVersion.isBlank()) {
1044
+ status = waitForGlassesStatus(status, OTA_BES_VERSION_WAIT_MS) {
1045
+ !it.connected || it.besFirmwareVersion.isNotBlank()
1046
+ }
1047
+ }
1048
+
1049
+ if (OtaManifestChecker.hasMtkPatches(manifest) && status.mtkFirmwareVersion.isBlank()) {
1050
+ status = waitForGlassesStatus(status, OTA_MTK_VERSION_WAIT_MS) {
1051
+ !it.connected || it.mtkFirmwareVersion.isNotBlank()
1052
+ }
1053
+ }
1054
+
1055
+ if (!status.connected) {
1056
+ throw BluetoothException(
1057
+ "glasses_not_connected",
1058
+ "Cannot check OTA update because glasses disconnected.",
1059
+ )
1060
+ }
1061
+ return status
1062
+ }
1063
+
1064
+ private fun waitForGlassesStatus(
1065
+ initialStatus: GlassesStatus,
1066
+ timeoutMs: Long,
1067
+ isReady: (GlassesStatus) -> Boolean,
1068
+ ): GlassesStatus {
1069
+ val deadline = System.currentTimeMillis() + timeoutMs
1070
+ var status = initialStatus
1071
+ while (System.currentTimeMillis() < deadline) {
1072
+ status = getRawGlassesStatus()
1073
+ if (isReady(status)) return status
1074
+ val remainingMs = deadline - System.currentTimeMillis()
1075
+ if (remainingMs <= 0L) break
1076
+ Thread.sleep(minOf(OTA_VERSION_POLL_MS, remainingMs))
1077
+ }
1078
+ return getRawGlassesStatus()
1079
+ }
1080
+
1081
+ private fun resolveOtaVersionUrl(status: GlassesStatus): String {
1082
+ val deviceUrl = status.otaVersionUrl.trim()
1083
+ if (isLegacyAsgOtaStartBuild(status.buildNumber)) {
1084
+ return deviceUrl.ifBlank { OtaManifestDefaults.PROD_OTA_VERSION_URL }
1085
+ }
1086
+ // SDK consumers are pinned to the manifest built for their SDK version.
1087
+ // A future glasses-advertised URL should not silently change that pairing.
1088
+ return configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
1089
+ }
901
1090
 
902
- internal fun sendOtaQueryStatus(): OtaQueryResult = checkForOtaUpdate()
1091
+ private fun isLegacyAsgOtaStartBuild(buildNumber: String): Boolean {
1092
+ val parsed = buildNumber.toIntOrNull()
1093
+ return parsed != null && parsed < 100_000
1094
+ }
903
1095
 
904
1096
  internal fun sendShutdown() {
905
1097
  deviceManager.sendShutdown()
@@ -917,12 +1109,14 @@ class MentraBluetoothSdk private constructor(
917
1109
  stopStreamKeepAliveMonitor()
918
1110
  Bridge.removeEventSink(bridgeEventSinkId)
919
1111
  DeviceStore.store.removeListener(storeListenerId)
1112
+ analytics.shutdown()
920
1113
  listeners.clear()
921
1114
  }
922
1115
 
923
1116
  private fun dispatchStoreUpdate(category: String, changes: Map<String, Any>) {
924
1117
  when (ObservableStore.normalizeCategory(category)) {
925
1118
  "glasses" -> {
1119
+ analytics.observeGlassesStatus(getRawGlassesStatus())
926
1120
  val state = getState()
927
1121
  dispatchToListeners {
928
1122
  it.onStateChanged(state)
@@ -1112,6 +1306,11 @@ class MentraBluetoothSdk private constructor(
1112
1306
  handleVideoRecordingStatusForRequests(event)
1113
1307
  dispatchToListeners { it.onVideoRecordingStatus(event) }
1114
1308
  }
1309
+ "media_success", "media_error" -> {
1310
+ val event = MediaUploadEvent(data)
1311
+ handleMediaUploadForRequests(event)
1312
+ dispatchToListeners { it.onMediaUpload(event) }
1313
+ }
1115
1314
  "rgb_led_control_response" -> {
1116
1315
  val event = RgbLedControlResponseEvent(data)
1117
1316
  handleRgbLedResponseForRequests(event)
@@ -1290,6 +1489,14 @@ class MentraBluetoothSdk private constructor(
1290
1489
  return streamId to pending
1291
1490
  }
1292
1491
  if (pendingStreamStarts.size == 1) {
1492
+ // A streamId-less STOPPED is the glasses' stop-ack for a PREVIOUS
1493
+ // stream (their stop ack carries no streamId), not a verdict on the
1494
+ // pending start — a start_stream that replaces a running publisher
1495
+ // emits exactly this sequence (stopped -> initializing -> streaming).
1496
+ // Attributing it here would reject a start that is about to succeed.
1497
+ if (event.state == StreamState.STOPPED) {
1498
+ return null
1499
+ }
1293
1500
  val entry = pendingStreamStarts.entries.first()
1294
1501
  return entry.key to entry.value
1295
1502
  }
@@ -1341,7 +1548,14 @@ class MentraBluetoothSdk private constructor(
1341
1548
  val request = pendingVideoRecordingRequests[event.requestId] ?: return
1342
1549
  if (event.success) {
1343
1550
  if (event.status == request.expectedStatus) {
1344
- request.pending.resolve(event)
1551
+ if (request.waitForUpload) {
1552
+ request.stoppedEvent = event
1553
+ if (request.uploadSucceeded) {
1554
+ request.pending.resolve(event)
1555
+ }
1556
+ } else {
1557
+ request.pending.resolve(event)
1558
+ }
1345
1559
  }
1346
1560
  } else {
1347
1561
  request.pending.reject(
@@ -1353,6 +1567,27 @@ class MentraBluetoothSdk private constructor(
1353
1567
  }
1354
1568
  }
1355
1569
 
1570
+ private fun handleMediaUploadForRequests(event: MediaUploadEvent) {
1571
+ if (!event.isVideo) return
1572
+ val request = pendingVideoRecordingRequests[event.requestId] ?: return
1573
+ if (!request.waitForUpload) return
1574
+ if (event.isSuccess) {
1575
+ val stoppedEvent = request.stoppedEvent
1576
+ if (stoppedEvent != null) {
1577
+ request.pending.resolve(stoppedEvent)
1578
+ } else {
1579
+ request.uploadSucceeded = true
1580
+ }
1581
+ } else {
1582
+ request.pending.reject(
1583
+ BluetoothException(
1584
+ "video_upload_failed",
1585
+ event.errorMessage ?: "Video upload failed.",
1586
+ )
1587
+ )
1588
+ }
1589
+ }
1590
+
1356
1591
  private fun handleRgbLedResponseForRequests(event: RgbLedControlResponseEvent) {
1357
1592
  val pending = pendingRgbLedRequests[event.requestId] ?: return
1358
1593
  if (event.state == "success") {