@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.
- package/README.md +68 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +223 -36
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
- package/build/BluetoothSdk.types.d.ts +3 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +2 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/nativeLogConsole.d.ts +19 -0
- package/build/_private/nativeLogConsole.d.ts.map +1 -1
- package/build/_private/nativeLogConsole.js +12 -0
- package/build/_private/nativeLogConsole.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +1 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +247 -48
- package/ios/Source/internal/BluetoothAvailability.swift +6 -0
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
- package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
- package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
- package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
- package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
- package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
- package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
- package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +1 -0
- package/src/_private/BluetoothSdkModule.ts +2 -1
- package/src/_private/nativeLogConsole.ts +19 -0
- 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
|
-
?:
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
951
|
-
|
|
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
|
-
|
|
1066
|
-
|
|
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
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
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.
|
|
59
|
-
|
|
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
|
+
}
|