@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
@@ -0,0 +1,428 @@
1
+ import Foundation
2
+
3
+ /// Pure decision logic for the Mentra Live usage events: one event per photo,
4
+ /// recording, stream, mic session, setup attempt, OTA attempt and timed-out
5
+ /// glasses command. It owns no threads and no I/O; `BluetoothSdkAnalytics` feeds
6
+ /// it facts and captures what it returns, and only when analytics is enabled.
7
+ ///
8
+ /// Durations are measured on the phone's clock between the start and end
9
+ /// statuses it observes. A recording or stream whose end never reaches this
10
+ /// process (the app was killed mid-stream) produces only its start.
11
+ struct BluetoothSdkUsageTracker {
12
+ static let eventPhotoTaken = "bluetooth_sdk_photo_taken"
13
+ static let eventVideoRecorded = "bluetooth_sdk_video_recorded"
14
+ static let eventStreamStarted = "bluetooth_sdk_stream_started"
15
+ static let eventStreamEnded = "bluetooth_sdk_stream_ended"
16
+ static let eventMicSession = "bluetooth_sdk_mic_session"
17
+ static let eventWifiSetup = "bluetooth_sdk_wifi_setup"
18
+ static let eventHotspotStarted = "bluetooth_sdk_hotspot_started"
19
+ static let eventOtaFinished = "bluetooth_sdk_ota_finished"
20
+ static let eventRequestFailed = "bluetooth_sdk_request_failed"
21
+
22
+ static let maxRequestTimeoutsDefault = 100
23
+ /// Glasses report button-capture photo failures under this request id (ASG MediaCaptureService).
24
+ static let localPhotoRequestId = "local"
25
+ /// Button-started recordings use `local_video_<ts>_<rand>` request ids on the glasses.
26
+ static let localVideoRequestPrefix = "local_video_"
27
+
28
+ private static let maxOpenRecordings = 16
29
+ private static let maxOtaSessions = 16
30
+ private static let maxDetailLength = 200
31
+ private static let triggerApi = "api"
32
+ private static let triggerButton = "button"
33
+ private static let otaComplete = "complete"
34
+ private static let otaFailed = "failed"
35
+ private static let otaIdle = "idle"
36
+ private static let legacyTerminalStreamStates: Set<String> = ["stopped", "reconnect_failed"]
37
+ private static let liveStreamStates: Set<String> = ["streaming", "reconnected"]
38
+ private static let urlPattern = try! NSRegularExpression(pattern: #"[A-Za-z][A-Za-z0-9+.\-]*://\S+"#)
39
+
40
+ private struct OpenRecording {
41
+ var startedAtMs: Int64?
42
+ var width: Int?
43
+ var height: Int?
44
+ var fps: Int?
45
+ var save: Bool?
46
+ var maxDurationMinutes: Int?
47
+ var stoppedByApp = false
48
+ }
49
+
50
+ private struct OpenStream {
51
+ let streamId: String
52
+ let streamProtocol: String
53
+ /// Set by the first live status; nil while the start is still pending.
54
+ var startedAtMs: Int64?
55
+ var reconnectAttempts = 0
56
+ var stoppedByApp = false
57
+ var bitrate: Int64?
58
+ var fps: Double?
59
+ var droppedFrames: Int64?
60
+ var temperatureC: Double?
61
+ }
62
+
63
+ private struct OpenOta {
64
+ let startedAtMs: Int64
65
+ let fromBuildNumber: String
66
+ let fromBesFirmwareVersion: String
67
+ let fromMtkFirmwareVersion: String
68
+ }
69
+
70
+ private let maxRequestTimeouts: Int
71
+ private var recordings = BoundedMap<String, OpenRecording>(capacity: BluetoothSdkUsageTracker.maxOpenRecordings)
72
+ private var stream: OpenStream?
73
+ private var micSource: String?
74
+ private var micStartedAtMs: Int64 = 0
75
+ private var micTranscribed = false
76
+ private var otaSessions = BoundedMap<String, OpenOta>(capacity: BluetoothSdkUsageTracker.maxOtaSessions)
77
+ private var finishedOtaSessions = BoundedMap<String, Bool>(capacity: BluetoothSdkUsageTracker.maxOtaSessions)
78
+ private var requestTimeouts = 0
79
+
80
+ init(maxRequestTimeouts: Int = BluetoothSdkUsageTracker.maxRequestTimeoutsDefault) {
81
+ self.maxRequestTimeouts = maxRequestTimeouts
82
+ }
83
+
84
+ func photoSettled(
85
+ size: String,
86
+ mode: String,
87
+ compress: String,
88
+ save: Bool,
89
+ transferMethod: String,
90
+ latencyMs: Int64,
91
+ fileSizeBytes: Int64?,
92
+ errorCode: String?
93
+ ) -> AnalyticsEvent {
94
+ var properties: [String: Any] = ["trigger": Self.triggerApi]
95
+ Self.putOutcome(&properties, errorCode)
96
+ properties["latency_ms"] = latencyMs
97
+ properties["photo_size"] = size
98
+ properties["photo_mode"] = mode
99
+ properties["compress"] = compress
100
+ properties["save_to_gallery"] = save
101
+ properties["transfer_method"] = transferMethod
102
+ if let fileSizeBytes, fileSizeBytes > 0 { properties["file_size_bytes"] = fileSizeBytes }
103
+ return AnalyticsEvent(name: Self.eventPhotoTaken, properties: properties)
104
+ }
105
+
106
+ /// A button photo the glasses could not take; successful button photos never reach the phone.
107
+ func buttonPhotoFailed(errorCode: String?) -> AnalyticsEvent {
108
+ var properties: [String: Any] = ["trigger": Self.triggerButton]
109
+ Self.putOutcome(&properties, errorCode ?? "photo_request_failed")
110
+ return AnalyticsEvent(name: Self.eventPhotoTaken, properties: properties)
111
+ }
112
+
113
+ /// Records the API parameters of a recording so its end event can report them.
114
+ mutating func videoRequested(requestId: String, width: Int, height: Int, fps: Int, save: Bool, maxDurationMinutes: Int) {
115
+ recordings[requestId] = OpenRecording(
116
+ width: width > 0 ? width : nil,
117
+ height: height > 0 ? height : nil,
118
+ fps: fps > 0 ? fps : nil,
119
+ save: save,
120
+ maxDurationMinutes: maxDurationMinutes > 0 ? maxDurationMinutes : nil
121
+ )
122
+ }
123
+
124
+ mutating func videoStopRequested(requestId: String) {
125
+ guard var recording = recordings[requestId] else { return }
126
+ recording.stoppedByApp = true
127
+ recordings[requestId] = recording
128
+ }
129
+
130
+ /// Glasses report every recording, button-started ones included, through
131
+ /// `video_recording_status`. A recording is reported once: when it stops, or
132
+ /// when a command for it fails. `not_recording` answers a stop for a recording
133
+ /// that already ended and is not a recording of its own.
134
+ mutating func videoStatus(requestId: String, status: String, success: Bool, nowMs: Int64) -> AnalyticsEvent? {
135
+ guard !requestId.isBlank else { return nil }
136
+ if success {
137
+ switch status {
138
+ case "recording_started":
139
+ var recording = recordings[requestId] ?? OpenRecording()
140
+ if recording.startedAtMs == nil { recording.startedAtMs = nowMs }
141
+ recordings[requestId] = recording
142
+ return nil
143
+ case "recording_stopped":
144
+ guard let recording = recordings.removeValue(forKey: requestId) else { return nil }
145
+ return videoEvent(requestId: requestId, recording: recording, nowMs: nowMs, errorCode: nil)
146
+ default:
147
+ return nil
148
+ }
149
+ }
150
+ if status == "not_recording" {
151
+ recordings.removeValue(forKey: requestId)
152
+ return nil
153
+ }
154
+ let recording = recordings.removeValue(forKey: requestId) ?? OpenRecording()
155
+ return videoEvent(
156
+ requestId: requestId,
157
+ recording: recording,
158
+ nowMs: nowMs,
159
+ errorCode: status.isBlank ? "video_recording_failed" : status
160
+ )
161
+ }
162
+
163
+ private func videoEvent(requestId: String, recording: OpenRecording, nowMs: Int64, errorCode: String?) -> AnalyticsEvent {
164
+ var properties: [String: Any] = [
165
+ "trigger": requestId.hasPrefix(Self.localVideoRequestPrefix) ? Self.triggerButton : Self.triggerApi,
166
+ ]
167
+ Self.putOutcome(&properties, errorCode)
168
+ if let startedAtMs = recording.startedAtMs { properties["duration_ms"] = max(nowMs - startedAtMs, 0) }
169
+ if let width = recording.width { properties["width"] = width }
170
+ if let height = recording.height { properties["height"] = height }
171
+ if let fps = recording.fps { properties["fps"] = fps }
172
+ if let save = recording.save { properties["save_to_gallery"] = save }
173
+ if let maxDurationMinutes = recording.maxDurationMinutes { properties["max_duration_minutes"] = maxDurationMinutes }
174
+ properties["stopped_by_app"] = recording.stoppedByApp
175
+ return AnalyticsEvent(name: Self.eventVideoRecorded, properties: properties)
176
+ }
177
+
178
+ /// Opens the stream as its start command is sent, so a status that arrives
179
+ /// before `startStream` returns is still attributed to it. A new start
180
+ /// supersedes a stream that was live (the glasses stop it), so that stream is
181
+ /// reported as `replaced`.
182
+ mutating func streamRequested(streamId: String, streamUrl: String, nowMs: Int64) -> AnalyticsEvent? {
183
+ var replaced: AnalyticsEvent?
184
+ if let open = stream, open.startedAtMs != nil, open.streamId != streamId {
185
+ replaced = Self.streamEndedEvent(open, endState: "replaced", errorDetails: nil, nowMs: nowMs)
186
+ }
187
+ stream = OpenStream(streamId: streamId, streamProtocol: Self.streamProtocol(streamUrl))
188
+ return replaced
189
+ }
190
+
191
+ func streamStartSettled(
192
+ streamUrl: String,
193
+ width: Int?,
194
+ height: Int?,
195
+ fps: Int?,
196
+ bitrate: Int?,
197
+ captureAudio: Bool,
198
+ glassesNetwork: String,
199
+ latencyMs: Int64,
200
+ errorCode: String?
201
+ ) -> AnalyticsEvent {
202
+ var properties: [String: Any] = [:]
203
+ Self.putOutcome(&properties, errorCode)
204
+ properties["protocol"] = Self.streamProtocol(streamUrl)
205
+ if let width { properties["width"] = width }
206
+ if let height { properties["height"] = height }
207
+ if let fps { properties["fps"] = fps }
208
+ if let bitrate { properties["bitrate"] = bitrate }
209
+ properties["capture_audio"] = captureAudio
210
+ properties["glasses_network"] = glassesNetwork
211
+ properties["latency_ms"] = latencyMs
212
+ return AnalyticsEvent(name: Self.eventStreamStarted, properties: properties)
213
+ }
214
+
215
+ mutating func streamStopRequested() {
216
+ stream?.stoppedByApp = true
217
+ }
218
+
219
+ /// Folds one accepted `stream_status` into the open stream and reports the
220
+ /// stream once, on its terminal status. Glasses with stream revisions flag the
221
+ /// terminal status; older glasses only send a final `stopped` or
222
+ /// `reconnect_failed`. A stream that never went live ends silently: its
223
+ /// `bluetooth_sdk_stream_started` already reported the failure.
224
+ mutating func streamStatus(
225
+ streamId: String?,
226
+ state: String,
227
+ terminal: Bool?,
228
+ reconnectAttempt: Int?,
229
+ bitrate: Int64?,
230
+ fps: Double?,
231
+ droppedFrames: Int64?,
232
+ temperatureC: Double?,
233
+ errorDetails: String?,
234
+ nowMs: Int64
235
+ ) -> AnalyticsEvent? {
236
+ guard var open = stream else { return nil }
237
+ if let streamId, !streamId.isBlank, streamId != open.streamId { return nil }
238
+ if Self.liveStreamStates.contains(state), open.startedAtMs == nil { open.startedAtMs = nowMs }
239
+ if let reconnectAttempt { open.reconnectAttempts = max(open.reconnectAttempts, reconnectAttempt) }
240
+ if let bitrate { open.bitrate = bitrate }
241
+ if let fps { open.fps = fps }
242
+ if let droppedFrames { open.droppedFrames = droppedFrames }
243
+ if let temperatureC { open.temperatureC = temperatureC }
244
+ let ended = terminal == true || (terminal == nil && Self.legacyTerminalStreamStates.contains(state))
245
+ guard ended else {
246
+ stream = open
247
+ return nil
248
+ }
249
+ stream = nil
250
+ guard open.startedAtMs != nil else { return nil }
251
+ return Self.streamEndedEvent(open, endState: state, errorDetails: errorDetails, nowMs: nowMs)
252
+ }
253
+
254
+ private static func streamEndedEvent(_ open: OpenStream, endState: String, errorDetails: String?, nowMs: Int64) -> AnalyticsEvent {
255
+ var properties: [String: Any] = [
256
+ "protocol": open.streamProtocol,
257
+ "end_state": endState,
258
+ "stopped_by_app": open.stoppedByApp,
259
+ "reconnect_attempts": open.reconnectAttempts,
260
+ ]
261
+ if let startedAtMs = open.startedAtMs { properties["duration_ms"] = max(nowMs - startedAtMs, 0) }
262
+ if let detail = Self.sanitizedDetail(errorDetails) { properties["error_code"] = detail }
263
+ if let bitrate = open.bitrate { properties["bitrate"] = bitrate }
264
+ if let fps = open.fps { properties["fps"] = fps }
265
+ if let droppedFrames = open.droppedFrames { properties["dropped_frames"] = droppedFrames }
266
+ if let temperatureC = open.temperatureC { properties["temperature_c"] = temperatureC }
267
+ return AnalyticsEvent(name: Self.eventStreamEnded, properties: properties)
268
+ }
269
+
270
+ /// Follows the active mic source. A session ends when the source turns off or
271
+ /// changes; `local_transcription` is true if on-device transcription ran at
272
+ /// any point during it.
273
+ mutating func observeMic(currentMic: String, transcribing: Bool, nowMs: Int64) -> AnalyticsEvent? {
274
+ let next: String? = currentMic.isBlank ? nil : currentMic
275
+ let previous = micSource
276
+ if next == previous {
277
+ if next != nil, transcribing { micTranscribed = true }
278
+ return nil
279
+ }
280
+ let ended = previous.map {
281
+ AnalyticsEvent(
282
+ name: Self.eventMicSession,
283
+ properties: [
284
+ "duration_ms": max(nowMs - micStartedAtMs, 0),
285
+ "mic_source": $0,
286
+ "local_transcription": micTranscribed,
287
+ ]
288
+ )
289
+ }
290
+ micSource = next
291
+ micStartedAtMs = nowMs
292
+ micTranscribed = next != nil && transcribing
293
+ return ended
294
+ }
295
+
296
+ func wifiSetupSettled(latencyMs: Int64, errorCode: String?) -> AnalyticsEvent {
297
+ var properties: [String: Any] = [:]
298
+ Self.putOutcome(&properties, errorCode)
299
+ properties["latency_ms"] = latencyMs
300
+ return AnalyticsEvent(name: Self.eventWifiSetup, properties: properties)
301
+ }
302
+
303
+ func hotspotStartSettled(latencyMs: Int64, errorCode: String?) -> AnalyticsEvent {
304
+ var properties: [String: Any] = [:]
305
+ Self.putOutcome(&properties, errorCode)
306
+ properties["latency_ms"] = latencyMs
307
+ return AnalyticsEvent(name: Self.eventHotspotStarted, properties: properties)
308
+ }
309
+
310
+ /// One event per OTA session, on `complete` or `failed`. The "from" versions are
311
+ /// the glasses versions when this process first saw the session in progress.
312
+ mutating func otaStatus(
313
+ sessionId: String,
314
+ status: String,
315
+ stepType: String,
316
+ totalSteps: Int,
317
+ errorMessage: String?,
318
+ fromBuildNumber: String,
319
+ fromBesFirmwareVersion: String,
320
+ fromMtkFirmwareVersion: String,
321
+ nowMs: Int64
322
+ ) -> AnalyticsEvent? {
323
+ if finishedOtaSessions.contains(sessionId) { return nil }
324
+ if status != Self.otaComplete, status != Self.otaFailed {
325
+ if status != Self.otaIdle, !otaSessions.contains(sessionId) {
326
+ otaSessions[sessionId] = OpenOta(
327
+ startedAtMs: nowMs,
328
+ fromBuildNumber: fromBuildNumber,
329
+ fromBesFirmwareVersion: fromBesFirmwareVersion,
330
+ fromMtkFirmwareVersion: fromMtkFirmwareVersion
331
+ )
332
+ }
333
+ return nil
334
+ }
335
+ finishedOtaSessions[sessionId] = true
336
+ let open = otaSessions.removeValue(forKey: sessionId)
337
+ let success = status == Self.otaComplete
338
+ var properties: [String: Any] = ["success": success]
339
+ if !success {
340
+ if !stepType.isBlank { properties["failed_step_type"] = stepType }
341
+ if let detail = Self.sanitizedDetail(errorMessage) { properties["error_message"] = detail }
342
+ }
343
+ if totalSteps > 0 { properties["total_steps"] = totalSteps }
344
+ if let open {
345
+ properties["duration_ms"] = max(nowMs - open.startedAtMs, 0)
346
+ if !open.fromBuildNumber.isBlank { properties["from_build_number"] = open.fromBuildNumber }
347
+ if !open.fromBesFirmwareVersion.isBlank { properties["from_bes_firmware_version"] = open.fromBesFirmwareVersion }
348
+ if !open.fromMtkFirmwareVersion.isBlank { properties["from_mtk_firmware_version"] = open.fromMtkFirmwareVersion }
349
+ }
350
+ return AnalyticsEvent(name: Self.eventOtaFinished, properties: properties)
351
+ }
352
+
353
+ /// At most `maxRequestTimeouts` per SDK instance, so a failure loop cannot run up event volume.
354
+ mutating func requestTimedOut(operation: String) -> AnalyticsEvent? {
355
+ guard requestTimeouts < maxRequestTimeouts else { return nil }
356
+ requestTimeouts += 1
357
+ return AnalyticsEvent(
358
+ name: Self.eventRequestFailed,
359
+ properties: ["operation": operation, "error_code": "request_timeout"]
360
+ )
361
+ }
362
+
363
+ /// Mirrors the glasses' scheme mapping in ASG StreamCommandHandler.
364
+ static func streamProtocol(_ streamUrl: String) -> String {
365
+ let trimmed = streamUrl.trimmingCharacters(in: .whitespacesAndNewlines)
366
+ guard let separator = trimmed.range(of: "://") else { return "unknown" }
367
+ switch trimmed[..<separator.lowerBound].lowercased() {
368
+ case "srt": return "srt"
369
+ case "rtmp", "rtmps": return "rtmp"
370
+ case "http", "https": return "whip"
371
+ default: return "unknown"
372
+ }
373
+ }
374
+
375
+ /// Free-text details may carry stream or upload URLs; those never leave the phone.
376
+ static func sanitizedDetail(_ value: String?) -> String? {
377
+ guard let value else { return nil }
378
+ let range = NSRange(value.startIndex..., in: value)
379
+ let replaced = urlPattern.stringByReplacingMatches(in: value, range: range, withTemplate: "<url>")
380
+ let trimmed = replaced.trimmingCharacters(in: .whitespacesAndNewlines)
381
+ guard !trimmed.isEmpty else { return nil }
382
+ return String(trimmed.prefix(maxDetailLength))
383
+ }
384
+
385
+ private static func putOutcome(_ properties: inout [String: Any], _ errorCode: String?) {
386
+ properties["success"] = errorCode == nil
387
+ if let detail = sanitizedDetail(errorCode) { properties["error_code"] = detail }
388
+ }
389
+ }
390
+
391
+ /// Insertion-ordered map that drops its oldest entry beyond `capacity`, so a
392
+ /// stream of ids whose end never arrives cannot grow without bound.
393
+ private struct BoundedMap<Key: Hashable, Value> {
394
+ private let capacity: Int
395
+ private var order: [Key] = []
396
+ private var storage: [Key: Value] = [:]
397
+
398
+ init(capacity: Int) {
399
+ self.capacity = capacity
400
+ }
401
+
402
+ func contains(_ key: Key) -> Bool {
403
+ storage[key] != nil
404
+ }
405
+
406
+ subscript(key: Key) -> Value? {
407
+ get { storage[key] }
408
+ set {
409
+ guard let newValue else {
410
+ removeValue(forKey: key)
411
+ return
412
+ }
413
+ if storage.updateValue(newValue, forKey: key) == nil {
414
+ order.append(key)
415
+ if order.count > capacity {
416
+ storage.removeValue(forKey: order.removeFirst())
417
+ }
418
+ }
419
+ }
420
+ }
421
+
422
+ @discardableResult
423
+ mutating func removeValue(forKey key: Key) -> Value? {
424
+ guard let removed = storage.removeValue(forKey: key) else { return nil }
425
+ order.removeAll { $0 == key }
426
+ return removed
427
+ }
428
+ }
@@ -4,8 +4,59 @@ import XCTest
4
4
  final class BluetoothSdkAnalyticsTrackerTests: XCTestCase {
5
5
  private var tracker = BluetoothSdkAnalyticsTracker(simulatedModel: "Simulated Glasses")
6
6
 
7
- private func snapshot(connected: Bool, model: String = "Mentra Live", serial: String = "") -> AnalyticsGlassesSnapshot {
8
- AnalyticsGlassesSnapshot(connected: connected, fullyBooted: connected, model: model, serialNumber: serial)
7
+ private func snapshot(
8
+ connected: Bool,
9
+ model: String = "Mentra Live",
10
+ serial: String = "",
11
+ battery: Int = -1,
12
+ charging: Bool = false
13
+ ) -> AnalyticsGlassesSnapshot {
14
+ var snapshot = AnalyticsGlassesSnapshot(connected: connected, fullyBooted: connected, model: model, serialNumber: serial)
15
+ snapshot.batteryLevel = battery
16
+ snapshot.charging = charging
17
+ return snapshot
18
+ }
19
+
20
+ func testCountedConnectionReportsItsLengthReasonAndBatteryWhenItEnds() throws {
21
+ tracker.initialize(snapshot(connected: false), reportingDay: 100)
22
+ _ = tracker.observe(snapshot(connected: true), reportingDay: 100, nowMs: 1000)
23
+ _ = tracker.observe(snapshot(connected: true, battery: 80), reportingDay: 100, nowMs: 2000)
24
+ _ = tracker.observe(snapshot(connected: true, battery: 71, charging: true), reportingDay: 100, nowMs: 3000)
25
+
26
+ let ended = tracker.observe(snapshot(connected: false), reportingDay: 100, nowMs: 61000) {
27
+ BluetoothSdkAnalyticsTracker.disconnectUser
28
+ }
29
+ XCTAssertEqual(ended.count, 1)
30
+ let disconnected = try XCTUnwrap(ended.first)
31
+ XCTAssertEqual(disconnected.name, "bluetooth_sdk_glasses_disconnected")
32
+ XCTAssertEqual(disconnected.properties["session_duration_ms"] as? Int64, 60000)
33
+ XCTAssertEqual(disconnected.properties["disconnect_reason"] as? String, "user_disconnect")
34
+ XCTAssertEqual(disconnected.properties["battery_level_at_connect"] as? Int, 80)
35
+ XCTAssertEqual(disconnected.properties["battery_level_at_disconnect"] as? Int, 71)
36
+ XCTAssertEqual(disconnected.properties["charging"] as? Bool, true)
37
+ XCTAssertEqual(disconnected.properties["glasses_model"] as? String, "Mentra Live")
38
+
39
+ // Further disconnected updates do not report the connection again.
40
+ XCTAssertTrue(tracker.observe(snapshot(connected: false), reportingDay: 100, nowMs: 62000).isEmpty)
41
+ }
42
+
43
+ func testConnectionAlreadyUpWhenTheSdkStartedIsNotReportedAsEnding() {
44
+ tracker.initialize(snapshot(connected: true, serial: "MLAB0001"), reportingDay: 100)
45
+ XCTAssertTrue(tracker.observe(snapshot(connected: false), reportingDay: 100, nowMs: 5000).isEmpty)
46
+ }
47
+
48
+ func testBatteryFromAPreviousConnectionDoesNotLeakIntoTheNextOne() throws {
49
+ tracker.initialize(snapshot(connected: false), reportingDay: 100)
50
+ _ = tracker.observe(snapshot(connected: true, battery: 90), reportingDay: 100, nowMs: 0)
51
+ _ = tracker.observe(snapshot(connected: false), reportingDay: 100, nowMs: 1000)
52
+ _ = tracker.observe(snapshot(connected: true), reportingDay: 100, nowMs: 2000)
53
+ let ended = tracker.observe(snapshot(connected: false), reportingDay: 100, nowMs: 3000)
54
+ XCTAssertEqual(ended.count, 1)
55
+ let second = try XCTUnwrap(ended.first)
56
+ XCTAssertNil(second.properties["battery_level_at_connect"])
57
+ XCTAssertNil(second.properties["battery_level_at_disconnect"])
58
+ XCTAssertNil(second.properties["charging"])
59
+ XCTAssertEqual(second.properties["disconnect_reason"] as? String, "unknown")
9
60
  }
10
61
 
11
62
  func testConnectThenSerialEmitsEachOncePerConnection() {
@@ -40,7 +91,7 @@ final class BluetoothSdkAnalyticsTrackerTests: XCTestCase {
40
91
  tracker.initialize(snapshot(connected: false), reportingDay: 100)
41
92
  _ = tracker.observe(snapshot(connected: true, model: ""), reportingDay: 100)
42
93
  let ended = tracker.observe(snapshot(connected: false, model: ""), reportingDay: 100)
43
- XCTAssertEqual(ended.map(\.name), ["bluetooth_sdk_glasses_connected"])
94
+ XCTAssertEqual(ended.map(\.name), ["bluetooth_sdk_glasses_connected", "bluetooth_sdk_glasses_disconnected"])
44
95
  XCTAssertEqual(ended[0].properties["glasses_model_unresolved"] as? Bool, true)
45
96
  XCTAssertNil(ended[0].properties["glasses_model"])
46
97
  }
@@ -0,0 +1,153 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class BluetoothSdkUpdateCheckTests: XCTestCase {
5
+ /// Collects values handed to `@Sendable` callbacks from the check's queue.
6
+ private final class Recorder<Value>: @unchecked Sendable {
7
+ private let lock = NSLock()
8
+ private var stored: [Value] = []
9
+
10
+ var values: [Value] {
11
+ lock.lock()
12
+ defer { lock.unlock() }
13
+ return stored
14
+ }
15
+
16
+ func append(_ value: Value) {
17
+ lock.lock()
18
+ stored.append(value)
19
+ lock.unlock()
20
+ }
21
+ }
22
+
23
+ private final class CheckBox: @unchecked Sendable {
24
+ var check: BluetoothSdkUpdateCheck?
25
+ }
26
+
27
+ private func result(
28
+ _ status: String,
29
+ latest: String? = "3.2.1",
30
+ minimum: String? = nil
31
+ ) -> BluetoothSdkUpdateCheckResult {
32
+ BluetoothSdkUpdateCheckResult(status: status, channel: "latest", latestVersion: latest, minimumSupportedVersion: minimum)
33
+ }
34
+
35
+ func testParsesTheCoreResponseTreatingNullAndBlankVersionsAsUnknown() {
36
+ let parsed = BluetoothSdkUpdateCheckResult.parse(
37
+ #"{"status":"update_available","channel":"dev","latestVersion":"3.3.0-dev.821","minimumSupportedVersion":null}"#
38
+ )
39
+ XCTAssertEqual(
40
+ parsed,
41
+ BluetoothSdkUpdateCheckResult(status: "update_available", channel: "dev", latestVersion: "3.3.0-dev.821", minimumSupportedVersion: nil)
42
+ )
43
+ XCTAssertNil(BluetoothSdkUpdateCheckResult.parse(#"{"status":"current","latestVersion":""}"#)?.latestVersion)
44
+ XCTAssertNotNil(BluetoothSdkUpdateCheckResult.parse(#"{"status":"current","latestVersion":""}"#))
45
+ XCTAssertNil(BluetoothSdkUpdateCheckResult.parse("<html>502</html>"))
46
+ }
47
+
48
+ func testBannerNamesInstalledAndLatestVersionsAndTheSurfaceUpdateCommand() throws {
49
+ let reactNative = try XCTUnwrap(BluetoothSdkUpdateNotice.banner(installedVersion: "3.1.0", result: result("update_available"), surface: "react_native"))
50
+ XCTAssertTrue(reactNative.contains("MENTRA BLUETOOTH SDK UPDATE AVAILABLE"))
51
+ XCTAssertTrue(reactNative.contains("Installed: 3.1.0"))
52
+ XCTAssertTrue(reactNative.contains("Latest: 3.2.1"))
53
+ XCTAssertTrue(reactNative.contains("bun add @mentra/bluetooth-sdk@3.2.1"))
54
+ XCTAssertFalse(reactNative.contains("NO LONGER SUPPORTED"))
55
+ XCTAssertTrue(reactNative.hasPrefix("=================================================================="))
56
+ XCTAssertTrue(reactNative.hasSuffix("=================================================================="))
57
+
58
+ let ios = try XCTUnwrap(BluetoothSdkUpdateNotice.banner(installedVersion: "3.1.0", result: result("update_available"), surface: "ios"))
59
+ XCTAssertTrue(ios.contains("MentraBluetoothSDK Swift package at 3.2.1"))
60
+ let android = try XCTUnwrap(BluetoothSdkUpdateNotice.banner(installedVersion: "3.1.0", result: result("update_available"), surface: "android"))
61
+ XCTAssertTrue(android.contains("com.mentraglass:bluetooth-sdk:3.2.1"))
62
+ }
63
+
64
+ func testUnsupportedVersionGetsTheStrongerBanner() throws {
65
+ let banner = try XCTUnwrap(BluetoothSdkUpdateNotice.banner(
66
+ installedVersion: "3.0.5",
67
+ result: result("unsupported", minimum: "3.1.0"),
68
+ surface: "ios"
69
+ ))
70
+ XCTAssertTrue(banner.contains("MENTRA BLUETOOTH SDK 3.0.5 IS NO LONGER SUPPORTED"))
71
+ XCTAssertTrue(banner.contains("known issues"))
72
+ }
73
+
74
+ func testNoBannerWhenCurrentUnknownOrNoLatestVersionToPointAt() {
75
+ XCTAssertNil(BluetoothSdkUpdateNotice.banner(installedVersion: "3.2.1", result: result("current"), surface: "ios"))
76
+ XCTAssertNil(BluetoothSdkUpdateNotice.banner(installedVersion: "3.2.1", result: result("unknown", latest: nil), surface: "ios"))
77
+ XCTAssertNil(BluetoothSdkUpdateNotice.banner(installedVersion: "3.2.1", result: result("update_available", latest: nil), surface: "ios"))
78
+ }
79
+
80
+ func testSurfaceFollowsTheIntegration() {
81
+ XCTAssertEqual(BluetoothSdkUpdateCheck.surface(forAnalyticsSurface: "react_native"), "react_native")
82
+ XCTAssertEqual(BluetoothSdkUpdateCheck.surface(forAnalyticsSurface: "ios"), "ios")
83
+ XCTAssertEqual(BluetoothSdkUpdateCheck.surface(forAnalyticsSurface: "macos"), "ios")
84
+ }
85
+
86
+ func testRequestIdentifiesTheInstallAppAndSdk() throws {
87
+ let check = BluetoothSdkUpdateCheck(surface: "react_native", sdkVersion: "3.1.0", transport: { _, completion in completion(nil) }) { _, _ in }
88
+ let body = check.requestBody()
89
+ let installId = try XCTUnwrap(body["install_id"] as? String)
90
+ XCTAssertEqual(installId, BluetoothSdkInstallId.get())
91
+ XCTAssertTrue(installId.hasPrefix("mentra-bt-sdk-"))
92
+ XCTAssertEqual(body["app_identifier"] as? String, Bundle.main.bundleIdentifier ?? "")
93
+ XCTAssertEqual(body["sdk_version"] as? String, "3.1.0")
94
+ XCTAssertEqual(body["sdk_platform"] as? String, "ios")
95
+ XCTAssertEqual(body["sdk_surface"] as? String, "react_native")
96
+ XCTAssertNotNil(body["app_build_type"])
97
+ XCTAssertNotNil(body["app_install_source"])
98
+ XCTAssertTrue(JSONSerialization.isValidJSONObject(body))
99
+ }
100
+
101
+ func testOutOfDateSdkPrintsTheBannerOnceAtStartAndStopsOnClose() throws {
102
+ let notices = Recorder<String>()
103
+ let sentBodies = Recorder<Data>()
104
+ let check = BluetoothSdkUpdateCheck(
105
+ surface: "ios",
106
+ sdkVersion: "3.1.0",
107
+ transport: { body, completion in
108
+ sentBodies.append(body)
109
+ completion(Data(#"{"status":"update_available","channel":"latest","latestVersion":"3.2.1","minimumSupportedVersion":null}"#.utf8))
110
+ }
111
+ ) { banner, _ in notices.append(banner) }
112
+ check.start()
113
+ BluetoothSdkUpdateCheck.awaitIdle()
114
+ XCTAssertEqual(notices.values.count, 1)
115
+ XCTAssertTrue(notices.values.first?.contains("Latest: 3.2.1") == true)
116
+ let sentBody = try XCTUnwrap(sentBodies.values.first)
117
+ let sent = try XCTUnwrap(JSONSerialization.jsonObject(with: sentBody) as? [String: Any])
118
+ XCTAssertEqual(sent["sdk_version"] as? String, "3.1.0")
119
+ check.stop()
120
+ }
121
+
122
+ func testFailuresAndUnknownVersionsAreSilent() {
123
+ let notices = Recorder<String>()
124
+ let calls = Recorder<Bool>()
125
+ let checks = [
126
+ BluetoothSdkUpdateCheck(surface: "ios", sdkVersion: "3.1.0", transport: { _, completion in completion(nil) }) { banner, _ in notices.append(banner) },
127
+ BluetoothSdkUpdateCheck(surface: "ios", sdkVersion: "3.1.0", transport: { _, completion in completion(Data("<html>502</html>".utf8)) }) { banner, _ in notices.append(banner) },
128
+ BluetoothSdkUpdateCheck(surface: "ios", sdkVersion: "unknown", transport: { _, completion in calls.append(true); completion(nil) }) { banner, _ in notices.append(banner) },
129
+ BluetoothSdkUpdateCheck(surface: "ios", sdkVersion: nil, transport: { _, completion in calls.append(true); completion(nil) }) { banner, _ in notices.append(banner) },
130
+ ]
131
+ checks.forEach { $0.start() }
132
+ BluetoothSdkUpdateCheck.awaitIdle()
133
+ XCTAssertTrue(notices.values.isEmpty)
134
+ XCTAssertTrue(calls.values.isEmpty)
135
+ }
136
+
137
+ func testCheckStoppedBeforeTheResponseArrivesPrintsNothing() {
138
+ let notices = Recorder<String>()
139
+ let box = CheckBox()
140
+ let check = BluetoothSdkUpdateCheck(
141
+ surface: "ios",
142
+ sdkVersion: "3.1.0",
143
+ transport: { _, completion in
144
+ box.check?.stop()
145
+ completion(Data(#"{"status":"update_available","latestVersion":"3.2.1"}"#.utf8))
146
+ }
147
+ ) { banner, _ in notices.append(banner) }
148
+ box.check = check
149
+ check.start()
150
+ BluetoothSdkUpdateCheck.awaitIdle()
151
+ XCTAssertTrue(notices.values.isEmpty)
152
+ }
153
+ }