@mentra/bluetooth-sdk 0.1.12 → 0.1.14

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 (69) hide show
  1. package/README.md +30 -11
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +47 -12
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +75 -29
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -5
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +208 -45
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdkDebug.kt +14 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/ObservableStore.kt +20 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +153 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +80 -14
  10. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +4 -16
  11. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +4 -8
  12. package/android/src/main/java/com/mentra/bluetoothsdk/services/PhoneMic.kt +90 -4
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +3831 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +20 -28
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/{Mach1.java → Mach1.kt} +514 -561
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +8674 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +5 -17
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +9 -20
  20. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  21. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -33
  22. package/build/BluetoothSdk.types.d.ts +48 -17
  23. package/build/BluetoothSdk.types.d.ts.map +1 -1
  24. package/build/BluetoothSdk.types.js.map +1 -1
  25. package/build/_private/BluetoothSdkModule.d.ts +9 -9
  26. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  27. package/build/_private/BluetoothSdkModule.js +0 -6
  28. package/build/_private/BluetoothSdkModule.js.map +1 -1
  29. package/build/_private/photoRequestPayload.d.ts +3 -1
  30. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  31. package/build/_private/photoRequestPayload.js +46 -2
  32. package/build/_private/photoRequestPayload.js.map +1 -1
  33. package/build/debug.d.ts +3 -0
  34. package/build/debug.d.ts.map +1 -0
  35. package/build/debug.js +8 -0
  36. package/build/debug.js.map +1 -0
  37. package/build/index.d.ts +1 -1
  38. package/build/index.d.ts.map +1 -1
  39. package/build/index.js +8 -8
  40. package/build/index.js.map +1 -1
  41. package/ios/BluetoothSdkModule.swift +44 -68
  42. package/ios/Source/BluetoothSdkDefaults.swift +23 -3
  43. package/ios/Source/DeviceManager.swift +38 -33
  44. package/ios/Source/DeviceStore.swift +5 -5
  45. package/ios/Source/MentraBluetoothSDK.swift +217 -50
  46. package/ios/Source/MentraBluetoothSDKDebug.swift +12 -0
  47. package/ios/Source/ObservableStore.swift +11 -0
  48. package/ios/Source/OtaManifest.swift +170 -0
  49. package/ios/Source/camera/CameraModels.swift +219 -13
  50. package/ios/Source/controllers/ControllerManager.swift +3 -7
  51. package/ios/Source/controllers/R1.swift +3 -7
  52. package/ios/Source/sgcs/Frame.swift +0 -2
  53. package/ios/Source/sgcs/G1.swift +7 -8
  54. package/ios/Source/sgcs/G2.swift +21 -20
  55. package/ios/Source/sgcs/Mach1.swift +7 -8
  56. package/ios/Source/sgcs/MentraLive.swift +132 -63
  57. package/ios/Source/sgcs/MentraNex.swift +7 -8
  58. package/ios/Source/sgcs/SGCManager.swift +7 -10
  59. package/ios/Source/sgcs/Simulated.swift +8 -8
  60. package/ios/Source/status/DeviceStatus.swift +1 -9
  61. package/ios/Source/streaming/StreamModels.swift +7 -32
  62. package/package.json +6 -1
  63. package/src/BluetoothSdk.types.ts +49 -17
  64. package/src/_private/BluetoothSdkModule.ts +9 -17
  65. package/src/_private/photoRequestPayload.ts +48 -3
  66. package/src/debug.ts +9 -0
  67. package/src/index.ts +19 -10
  68. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +0 -3974
  69. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +0 -7434
