@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
|
@@ -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(
|
|
8
|
-
|
|
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
|
+
}
|