@mentra/bluetooth-sdk 3.3.0-dev.822 → 3.3.0-dev.825

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 (42) hide show
  1. package/README.md +68 -6
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +1 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  9. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +223 -36
  10. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  11. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  12. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  13. package/build/BluetoothSdk.types.d.ts +3 -0
  14. package/build/BluetoothSdk.types.d.ts.map +1 -1
  15. package/build/BluetoothSdk.types.js.map +1 -1
  16. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  17. package/build/_private/BluetoothSdkModule.js +2 -1
  18. package/build/_private/BluetoothSdkModule.js.map +1 -1
  19. package/build/_private/nativeLogConsole.d.ts +19 -0
  20. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  21. package/build/_private/nativeLogConsole.js +12 -0
  22. package/build/_private/nativeLogConsole.js.map +1 -1
  23. package/build/generated/releaseMetadata.js +5 -5
  24. package/build/generated/releaseMetadata.js.map +1 -1
  25. package/ios/BluetoothSdkModule.swift +1 -0
  26. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  27. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  28. package/ios/Source/MentraBluetoothSDK.swift +247 -48
  29. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  30. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  31. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  32. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  33. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  34. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  35. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  36. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  37. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  38. package/package.json +2 -2
  39. package/src/BluetoothSdk.types.ts +1 -0
  40. package/src/_private/BluetoothSdkModule.ts +2 -1
  41. package/src/_private/nativeLogConsole.ts +19 -0
  42. package/src/generated/releaseMetadata.ts +5 -5
@@ -8,6 +8,7 @@ import android.content.Context
8
8
  import android.os.Bundle
9
9
  import android.os.Handler
10
10
  import android.os.Looper
11
+ import android.util.Log
11
12
  import com.mentra.bluetoothsdk.streaming.StreamSessionState
12
13
  import com.mentra.bluetoothsdk.utils.ControllerTypes
13
14
  import com.mentra.bluetoothsdk.utils.PhoneAudioMonitor