@@ -45,6 +45,7 @@ class MentraBluetoothSdk private constructor(
45
45
  private var pendingWifiStatus: PendingWifiStatusRequest? = null
46
46
  private var pendingHotspotStatus: PendingHotspotStatusRequest? = null
47
47
  private var pendingVersionInfo: PendingResponse<VersionInfoResult>? = null
48
+ @Volatile private var configuredOtaVersionUrl: String? = null
48
49
 
49
50
  init {
50
51
  listeners.add(listener)
@@ -68,6 +69,9 @@ class MentraBluetoothSdk private constructor(
68
69
  private const val VIDEO_UPLOAD_STOP_TIMEOUT_MS = 10 * 60 * 1000L
69
70
  private const val STREAM_START_TIMEOUT_MS = 30_000L
70
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
71
75
  private const val DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS = 5
72
76
  private const val MAX_MISSED_STREAM_KEEP_ALIVE_ACKS = 3
73
77
 
@@ -470,40 +474,73 @@ class MentraBluetoothSdk private constructor(
470
474
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "voice_activity_detection_enabled", enabled)
471
475
  }
472
476
 
473
- fun setButtonPhotoSettings(size: ButtonPhotoSize): SettingsAckEvent =
477
+ fun setPhotoCaptureDefaults(settings: PhotoCaptureDefaults): SettingsAckEvent =
474
478
  performSettingsCommand(
475
479
  setting = "button_photo",
476
- updateStore = { _ -> DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_size", size.value) },
477
- 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) },
478
528
  )
479
529
 
480
- fun setButtonPhotoSettings(settings: ButtonPhotoSettings): SettingsAckEvent =
481
- setButtonPhotoSettings(size = settings.size)
482
-
483
- fun setButtonVideoRecordingSettings(width: Int, height: Int, fps: Int): SettingsAckEvent =
530
+ fun setVideoRecordingDefaults(defaults: VideoRecordingDefaults): SettingsAckEvent =
484
531
  performSettingsCommand(
485
532
  setting = "button_video_recording",
486
533
  updateStore = { _ ->
487
- DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_width", width)
488
- DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_height", height)
489
- DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_fps", fps)
534
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_width", defaults.width)
535
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_height", defaults.height)
536
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_fps", defaults.fps)
490
537
  },
491
538
  send = { requestId ->
492
- deviceManager.sendButtonVideoRecordingSettings(requestId, width, height, fps)
539
+ deviceManager.sendButtonVideoRecordingSettings(requestId, defaults.width, defaults.height, defaults.fps)
493
540
  },
494
541
  )
495
542
 
496
- fun setButtonVideoRecordingSettings(settings: ButtonVideoRecordingSettings): SettingsAckEvent =
497
- setButtonVideoRecordingSettings(width = settings.width, height = settings.height, fps = settings.fps)
498
-
499
- fun setButtonCameraLed(enabled: Boolean): SettingsAckEvent =
500
- performSettingsCommand(
501
- setting = "button_camera_led",
502
- updateStore = { _ -> DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_camera_led", enabled) },
503
- send = { requestId -> deviceManager.sendButtonCameraLedSetting(requestId, enabled) },
504
- )
505
-
506
- fun setButtonMaxRecordingTime(minutes: Int): SettingsAckEvent =
543
+ fun setMaxVideoRecordingDuration(minutes: Int): SettingsAckEvent =
507
544
  performSettingsCommand(
508
545
  setting = "button_max_recording_time",
509
546
  updateStore = { _ ->
@@ -677,19 +714,7 @@ class MentraBluetoothSdk private constructor(
677
714
  val pending = PendingResponse<PhotoResponseEvent>("photo request ${request.requestId}")
678
715
  pendingPhotoRequests[request.requestId] = pending
679
716
  try {
680
- deviceManager.requestPhoto(
681
- request.requestId,
682
- request.appId,
683
- request.size.value,
684
- request.webhookUrl,
685
- request.authToken,
686
- request.compress.value,
687
- request.flash,
688
- request.save,
689
- request.sound,
690
- request.exposureTimeNs,
691
- request.iso,
692
- )
717
+ deviceManager.requestPhoto(request)
693
718
  return pending.await()
694
719
  } finally {
695
720
  pendingPhotoRequests.remove(request.requestId, pending)
@@ -719,7 +744,16 @@ class MentraBluetoothSdk private constructor(
719
744
  }
720
745
  }
721
746
 
722
- fun startStream(request: StreamRequest): StreamStatusEvent {
747
+ fun startStream(request: StreamRequest): StreamStatusEvent =
748
+ startStream(request, startSdkKeepAlive = true)
749
+
750
+ internal fun startExternallyManagedStream(request: StreamRequest): StreamStatusEvent =
751
+ startStream(request, startSdkKeepAlive = false)
752
+
753
+ private fun startStream(
754
+ request: StreamRequest,
755
+ startSdkKeepAlive: Boolean,
756
+ ): StreamStatusEvent {
723
757
  val message = request.toMap().toMutableMap()
724
758
  val streamId = (message["streamId"] as? String)?.takeIf { it.isNotBlank() }
725
759
  ?: "sdk-${UUID.randomUUID()}"
@@ -730,8 +764,8 @@ class MentraBluetoothSdk private constructor(
730
764
  try {
731
765
  deviceManager.startStream(message)
732
766
  val event = pending.await(STREAM_START_TIMEOUT_MS)
733
- if (request.keepAlive && !request.isExternallyManagedKeepAlive()) {
734
- startStreamKeepAliveMonitor(streamId, request.keepAliveIntervalSeconds)
767
+ if (startSdkKeepAlive) {
768
+ startStreamKeepAliveMonitor(streamId, DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS)
735
769
  }
736
770
  return event
737
771
  } finally {
@@ -871,8 +905,41 @@ class MentraBluetoothSdk private constructor(
871
905
  }
872
906
  }
873
907
 
874
- /** Ask connected Mentra Live glasses to check/report OTA availability and status. */
875
- fun checkForOtaUpdate(): OtaQueryResult =
908
+ internal fun setOtaVersionUrl(otaVersionUrl: String) {
909
+ configuredOtaVersionUrl = OtaManifestChecker.normalizeHttpUrl(otaVersionUrl)
910
+ }
911
+
912
+ internal fun getOtaVersionUrl(): String = configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
913
+
914
+ /** Fetch the configured OTA manifest and return whether any ASG/BES/MTK update is available. */
915
+ fun checkForOtaUpdate(): Boolean {
916
+ val status = getFreshGlassesStatus()
917
+ if (!status.connected) {
918
+ throw BluetoothException(
919
+ "glasses_not_connected",
920
+ "Cannot check OTA update because glasses are not connected.",
921
+ )
922
+ }
923
+ if (status.buildNumber.isBlank()) {
924
+ throw BluetoothException(
925
+ "missing_glasses_version",
926
+ "Cannot check OTA update because glasses build number is unavailable.",
927
+ )
928
+ }
929
+
930
+ val manifestUrl = resolveOtaVersionUrl(status)
931
+ val manifest = OtaManifestChecker.fetch(manifestUrl)
932
+ val otaStatus = waitForOtaManifestStatus(status, manifest)
933
+ return OtaManifestChecker.hasUpdate(
934
+ otaStatus.buildNumber,
935
+ otaStatus.mtkFirmwareVersion,
936
+ otaStatus.besFirmwareVersion,
937
+ manifest,
938
+ )
939
+ }
940
+
941
+ /** Ask connected Mentra Live glasses to report the current OTA install/session status. */
942
+ private fun queryOtaStatus(): OtaQueryResult =
876
943
  performOtaQuery("OTA status query") {
877
944
  deviceManager.sendOtaQueryStatus()
878
945
  }
@@ -905,6 +972,11 @@ class MentraBluetoothSdk private constructor(
905
972
 
906
973
  /** Start the OTA flow after your app has presented the available update to the user. */
907
974
  fun startOtaUpdate(): OtaStartAckEvent {
975
+ val otaVersionUrl = resolveOtaVersionUrl(getFreshGlassesStatus())
976
+ return startOtaUpdate(otaVersionUrl)
977
+ }
978
+
979
+ private fun startOtaCommand(otaVersionUrl: String): OtaStartAckEvent {
908
980
  val pending = PendingResponse<OtaStartAckEvent>("OTA start command")
909
981
  synchronized(oneShotLock) {
910
982
  if (pendingOtaStart != null) {
@@ -916,7 +988,7 @@ class MentraBluetoothSdk private constructor(
916
988
  pendingOtaStart = pending
917
989
  }
918
990
  try {
919
- deviceManager.sendOtaStart()
991
+ deviceManager.sendOtaStart(otaVersionUrl)
920
992
  return pending.await()
921
993
  } finally {
922
994
  synchronized(oneShotLock) {
@@ -927,15 +999,98 @@ class MentraBluetoothSdk private constructor(
927
999
  }
928
1000
  }
929
1001
 
930
- /** Re-run the glasses-side OTA version check, mainly after correcting clock skew/TLS failures. */
931
- fun retryOtaVersionCheck(): OtaQueryResult =
1002
+ internal fun startOtaUpdate(otaVersionUrl: String): OtaStartAckEvent =
1003
+ startOtaCommand(otaVersionUrl)
1004
+
1005
+ internal fun sendOtaQueryStatus(): OtaQueryResult = queryOtaStatus()
1006
+
1007
+ /** Re-run the glasses-side OTA version check after an internal clock-skew recovery. */
1008
+ @JvmSynthetic
1009
+ internal fun retryOtaVersionCheck(): OtaQueryResult =
932
1010
  performOtaQuery("OTA version retry") {
933
1011
  deviceManager.retryOtaVersionCheck()
934
1012
  }
935
1013
 
936
- internal fun sendOtaStart(): OtaStartAckEvent = startOtaUpdate()
1014
+ private fun getFreshGlassesStatus(): GlassesStatus {
1015
+ val status = getRawGlassesStatus()
1016
+ if (!status.connected || status.buildNumber.isNotBlank()) {
1017
+ return status
1018
+ }
1019
+
1020
+ return try {
1021
+ val versionInfo = requestVersionInfo()
1022
+ status.copy(
1023
+ androidVersion = versionInfo.androidVersion.ifBlank { status.androidVersion },
1024
+ firmwareVersion = versionInfo.firmwareVersion.ifBlank { status.firmwareVersion },
1025
+ besFirmwareVersion = versionInfo.besFirmwareVersion.ifBlank { status.besFirmwareVersion },
1026
+ mtkFirmwareVersion = versionInfo.mtkFirmwareVersion.ifBlank { status.mtkFirmwareVersion },
1027
+ buildNumber = versionInfo.buildNumber.ifBlank { status.buildNumber },
1028
+ systemTimeMs = versionInfo.systemTimeMs ?: status.systemTimeMs,
1029
+ otaVersionUrl = versionInfo.otaVersionUrl.ifBlank { status.otaVersionUrl },
1030
+ appVersion = versionInfo.appVersion.ifBlank { status.appVersion },
1031
+ )
1032
+ } catch (_: Throwable) {
1033
+ status
1034
+ }
1035
+ }
1036
+
1037
+ private fun waitForOtaManifestStatus(
1038
+ initialStatus: GlassesStatus,
1039
+ manifest: org.json.JSONObject,
1040
+ ): GlassesStatus {
1041
+ var status = initialStatus
1042
+ if (OtaManifestChecker.hasBesFirmware(manifest) && status.besFirmwareVersion.isBlank()) {
1043
+ status = waitForGlassesStatus(status, OTA_BES_VERSION_WAIT_MS) {
1044
+ !it.connected || it.besFirmwareVersion.isNotBlank()
1045
+ }
1046
+ }
1047
+
1048
+ if (OtaManifestChecker.hasMtkPatches(manifest) && status.mtkFirmwareVersion.isBlank()) {
1049
+ status = waitForGlassesStatus(status, OTA_MTK_VERSION_WAIT_MS) {
1050
+ !it.connected || it.mtkFirmwareVersion.isNotBlank()
1051
+ }
1052
+ }
1053
+
1054
+ if (!status.connected) {
1055
+ throw BluetoothException(
1056
+ "glasses_not_connected",
1057
+ "Cannot check OTA update because glasses disconnected.",
1058
+ )
1059
+ }
1060
+ return status
1061
+ }
1062
+
1063
+ private fun waitForGlassesStatus(
1064
+ initialStatus: GlassesStatus,
1065
+ timeoutMs: Long,
1066
+ isReady: (GlassesStatus) -> Boolean,
1067
+ ): GlassesStatus {
1068
+ val deadline = System.currentTimeMillis() + timeoutMs
1069
+ var status = initialStatus
1070
+ while (System.currentTimeMillis() < deadline) {
1071
+ status = getRawGlassesStatus()
1072
+ if (isReady(status)) return status
1073
+ val remainingMs = deadline - System.currentTimeMillis()
1074
+ if (remainingMs <= 0L) break
1075
+ Thread.sleep(minOf(OTA_VERSION_POLL_MS, remainingMs))
1076
+ }
1077
+ return getRawGlassesStatus()
1078
+ }
1079
+
1080
+ private fun resolveOtaVersionUrl(status: GlassesStatus): String {
1081
+ val deviceUrl = status.otaVersionUrl.trim()
1082
+ if (isLegacyAsgOtaStartBuild(status.buildNumber)) {
1083
+ return deviceUrl.ifBlank { OtaManifestDefaults.PROD_OTA_VERSION_URL }
1084
+ }
1085
+ // SDK consumers are pinned to the manifest built for their SDK version.
1086
+ // A future glasses-advertised URL should not silently change that pairing.
1087
+ return configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
1088
+ }
937
1089
 
938
- internal fun sendOtaQueryStatus(): OtaQueryResult = checkForOtaUpdate()
1090
+ private fun isLegacyAsgOtaStartBuild(buildNumber: String): Boolean {
1091
+ val parsed = buildNumber.toIntOrNull()
1092
+ return parsed != null && parsed < 100_000
1093
+ }
939
1094
 
940
1095
  internal fun sendShutdown() {
941
1096
  deviceManager.sendShutdown()
@@ -1333,6 +1488,14 @@ class MentraBluetoothSdk private constructor(
1333
1488
  return streamId to pending
1334
1489
  }
1335
1490
  if (pendingStreamStarts.size == 1) {
1491
+ // A streamId-less STOPPED is the glasses' stop-ack for a PREVIOUS
1492
+ // stream (their stop ack carries no streamId), not a verdict on the
1493
+ // pending start — a start_stream that replaces a running publisher
1494
+ // emits exactly this sequence (stopped -> initializing -> streaming).
1495
+ // Attributing it here would reject a start that is about to succeed.
1496
+ if (event.state == StreamState.STOPPED) {
1497
+ return null
1498
+ }
1336
1499
  val entry = pendingStreamStarts.entries.first()
1337
1500
  return entry.key to entry.value
1338
1501
  }
@@ -0,0 +1,14 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ object MentraBluetoothSdkDebug {
4
+ @JvmStatic
5
+ fun setOtaVersionUrl(
6
+ sdk: MentraBluetoothSdk,
7
+ otaVersionUrl: String,
8
+ ) {
9
+ sdk.setOtaVersionUrl(otaVersionUrl)
10
+ }
11
+
12
+ @JvmStatic
13
+ fun getOtaVersionUrl(sdk: MentraBluetoothSdk): String = sdk.getOtaVersionUrl()
14
+ }
@@ -53,6 +53,26 @@ class ObservableStore {
53
53
  listeners.forEach { it(normalizedCategory, mapOf(key to value)) }
54
54
  }
55
55
 
56
+ fun remove(category: String, key: String) {
57
+ val normalizedCategory: String
58
+ val updatedSnapshot: Map<String, Any>
59
+ val listeners: List<(String, Map<String, Any>) -> Unit>
60
+
61
+ synchronized(this) {
62
+ normalizedCategory = normalizeCategory(category)
63
+ val fullKey = "$normalizedCategory.$key"
64
+ if (!values.containsKey(fullKey)) return // already absent, no notification needed
65
+ values.remove(fullKey)
66
+ val prefix = "$normalizedCategory."
67
+ updatedSnapshot = values.filterKeys { it.startsWith(prefix) }
68
+ .mapKeys { it.key.removePrefix(prefix) }
69
+ listeners = emitListeners.values.toList()
70
+ }
71
+
72
+ // Emit the full updated category snapshot so UI listeners can clear the removed key.
73
+ listeners.forEach { it(normalizedCategory, updatedSnapshot) }
74
+ }
75
+
56
76
  @Synchronized
57
77
  fun get(category: String, key: String): Any? = values["${normalizeCategory(category)}.$key"]
58
78
 
@@ -0,0 +1,153 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import java.net.HttpURLConnection
4
+ import java.net.URI
5
+ import java.net.URL
6
+ import org.json.JSONArray
7
+ import org.json.JSONObject
8
+
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
+ // Keep prod as the legacy-device fallback: pre-override ASG builds ignore
13
+ // ota_start.ota_version_url and use their compiled MentraOS default.
14
+ const val PROD_OTA_VERSION_URL = "https://ota.mentraglass.com/prod_live_version.json"
15
+
16
+ fun defaultOtaVersionUrl(): String {
17
+ val sdkVersion = BuildConfig.SDK_VERSION.trim()
18
+ if (sdkVersion.isBlank() || sdkVersion == "unspecified") {
19
+ throw BluetoothException(
20
+ "missing_sdk_version",
21
+ "Cannot determine Bluetooth SDK version for the default OTA manifest URL.",
22
+ )
23
+ }
24
+ return "$SDK_OTA_RELEASE_BASE_URL/bluetooth-sdk-$sdkVersion-version.json"
25
+ }
26
+ }
27
+
28
+ internal object OtaManifestChecker {
29
+ private const val ASG_CLIENT_PACKAGE = "com.mentra.asg_client"
30
+
31
+ fun normalizeHttpUrl(value: String): String {
32
+ val trimmed = value.trim()
33
+ if (trimmed.isEmpty()) {
34
+ throw BluetoothException("invalid_ota_url", "OTA version URL must be a non-empty http(s) URL.")
35
+ }
36
+
37
+ val uri =
38
+ try {
39
+ URI(trimmed)
40
+ } catch (error: Exception) {
41
+ throw BluetoothException("invalid_ota_url", "OTA version URL must be a valid http(s) URL.", error)
42
+ }
43
+
44
+ val scheme = uri.scheme?.lowercase()
45
+ if ((scheme != "http" && scheme != "https") || uri.host.isNullOrBlank()) {
46
+ throw BluetoothException("invalid_ota_url", "OTA version URL must be an http(s) URL.")
47
+ }
48
+
49
+ return uri.toString()
50
+ }
51
+
52
+ fun fetch(otaVersionUrl: String): JSONObject {
53
+ val connection = URL(otaVersionUrl).openConnection() as HttpURLConnection
54
+ connection.connectTimeout = 15_000
55
+ connection.readTimeout = 15_000
56
+ connection.requestMethod = "GET"
57
+ return try {
58
+ val status = connection.responseCode
59
+ if (status !in 200..299) {
60
+ throw BluetoothException("ota_manifest_request_failed", "OTA manifest request failed with HTTP $status.")
61
+ }
62
+ JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
63
+ } finally {
64
+ connection.disconnect()
65
+ }
66
+ }
67
+
68
+ fun hasUpdate(
69
+ currentBuildNumber: String,
70
+ currentMtkVersion: String,
71
+ currentBesVersion: String,
72
+ manifest: JSONObject,
73
+ ): Boolean =
74
+ hasApkUpdate(currentBuildNumber, manifest) ||
75
+ hasMtkUpdate(manifest.optJSONArray("mtk_patches"), currentMtkVersion) ||
76
+ hasBesUpdate(manifest.optJSONObject("bes_firmware"), currentBesVersion)
77
+
78
+ fun hasMtkPatches(manifest: JSONObject): Boolean =
79
+ (manifest.optJSONArray("mtk_patches")?.length() ?: 0) > 0
80
+
81
+ fun hasBesFirmware(manifest: JSONObject): Boolean =
82
+ manifest.optJSONObject("bes_firmware") != null
83
+
84
+ private fun latestAppInfo(manifest: JSONObject): JSONObject {
85
+ val apps = manifest.optJSONObject("apps")
86
+ val app = apps?.optJSONObject(ASG_CLIENT_PACKAGE)
87
+ if (app != null && app.hasNumber("versionCode")) {
88
+ return app
89
+ }
90
+
91
+ if (manifest.hasNumber("versionCode")) {
92
+ return manifest
93
+ }
94
+
95
+ throw BluetoothException("invalid_ota_manifest", "OTA manifest is missing ASG app versionCode.")
96
+ }
97
+
98
+ private fun hasApkUpdate(currentBuildNumber: String, manifest: JSONObject): Boolean {
99
+ val currentVersion =
100
+ currentBuildNumber.toLongOrNull()
101
+ ?: throw BluetoothException(
102
+ "invalid_glasses_version",
103
+ "Cannot check OTA update because glasses build number is invalid.",
104
+ )
105
+ val serverVersion = latestAppInfo(manifest).requiredLong("versionCode")
106
+ return serverVersion > currentVersion
107
+ }
108
+
109
+ private fun hasMtkUpdate(patches: JSONArray?, currentVersion: String): Boolean {
110
+ if (patches == null || patches.length() == 0) return false
111
+ if (currentVersion.isBlank()) return false
112
+
113
+ for (index in 0 until patches.length()) {
114
+ val patch = patches.optJSONObject(index) ?: continue
115
+ val startFirmware = patch.optString("start_firmware", "")
116
+ if (startFirmware == currentVersion) return true
117
+ val serverDate = if (startFirmware.contains("_")) startFirmware.substringAfterLast("_") else startFirmware
118
+ if (serverDate == currentVersion) return true
119
+ }
120
+ return false
121
+ }
122
+
123
+ private fun hasBesUpdate(besFirmware: JSONObject?, currentVersion: String): Boolean {
124
+ if (besFirmware == null) return false
125
+ val serverVersion = besFirmware.optString("version", "")
126
+ if (serverVersion.isBlank()) {
127
+ throw BluetoothException("invalid_ota_manifest", "OTA manifest bes_firmware.version is missing.")
128
+ }
129
+ if (currentVersion.isBlank()) return true
130
+ return compareVersions(serverVersion, currentVersion) > 0
131
+ }
132
+
133
+ private fun compareVersions(version1: String, version2: String): Int {
134
+ if (version1.contains(".") && version2.contains(".")) {
135
+ val parts1 = version1.split(".")
136
+ val parts2 = version2.split(".")
137
+ val maxLength = maxOf(parts1.size, parts2.size)
138
+ for (index in 0 until maxLength) {
139
+ val value1 = parts1.getOrNull(index)?.toIntOrNull() ?: 0
140
+ val value2 = parts2.getOrNull(index)?.toIntOrNull() ?: 0
141
+ if (value1 != value2) return value1 - value2
142
+ }
143
+ return 0
144
+ }
145
+ return version1.compareTo(version2)
146
+ }
147
+
148
+ private fun JSONObject.hasNumber(key: String): Boolean = !isNull(key) && opt(key) is Number
149
+
150
+ private fun JSONObject.requiredLong(key: String): Long =
151
+ (opt(key) as? Number)?.toLong()
152
+ ?: throw BluetoothException("invalid_ota_manifest", "OTA manifest is missing ASG app versionCode.")
153
+ }