@@ -34,6 +35,21 @@ class MentraBluetoothSdk private constructor(
34
35
  private val listeners =
35
36
  Collections.synchronizedSet(mutableSetOf<MentraBluetoothSdkListener>())
36
37
  private val analytics = BluetoothSdkAnalytics(appContext, config.analytics)
38
+ private val updateCheck =
39
+ BluetoothSdkUpdateCheck(appContext, config.analytics.surface) { banner, result ->
40
+ Log.w(UPDATE_LOG_TAG, banner)
41
+ dispatchToListeners {
42
+ it.onRawEvent(
43
+ "sdk_update_notice",
44
+ buildMap {
45
+ put("message", banner)
46
+ put("status", result.status)
47
+ put("installedVersion", BuildConfig.SDK_VERSION)
48
+ result.latestVersion?.let { latest -> put("latestVersion", latest) }
49
+ },
50
+ )
51
+ }
52
+ }
37
53
  private val discoveredDeviceNames = mutableSetOf<String>()
38
54
  private val bridgeEventSinkId: String
39
55
  private val storeListenerId: String
@@ -99,6 +115,7 @@ class MentraBluetoothSdk private constructor(
99
115
  analytics.initializeGlassesStatus(getRawGlassesStatus())
100
116
  storeListenerId = DeviceStore.store.addListener { category, changes -> dispatchStoreUpdate(category, changes) }
101
117
  analytics.captureStarted()
118
+ updateCheck.start()
102
119
  }
103
120
 
104
121
  companion object {
@@ -106,6 +123,8 @@ class MentraBluetoothSdk private constructor(
106
123
  private val SCAN_STATE_KEYS = setOf("searching", "searchingController", "searchResults")
107
124
  private const val DEFAULT_SCAN_TIMEOUT_MS = 15_000L
108
125
  private const val DEFAULT_REQUEST_TIMEOUT_MS = 15_000L
126
+ private const val UPDATE_LOG_TAG = "MentraBluetoothSDK"
127
+ private val MIC_STATE_KEYS = setOf("currentMic", "should_send_transcript", "local_stt_fallback_active")
109
128
  // Mirrors AsgConstants: one active + one coalesced pending FOV update, plus BLE margin.
110
129
  // Keep in sync with ios/Source/MentraBluetoothSDK.swift and the ASG readiness budget.
111
130
  private const val CAMERA_FOV_READY_TIMEOUT_MS = 20_000L
@@ -240,8 +259,10 @@ class MentraBluetoothSdk private constructor(
240
259
  // (expo.modules.AsyncFunctionQueue), so a blocked await here froze all
241
260
  // miniapp event delivery (Crust.mentraJsDispatchToJs) until the glasses
242
261
  // responded. resolve()/reject() stay callable from any thread.
243
- private class PendingResponse<T>(
262
+ private inner class PendingResponse<T>(
244
263
  private val operation: String,
264
+ // The operation without per-request ids, so timeouts group in analytics.
265
+ private val telemetryOperation: String = operation,
245
266
  ) {
246
267
  private val deferred = CompletableDeferred<T>()
247
268
 
@@ -258,10 +279,13 @@ class MentraBluetoothSdk private constructor(
258
279
  // can only mean the deadline elapsed. A rejected deferred rethrows
259
280
  // its error from await() and propagates out unchanged.
260
281
  return withTimeoutOrNull(timeoutMs) { deferred.await() }
261
- ?: throw BluetoothSdkException(
262
- "request_timeout",
263
- "$operation timed out waiting for glasses response.",
264
- )
282
+ ?: run {
283
+ analytics.recordUsage { requestTimedOut(telemetryOperation) }
284
+ throw BluetoothSdkException(
285
+ "request_timeout",
286
+ "$operation timed out waiting for glasses response.",
287
+ )
288
+ }
265
289
  }
266
290
 
267
291
  }
@@ -503,6 +527,7 @@ class MentraBluetoothSdk private constructor(
503
527
  }
504
528
 
505
529
  fun cancelConnectionAttempt() {
530
+ analytics.noteDisconnectRequested(BluetoothSdkAnalyticsTracker.DISCONNECT_CANCEL)
506
531
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
507
532
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
508
533
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_secure_pairing_capable", "")
@@ -514,6 +539,7 @@ class MentraBluetoothSdk private constructor(
514
539
  }
515
540
 
516
541
  fun disconnect() {
542
+ analytics.noteDisconnectRequested(BluetoothSdkAnalyticsTracker.DISCONNECT_USER)
517
543
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
518
544
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
519
545
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_secure_pairing_capable", "")
@@ -521,6 +547,7 @@ class MentraBluetoothSdk private constructor(
521
547
  }
522
548
 
523
549
  fun forget() {
550
+ analytics.noteDisconnectRequested(BluetoothSdkAnalyticsTracker.DISCONNECT_FORGET)
524
551
  deviceManager.forget()
525
552
  }
526
553
 
@@ -947,8 +974,12 @@ class MentraBluetoothSdk private constructor(
947
974
  pendingWifiStatus = PendingWifiStatusRequest(ssid, pending)
948
975
  }
949
976
  try {
950
- deviceManager.sendWifiCredentials(ssid, password)
951
- return pending.await()
977
+ return withUsageOutcome(
978
+ { latencyMs, errorCode, _ -> analytics.recordUsage { wifiSetupSettled(latencyMs, errorCode) } },
979
+ ) {
980
+ deviceManager.sendWifiCredentials(ssid, password)
981
+ pending.await()
982
+ }
952
983
  } finally {
953
984
  synchronized(oneShotLock) {
954
985
  if (pendingWifiStatus?.pending === pending) {
@@ -1062,8 +1093,14 @@ class MentraBluetoothSdk private constructor(
1062
1093
  pendingHotspotStatus = PendingHotspotStatusRequest(enabled, pending)
1063
1094
  }
1064
1095
  try {
1065
- deviceManager.setHotspotState(enabled)
1066
- pending.await()
1096
+ withUsageOutcome(
1097
+ { latencyMs, errorCode, _ ->
1098
+ if (enabled) analytics.recordUsage { hotspotStartSettled(latencyMs, errorCode) }
1099
+ },
1100
+ ) {
1101
+ deviceManager.setHotspotState(enabled)
1102
+ pending.await()
1103
+ }
1067
1104
  } finally {
1068
1105
  synchronized(oneShotLock) {
1069
1106
  if (pendingHotspotStatus?.pending === pending) {
@@ -1090,13 +1127,31 @@ class MentraBluetoothSdk private constructor(
1090
1127
  Bridge.log(
1091
1128
  "NATIVE: PHOTO PIPELINE [3b/6] MentraBluetoothSdk.requestPhoto requestId=${routedRequest.requestId}"
1092
1129
  )
1093
- val pending = PendingResponse<PhotoResponseEvent>("photo request ${routedRequest.requestId}")
1130
+ val pending = PendingResponse<PhotoResponseEvent>("photo request ${routedRequest.requestId}", "photo request")
1094
1131
  pendingPhotoRequests[routedRequest.requestId] = pending
1095
1132
  try {
1096
- deviceManager.requestPhoto(routedRequest)
1097
- return pending.await(
1098
- if (routedRequest.presendThumbnail) PHOTO_THUMBNAIL_REQUEST_TIMEOUT_MS else PHOTO_REQUEST_TIMEOUT_MS,
1099
- )
1133
+ return withUsageOutcome(
1134
+ { latencyMs, errorCode, event ->
1135
+ val fileSizeBytes = (event?.response as? PhotoResponse.Success)?.fileSizeBytes
1136
+ analytics.recordUsage {
1137
+ photoSettled(
1138
+ size = routedRequest.size.value,
1139
+ mode = routedRequest.mode.value,
1140
+ compress = routedRequest.compress.value,
1141
+ save = routedRequest.save,
1142
+ transferMethod = routedRequest.transferMethod,
1143
+ latencyMs = latencyMs,
1144
+ fileSizeBytes = fileSizeBytes,
1145
+ errorCode = errorCode,
1146
+ )
1147
+ }
1148
+ },
1149
+ ) {
1150
+ deviceManager.requestPhoto(routedRequest)
1151
+ pending.await(
1152
+ if (routedRequest.presendThumbnail) PHOTO_THUMBNAIL_REQUEST_TIMEOUT_MS else PHOTO_REQUEST_TIMEOUT_MS,
1153
+ )
1154
+ }
1100
1155
  } finally {
1101
1156
  pendingPhotoRequests.remove(routedRequest.requestId, pending)
1102
1157
  }
@@ -1112,7 +1167,7 @@ class MentraBluetoothSdk private constructor(
1112
1167
  mfnr: Boolean? = null,
1113
1168
  ): CameraStatusEvent {
1114
1169
  val effectiveRequestId = nonBlankRequestId(requestId) ?: generatedCameraRequestId("warm")
1115
- val pending = PendingResponse<CameraStatusEvent>("camera warm up $effectiveRequestId")
1170
+ val pending = PendingResponse<CameraStatusEvent>("camera warm up $effectiveRequestId", "camera warm up")
1116
1171
  // Guard against a concurrent warm-up reusing the same requestId: a blind put would overwrite
1117
1172
  // (and strand) the first caller's continuation until it times out. Reject the duplicate.
1118
1173
  if (pendingCameraStatusRequests.putIfAbsent(effectiveRequestId, pending) != null) {
@@ -1159,35 +1214,55 @@ class MentraBluetoothSdk private constructor(
1159
1214
  }
1160
1215
 
1161
1216
  suspend fun startStream(request: StreamRequest): StreamStatusEvent {
1162
- requireGlassesConnected("start stream")
1163
- if (!streamSession.supported) {
1164
- throw BluetoothSdkException("stream_control_unsupported", "Update the glasses software before starting a stream.")
1165
- }
1166
1217
  val message = request.toMap().toMutableMap()
1167
1218
  val streamId = (message["streamId"] as? String)?.takeIf { it.isNotBlank() }
1168
1219
  ?: "sdk-${UUID.randomUUID()}"
1169
1220
  message["streamId"] = streamId
1170
- val pending = PendingResponse<StreamStatusEvent>("start stream $streamId")
1171
- var start: PendingStreamStart? = null
1172
- try {
1173
- // seq assignment and the BLE hand-off must be one critical section:
1174
- // rejectPreemptedStreamStarts only works if seq order equals the
1175
- // order the commands reach the glasses' FIFO.
1176
- synchronized(streamStartOrderLock) {
1177
- val registered = PendingStreamStart(streamStartSeq.incrementAndGet(), pending)
1178
- pendingStreamStarts[streamId] = registered
1179
- start = registered
1180
- deviceManager.startStream(message)
1221
+ return withUsageOutcome(
1222
+ { latencyMs, errorCode, _ ->
1223
+ val glassesNetwork = glassesNetwork()
1224
+ analytics.recordUsage {
1225
+ streamStartSettled(
1226
+ streamUrl = request.streamUrl,
1227
+ width = request.video?.width,
1228
+ height = request.video?.height,
1229
+ fps = request.video?.fps,
1230
+ bitrate = request.video?.bitrate,
1231
+ captureAudio = request.captureAudio,
1232
+ glassesNetwork = glassesNetwork,
1233
+ latencyMs = latencyMs,
1234
+ errorCode = errorCode,
1235
+ )
1236
+ }
1237
+ },
1238
+ ) {
1239
+ requireGlassesConnected("start stream")
1240
+ if (!streamSession.supported) {
1241
+ throw BluetoothSdkException("stream_control_unsupported", "Update the glasses software before starting a stream.")
1242
+ }
1243
+ val pending = PendingResponse<StreamStatusEvent>("start stream $streamId", "start stream")
1244
+ val requestedAt = System.currentTimeMillis()
1245
+ analytics.recordUsage { streamRequested(streamId, request.streamUrl, requestedAt) }
1246
+ var start: PendingStreamStart? = null
1247
+ try {
1248
+ // seq assignment and the BLE hand-off must be one critical section:
1249
+ // rejectPreemptedStreamStarts only works if seq order equals the
1250
+ // order the commands reach the glasses' FIFO.
1251
+ synchronized(streamStartOrderLock) {
1252
+ val registered = PendingStreamStart(streamStartSeq.incrementAndGet(), pending)
1253
+ pendingStreamStarts[streamId] = registered
1254
+ start = registered
1255
+ deviceManager.startStream(message)
1256
+ }
1257
+ pending.await(STREAM_START_TIMEOUT_MS)
1258
+ } finally {
1259
+ start?.let { pendingStreamStarts.remove(streamId, it) }
1181
1260
  }
1182
- val event = pending.await(STREAM_START_TIMEOUT_MS)
1183
- return event
1184
- } finally {
1185
- start?.let { pendingStreamStarts.remove(streamId, it) }
1186
1261
  }
1187
1262
  }
1188
1263
 
1189
1264
  suspend fun rgbLedControl(request: RgbLedRequest): RgbLedControlResponseEvent {
1190
- val pending = PendingResponse<RgbLedControlResponseEvent>("RGB LED command ${request.requestId}")
1265
+ val pending = PendingResponse<RgbLedControlResponseEvent>("RGB LED command ${request.requestId}", "RGB LED command")
1191
1266
  pendingRgbLedRequests[request.requestId] = pending
1192
1267
  try {
1193
1268
  deviceManager.rgbLedControl(
@@ -1206,6 +1281,7 @@ class MentraBluetoothSdk private constructor(
1206
1281
  }
1207
1282
 
1208
1283
  suspend fun stopStream(): StreamStatusEvent {
1284
+ analytics.updateUsage { streamStopRequested() }
1209
1285
  val pending = PendingResponse<StreamStatusEvent>("stop stream")
1210
1286
  val targetStreamId = streamSession.currentStreamId
1211
1287
  try {
@@ -1247,6 +1323,16 @@ class MentraBluetoothSdk private constructor(
1247
1323
  "A video recording command is already waiting for requestId ${request.requestId}.",
1248
1324
  )
1249
1325
  }
1326
+ analytics.updateUsage {
1327
+ videoRequested(
1328
+ request.requestId,
1329
+ request.width,
1330
+ request.height,
1331
+ request.fps,
1332
+ request.save,
1333
+ request.maxRecordingTimeMinutes,
1334
+ )
1335
+ }
1250
1336
  try {
1251
1337
  deviceManager.startVideoRecording(
1252
1338
  request.requestId,
@@ -1285,6 +1371,7 @@ class MentraBluetoothSdk private constructor(
1285
1371
  "A video recording command is already waiting for requestId $requestId.",
1286
1372
  )
1287
1373
  }
1374
+ analytics.updateUsage { videoStopRequested(requestId) }
1288
1375
  try {
1289
1376
  deviceManager.stopVideoRecording(requestId, webhookUrl, authToken)
1290
1377
  val timeoutMs =
@@ -1591,6 +1678,7 @@ class MentraBluetoothSdk private constructor(
1591
1678
  Bridge.removeEventSink(bridgeEventSinkId)
1592
1679
  DeviceStore.store.removeListener(storeListenerId)
1593
1680
  analytics.shutdown()
1681
+ updateCheck.stop()
1594
1682
  listeners.clear()
1595
1683
  }
1596
1684
 
@@ -1600,7 +1688,7 @@ class MentraBluetoothSdk private constructor(
1600
1688
  if (changes["connected"] == false) {
1601
1689
  resetWifiProtocolSession("", "wifi_session_disconnected")
1602
1690
  }
1603
- analytics.observeGlassesStatus(getRawGlassesStatus())
1691
+ analytics.observeGlassesStatus(getRawGlassesStatus(), ::isPhoneBluetoothOff)
1604
1692
  val state = getState()
1605
1693
  dispatchToListeners {
1606
1694
  it.onStateChanged(state)
@@ -1608,6 +1696,12 @@ class MentraBluetoothSdk private constructor(
1608
1696
  }
1609
1697
  }
1610
1698
  ObservableStore.BLUETOOTH_CATEGORY -> {
1699
+ if (analytics.isEnabled && changes.keys.any { it in MIC_STATE_KEYS }) {
1700
+ val bluetooth = getRawBluetoothStatus()
1701
+ val transcribing = bluetooth.shouldSendTranscript || bluetooth.localSttFallbackActive
1702
+ val now = System.currentTimeMillis()
1703
+ analytics.recordUsage { observeMic(bluetooth.currentMic, transcribing, now) }
1704
+ }
1611
1705
  val state = getState()
1612
1706
  val scanChanged = changes.keys.any { it in SCAN_STATE_KEYS }
1613
1707
  dispatchToListeners {
@@ -1874,6 +1968,10 @@ class MentraBluetoothSdk private constructor(
1874
1968
  "photo_response" -> {
1875
1969
  val event = PhotoResponseEvent(data)
1876
1970
  handlePhotoResponseForRequests(event)
1971
+ val response = event.response
1972
+ if (response is PhotoResponse.Error && event.requestId == BluetoothSdkUsageTracker.LOCAL_PHOTO_REQUEST_ID) {
1973
+ analytics.recordUsage { buttonPhotoFailed(response.errorCode) }
1974
+ }
1877
1975
  dispatchToListeners { it.onPhotoResponse(event) }
1878
1976
  }
1879
1977
  "photo_status" -> dispatchToListeners { it.onPhotoStatus(PhotoStatusEvent(data)) }
@@ -1885,6 +1983,8 @@ class MentraBluetoothSdk private constructor(
1885
1983
  "video_recording_status" -> {
1886
1984
  val event = VideoRecordingStatusEvent(data)
1887
1985
  handleVideoRecordingStatusForRequests(event)
1986
+ val now = System.currentTimeMillis()
1987
+ analytics.recordUsage { videoStatus(event.requestId, event.status, event.success, now) }
1888
1988
  dispatchToListeners { it.onVideoRecordingStatus(event) }
1889
1989
  }
1890
1990
  "media_success", "media_error" -> {
@@ -1904,6 +2004,7 @@ class MentraBluetoothSdk private constructor(
1904
2004
  if (data.containsKey("revision") && !streamSession.accept(data)) return
1905
2005
  val event = StreamStatusEvent(data)
1906
2006
  handleStreamStatusForRequests(event)
2007
+ recordStreamStatus(event)
1907
2008
  dispatchToListeners { it.onStreamStatus(event) }
1908
2009
  }
1909
2010
  "keep_alive_ack" -> {
@@ -1928,6 +2029,23 @@ class MentraBluetoothSdk private constructor(
1928
2029
  )
1929
2030
  }
1930
2031
  }
2032
+ if (analytics.isEnabled) {
2033
+ val glasses = getRawGlassesStatus()
2034
+ val now = System.currentTimeMillis()
2035
+ analytics.recordUsage {
2036
+ otaStatus(
2037
+ sessionId = event.sessionId,
2038
+ status = event.status,
2039
+ stepType = event.stepType,
2040
+ totalSteps = event.totalSteps,
2041
+ errorMessage = event.errorMessage,
2042
+ fromBuildNumber = glasses.buildNumber,
2043
+ fromBesFirmwareVersion = glasses.besFirmwareVersion,
2044
+ fromMtkFirmwareVersion = glasses.mtkFirmwareVersion,
2045
+ nowMs = now,
2046
+ )
2047
+ }
2048
+ }
1931
2049
  dispatchToListeners { it.onOtaStatus(event) }
1932
2050
  }
1933
2051
  "mic_health" -> {
@@ -1983,6 +2101,75 @@ class MentraBluetoothSdk private constructor(
1983
2101
  }
1984
2102
  }
1985
2103
 
2104
+ private fun recordStreamStatus(event: StreamStatusEvent) {
2105
+ if (!analytics.isEnabled) return
2106
+ val status = event.status
2107
+ val attempt =
2108
+ when (status) {
2109
+ is StreamStatus.Reconnecting -> status.attempt
2110
+ is StreamStatus.Reconnected -> status.attempt
2111
+ else -> null
2112
+ }
2113
+ val errorDetails = event.errorDetails ?: (status as? StreamStatus.Error)?.errorDetails
2114
+ val now = System.currentTimeMillis()
2115
+ analytics.recordUsage {
2116
+ streamStatus(
2117
+ streamId = event.streamId,
2118
+ state = event.state.value,
2119
+ terminal = event.terminal,
2120
+ reconnectAttempt = attempt,
2121
+ bitrate = event.stats?.bitrate,
2122
+ fps = event.stats?.fps,
2123
+ droppedFrames = event.stats?.droppedFrames,
2124
+ temperatureC = event.stats?.temperatureC,
2125
+ errorDetails = errorDetails,
2126
+ nowMs = now,
2127
+ )
2128
+ }
2129
+ }
2130
+
2131
+ private fun glassesNetwork(): String {
2132
+ val glasses = getRawGlassesStatus()
2133
+ return when {
2134
+ glasses.hotspot is HotspotStatus.Enabled -> "hotspot"
2135
+ glasses.wifi is WifiStatus.Connected -> "wifi"
2136
+ else -> "none"
2137
+ }
2138
+ }
2139
+
2140
+ private fun isPhoneBluetoothOff(): Boolean =
2141
+ try {
2142
+ (appContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter?.isEnabled == false
2143
+ } catch (_: Exception) {
2144
+ false
2145
+ }
2146
+
2147
+ /**
2148
+ * Runs a glasses command and reports its latency and outcome to usage
2149
+ * analytics. Cancellation is the caller giving up, not a command outcome, so
2150
+ * it is not reported.
2151
+ */
2152
+ private inline fun <T> withUsageOutcome(
2153
+ record: (latencyMs: Long, errorCode: String?, result: T?) -> Unit,
2154
+ block: () -> T,
2155
+ ): T {
2156
+ val startedAt = System.currentTimeMillis()
2157
+ val result =
2158
+ try {
2159
+ block()
2160
+ } catch (error: CancellationException) {
2161
+ throw error
2162
+ } catch (error: Exception) {
2163
+ if (analytics.isEnabled) {
2164
+ val errorCode = (error as? BluetoothSdkException)?.code ?: error.javaClass.simpleName
2165
+ record(System.currentTimeMillis() - startedAt, errorCode, null)
2166
+ }
2167
+ throw error
2168
+ }
2169
+ if (analytics.isEnabled) record(System.currentTimeMillis() - startedAt, null, result)
2170
+ return result
2171
+ }
2172
+
1986
2173
  private fun dispatchToListeners(callback: (MentraBluetoothSdkListener) -> Unit) {
1987
2174
  val snapshot = synchronized(listeners) { listeners.toList() }
1988
2175
  val deliver = {
@@ -12,7 +12,61 @@ class BluetoothSdkAnalyticsTrackerTest {
12
12
  model: String = "Mentra Live",
13
13
  serial: String = "",
14
14
  fullyBooted: Boolean = connected,
15
- ) = AnalyticsGlassesSnapshot(connected = connected, fullyBooted = fullyBooted, model = model, serialNumber = serial)
15
+ battery: Int = -1,
16
+ charging: Boolean = false,
17
+ ) = AnalyticsGlassesSnapshot(
18
+ connected = connected,
19
+ fullyBooted = fullyBooted,
20
+ model = model,
21
+ serialNumber = serial,
22
+ batteryLevel = battery,
23
+ charging = charging,
24
+ )
25
+
26
+ @Test
27
+ fun `a counted connection reports its length, reason and battery when it ends`() {
28
+ tracker.initialize(snapshot(connected = false), reportingDay = 100)
29
+ tracker.observe(snapshot(connected = true), reportingDay = 100, nowMs = 1_000)
30
+ tracker.observe(snapshot(connected = true, battery = 80), reportingDay = 100, nowMs = 2_000)
31
+ tracker.observe(snapshot(connected = true, battery = 71, charging = true), reportingDay = 100, nowMs = 3_000)
32
+
33
+ val ended =
34
+ tracker.observe(snapshot(connected = false), reportingDay = 100, nowMs = 61_000) {
35
+ BluetoothSdkAnalyticsTracker.DISCONNECT_USER
36
+ }
37
+ val disconnected = ended.single()
38
+ assertThat(disconnected.name).isEqualTo("bluetooth_sdk_glasses_disconnected")
39
+ assertThat(disconnected.properties)
40
+ .containsEntry("session_duration_ms", 60_000L)
41
+ .containsEntry("disconnect_reason", "user_disconnect")
42
+ .containsEntry("battery_level_at_connect", 80)
43
+ .containsEntry("battery_level_at_disconnect", 71)
44
+ .containsEntry("charging", true)
45
+ .containsEntry("glasses_model", "Mentra Live")
46
+
47
+ // Further disconnected updates do not report the connection again.
48
+ assertThat(tracker.observe(snapshot(connected = false), reportingDay = 100, nowMs = 62_000)).isEmpty()
49
+ }
50
+
51
+ @Test
52
+ fun `a connection already up when the SDK started is not reported as ending`() {
53
+ tracker.initialize(snapshot(connected = true, serial = "MLAB0001"), reportingDay = 100)
54
+ assertThat(tracker.observe(snapshot(connected = false), reportingDay = 100, nowMs = 5_000)).isEmpty()
55
+ }
56
+
57
+ @Test
58
+ fun `battery from a previous connection does not leak into the next one`() {
59
+ tracker.initialize(snapshot(connected = false), reportingDay = 100)
60
+ tracker.observe(snapshot(connected = true, battery = 90), reportingDay = 100, nowMs = 0)
61
+ tracker.observe(snapshot(connected = false), reportingDay = 100, nowMs = 1_000)
62
+ tracker.observe(snapshot(connected = true), reportingDay = 100, nowMs = 2_000)
63
+ val second = tracker.observe(snapshot(connected = false), reportingDay = 100, nowMs = 3_000).single()
64
+ assertThat(second.properties)
65
+ .doesNotContainKey("battery_level_at_connect")
66
+ .doesNotContainKey("battery_level_at_disconnect")
67
+ .doesNotContainKey("charging")
68
+ .containsEntry("disconnect_reason", "unknown")
69
+ }
16
70
 
17
71
  @Test
18
72
  fun `connect then serial emits connected and identified once, reconnects do not repeat identification within a connection`() {
@@ -55,8 +109,9 @@ class BluetoothSdkAnalyticsTrackerTest {
55
109
  tracker.initialize(snapshot(connected = false), reportingDay = 100)
56
110
  tracker.observe(snapshot(connected = true, model = ""), reportingDay = 100)
57
111
  val ended = tracker.observe(snapshot(connected = false, model = ""), reportingDay = 100)
58
- assertThat(ended.single().name).isEqualTo("bluetooth_sdk_glasses_connected")
59
- assertThat(ended.single().properties).containsEntry("glasses_model_unresolved", true).doesNotContainKey("glasses_model")
112
+ assertThat(ended.map { it.name })
113
+ .containsExactly("bluetooth_sdk_glasses_connected", "bluetooth_sdk_glasses_disconnected")
114
+ assertThat(ended.first().properties).containsEntry("glasses_model_unresolved", true).doesNotContainKey("glasses_model")
60
115
  }
61
116
 
62
117
  @Test
@@ -0,0 +1,124 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import androidx.test.core.app.ApplicationProvider
4
+ import android.content.Context
5
+ import org.assertj.core.api.Assertions.assertThat
6
+ import org.json.JSONObject
7
+ import org.junit.Test
8
+ import org.junit.runner.RunWith
9
+ import org.robolectric.RobolectricTestRunner
10
+ import org.robolectric.annotation.Config
11
+
12
+ @RunWith(RobolectricTestRunner::class)
13
+ @Config(sdk = [33])
14
+ class BluetoothSdkUpdateCheckTest {
15
+ private val context: Context = ApplicationProvider.getApplicationContext()
16
+
17
+ private fun result(status: String, latest: String? = "3.2.1", minimum: String? = null) =
18
+ BluetoothSdkUpdateCheckResult(status, "latest", latest, minimum)
19
+
20
+ @Test
21
+ fun `parses the Core response, treating null and blank versions as unknown`() {
22
+ val parsed =
23
+ BluetoothSdkUpdateCheckResult.parse(
24
+ """{"status":"update_available","channel":"dev","latestVersion":"3.3.0-dev.821","minimumSupportedVersion":null}""",
25
+ )!!
26
+ assertThat(parsed).isEqualTo(BluetoothSdkUpdateCheckResult("update_available", "dev", "3.3.0-dev.821", null))
27
+ assertThat(BluetoothSdkUpdateCheckResult.parse("""{"status":"current","latestVersion":""}""")!!.latestVersion).isNull()
28
+ assertThat(BluetoothSdkUpdateCheckResult.parse("<html>502</html>")).isNull()
29
+ }
30
+
31
+ @Test
32
+ fun `the banner names the installed and latest versions and the surface's update command`() {
33
+ val reactNative = BluetoothSdkUpdateNotice.banner("3.1.0", result("update_available"), "react_native")!!
34
+ assertThat(reactNative)
35
+ .contains("MENTRA BLUETOOTH SDK UPDATE AVAILABLE")
36
+ .contains("Installed: 3.1.0")
37
+ .contains("Latest: 3.2.1")
38
+ .contains("bun add @mentra/bluetooth-sdk@3.2.1")
39
+ .doesNotContain("NO LONGER SUPPORTED")
40
+ assertThat(BluetoothSdkUpdateNotice.banner("3.1.0", result("update_available"), "android")!!)
41
+ .contains("com.mentraglass:bluetooth-sdk:3.2.1")
42
+ assertThat(BluetoothSdkUpdateNotice.banner("3.1.0", result("update_available"), "ios")!!)
43
+ .contains("MentraBluetoothSDK Swift package at 3.2.1")
44
+ }
45
+
46
+ @Test
47
+ fun `an unsupported version gets the stronger banner`() {
48
+ val banner = BluetoothSdkUpdateNotice.banner("3.0.5", result("unsupported", minimum = "3.1.0"), "android")!!
49
+ assertThat(banner)
50
+ .contains("MENTRA BLUETOOTH SDK 3.0.5 IS NO LONGER SUPPORTED")
51
+ .contains("known issues")
52
+ }
53
+
54
+ @Test
55
+ fun `no banner when current, unknown, or no latest version to point at`() {
56
+ assertThat(BluetoothSdkUpdateNotice.banner("3.2.1", result("current"), "android")).isNull()
57
+ assertThat(BluetoothSdkUpdateNotice.banner("3.2.1", result("unknown", latest = null), "android")).isNull()
58
+ assertThat(BluetoothSdkUpdateNotice.banner("3.2.1", result("update_available", latest = null), "android")).isNull()
59
+ }
60
+
61
+ @Test
62
+ fun `the request identifies the install, app and SDK`() {
63
+ val body = BluetoothSdkUpdateCheck(context, "react_native", sdkVersion = "3.1.0", transport = { null }) { _, _ -> }.requestBody()
64
+ assertThat(body.getString("install_id")).isEqualTo(BluetoothSdkInstallId.get(context)).startsWith("mentra-bt-sdk-")
65
+ assertThat(body.getString("app_identifier")).isEqualTo(context.packageName)
66
+ assertThat(body.getString("sdk_version")).isEqualTo("3.1.0")
67
+ assertThat(body.getString("sdk_platform")).isEqualTo("android")
68
+ assertThat(body.getString("sdk_surface")).isEqualTo("react_native")
69
+ assertThat(body.has("app_build_type")).isTrue()
70
+ }
71
+
72
+ @Test
73
+ fun `an out-of-date SDK prints the banner once at start and stops on close`() {
74
+ val notices = mutableListOf<String>()
75
+ var sentBody: String? = null
76
+ val check =
77
+ BluetoothSdkUpdateCheck(
78
+ context,
79
+ "android",
80
+ sdkVersion = "3.1.0",
81
+ transport = { body ->
82
+ sentBody = body
83
+ """{"status":"update_available","channel":"latest","latestVersion":"3.2.1","minimumSupportedVersion":null}"""
84
+ },
85
+ ) { banner, _ -> notices += banner }
86
+ check.start()
87
+ BluetoothSdkUpdateCheck.awaitIdle()
88
+ assertThat(notices).hasSize(1)
89
+ assertThat(notices.single()).contains("Latest: 3.2.1")
90
+ assertThat(JSONObject(sentBody!!).getString("sdk_version")).isEqualTo("3.1.0")
91
+ check.stop()
92
+ }
93
+
94
+ @Test
95
+ fun `failures and unknown versions are silent`() {
96
+ val notices = mutableListOf<String>()
97
+ BluetoothSdkUpdateCheck(context, "android", sdkVersion = "3.1.0", transport = { null }) { banner, _ -> notices += banner }.start()
98
+ BluetoothSdkUpdateCheck(context, "android", sdkVersion = "3.1.0", transport = { throw IllegalStateException("offline") }) { banner, _ -> notices += banner }.start()
99
+ var called = false
100
+ BluetoothSdkUpdateCheck(context, "android", sdkVersion = "unknown", transport = { called = true; null }) { banner, _ -> notices += banner }.start()
101
+ BluetoothSdkUpdateCheck.awaitIdle()
102
+ assertThat(notices).isEmpty()
103
+ assertThat(called).isFalse()
104
+ }
105
+
106
+ @Test
107
+ fun `a check stopped before the response arrives prints nothing`() {
108
+ val notices = mutableListOf<String>()
109
+ lateinit var check: BluetoothSdkUpdateCheck
110
+ check =
111
+ BluetoothSdkUpdateCheck(
112
+ context,
113
+ "android",
114
+ sdkVersion = "3.1.0",
115
+ transport = {
116
+ check.stop()
117
+ """{"status":"update_available","latestVersion":"3.2.1"}"""
118
+ },
119
+ ) { banner, _ -> notices += banner }
120
+ check.start()
121
+ BluetoothSdkUpdateCheck.awaitIdle()
122
+ assertThat(notices).isEmpty()
123
+ }
124
+ }