@mentra/bluetooth-sdk 3.2.1 → 3.3.0-beta.954

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 (95) hide show
  1. package/README.md +68 -6
  2. package/android/build.gradle +15 -0
  3. package/android/lc3Lib/build.gradle +6 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -2
  9. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +4 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -1
  13. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  14. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +223 -36
  15. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +93 -18
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +3 -1
  24. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  25. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
  29. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
  32. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +20 -7
  36. package/build/BluetoothSdk.types.d.ts +3 -0
  37. package/build/BluetoothSdk.types.d.ts.map +1 -1
  38. package/build/BluetoothSdk.types.js.map +1 -1
  39. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  40. package/build/_private/BluetoothSdkModule.js +2 -1
  41. package/build/_private/BluetoothSdkModule.js.map +1 -1
  42. package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
  43. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  44. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  45. package/build/_private/nativeLogConsole.d.ts +19 -0
  46. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  47. package/build/_private/nativeLogConsole.js +12 -0
  48. package/build/_private/nativeLogConsole.js.map +1 -1
  49. package/build/generated/changelogCatalog.d.ts +4 -1
  50. package/build/generated/changelogCatalog.d.ts.map +1 -1
  51. package/build/generated/changelogCatalog.js +5 -1
  52. package/build/generated/changelogCatalog.js.map +1 -1
  53. package/build/generated/releaseMetadata.js +6 -6
  54. package/build/generated/releaseMetadata.js.map +1 -1
  55. package/build/ota-transport/index.d.ts +4 -0
  56. package/build/ota-transport/index.d.ts.map +1 -1
  57. package/build/ota-transport/index.js +2 -0
  58. package/build/ota-transport/index.js.map +1 -1
  59. package/ios/BluetoothSdkModule.swift +8 -4
  60. package/ios/Source/BluetoothSdkDefaults.swift +2 -2
  61. package/ios/Source/DeviceManager.swift +6 -3
  62. package/ios/Source/GeneratedChangelogCatalog.swift +2 -1
  63. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  64. package/ios/Source/MentraBluetoothSDK.swift +295 -54
  65. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  66. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  67. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  68. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  69. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  70. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  71. package/ios/Source/sgcs/MentraLive.swift +71 -41
  72. package/ios/Source/sgcs/NimoCanvasCodec.swift +4 -1
  73. package/ios/Source/sgcs/SGCManager.swift +1 -1
  74. package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
  75. package/ios/Source/utils/G1Text.swift +2 -24
  76. package/ios/Source/utils/G1TextSanitizer.swift +27 -0
  77. package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
  78. package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
  79. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  80. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  81. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  82. package/ios/Tests/G1TextSanitizerTests.swift +32 -0
  83. package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
  84. package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
  85. package/ios/Tests/NimoCanvasCodecTests.swift +17 -4
  86. package/ios/Tests/VersionInfoCommandTests.swift +142 -1
  87. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  88. package/package.json +2 -2
  89. package/src/BluetoothSdk.types.ts +1 -0
  90. package/src/_private/BluetoothSdkModule.ts +2 -1
  91. package/src/_private/MentraLocalNetworkModule.ts +28 -0
  92. package/src/_private/nativeLogConsole.ts +19 -0
  93. package/src/generated/changelogCatalog.ts +5 -1
  94. package/src/generated/releaseMetadata.ts +6 -6
  95. package/src/ota-transport/index.ts +10 -1
@@ -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
+ }
@@ -923,7 +923,7 @@ extension MentraLive: CBCentralManagerDelegate {
923
923
  self.isConnecting = false
924
924
 
925
925
  self.connectedPeripheral = nil
926
- self.fullyBooted = false
926
+ self.clearPhysicalReadiness()
927
927
  self.connected = false
928
928
  self.glassesSessionId = nil // Fresh BLE session starts with no sid known
929
929
  self.streamControlVersion = 0
@@ -1349,7 +1349,7 @@ class MentraLive: NSObject, SGCManager {
1349
1349
  // Queued writes are session-bound: transmitting them into the NEXT session
1350
1350
  // (e.g. a stale handshake whose reply activates v2 before the new session
1351
1351
  // negotiated) is the bug class this clear removes.
1352
- Task { await commandQueue.removeAll() }
1352
+ commandQueue.removeAll()
1353
1353
  stopSignalStrengthPolling()
1354
1354
  DeviceStore.shared.apply("glasses", "signalStrength", -1)
1355
1355
  DeviceStore.shared.apply("glasses", "signalStrengthUpdatedAt", 0)
@@ -1617,6 +1617,16 @@ class MentraLive: NSObject, SGCManager {
1617
1617
  set { DeviceStore.shared.apply("glasses", "connected", newValue) }
1618
1618
  }
1619
1619
 
1620
+ private func clearPhysicalReadiness() {
1621
+ let readinessWasPending = connected && !fullyBooted
1622
+ fullyBooted = false
1623
+ if readinessWasPending {
1624
+ // Logical ASG readiness already emitted false without disconnecting.
1625
+ // The real disconnect must still perform the cleanup apply deduplicates.
1626
+ DeviceManager.shared.handleDeviceDisconnected()
1627
+ }
1628
+ }
1629
+
1620
1630
  // Queue Management
1621
1631
  private let commandQueue = CommandQueue()
1622
1632
  private let bluetoothQueue = DispatchQueue(label: "MentraLiveBluetooth", qos: .userInitiated)
@@ -1744,8 +1754,8 @@ class MentraLive: NSObject, SGCManager {
1744
1754
  // Publish disconnect immediately for Phone A UI. didDisconnectPeripheral may lag
1745
1755
  // or race with cancelPeripheralConnection; do not wait on it for stand-down.
1746
1756
  isConnecting = false
1757
+ clearPhysicalReadiness()
1747
1758
  connected = false
1748
- fullyBooted = false
1749
1759
  glassesSessionId = nil
1750
1760
  streamControlVersion = 0
1751
1761
  readinessCompletedThisBleSession = false
@@ -2123,8 +2133,7 @@ class MentraLive: NSObject, SGCManager {
2123
2133
  /// Wire-session generation at enqueue time; the drain loop drops entries from a
2124
2134
  /// previous session so a stale write (worst case: an old handshake whose reply
2125
2135
  /// would activate v2 pre-negotiation) can never cross a session boundary. This is
2126
- /// the deterministic guard - the async removeAll() on disconnect is best-effort
2127
- /// cleanup that may land after a reconnect has already resumed the drain loop.
2136
+ /// the deterministic guard if a consumer already dequeued a previous-session write.
2128
2137
  let generation: Int
2129
2138
  }
2130
2139
 
@@ -2166,7 +2175,7 @@ class MentraLive: NSObject, SGCManager {
2166
2175
  Bridge.log("LIVE: Cleared pending ACK mId \(pendingMessage.id) because \(reason)")
2167
2176
  }
2168
2177
 
2169
- actor CommandQueue {
2178
+ @MainActor final class CommandQueue {
2170
2179
  private var commands: [PendingMessage] = []
2171
2180
 
2172
2181
  func enqueue(_ command: PendingMessage) -> Int {
@@ -2282,15 +2291,11 @@ class MentraLive: NSObject, SGCManager {
2282
2291
  )
2283
2292
 
2284
2293
  // Push to front of queue for immediate retry
2285
- Task {
2286
- let queueSize = await self.commandQueue.pushToFront(retryMessage)
2287
- await MainActor.run {
2288
- self.logBleWriteTrace("retry_queued", retryMessage.trace, extra: [
2289
- "retryDelayMs": 0,
2290
- "queueSizeAfterAdd": queueSize,
2291
- ])
2292
- }
2293
- }
2294
+ let queueSize = commandQueue.pushToFront(retryMessage)
2295
+ logBleWriteTrace("retry_queued", retryMessage.trace, extra: [
2296
+ "retryDelayMs": 0,
2297
+ "queueSizeAfterAdd": queueSize,
2298
+ ])
2294
2299
 
2295
2300
  Bridge.log(
2296
2301
  "🔄 Retrying message mId: \(pendingMessage.id) (attempt \(retryMessage.retries)/3)"
@@ -2528,8 +2533,8 @@ class MentraLive: NSObject, SGCManager {
2528
2533
  Bridge.log("LIVE: Maximum reconnection attempts reached (\(MAX_RECONNECT_ATTEMPTS))")
2529
2534
  reconnectAttempts = 0
2530
2535
  updateConnectionState(ConnTypes.DISCONNECTED)
2536
+ clearPhysicalReadiness()
2531
2537
  connected = false
2532
- fullyBooted = false
2533
2538
  glassesSessionId = nil
2534
2539
  streamControlVersion = 0
2535
2540
  readinessCompletedThisBleSession = false
@@ -3143,6 +3148,11 @@ class MentraLive: NSObject, SGCManager {
3143
3148
  if type.hasPrefix("version_info") {
3144
3149
  Bridge.log("LIVE: Received \(type)")
3145
3150
 
3151
+ // A new process can report its build before glasses_ready resets the
3152
+ // wire epoch. Publish its pending readiness before those version fields
3153
+ // can complete OTA and trigger the next request.
3154
+ handleGlassesSessionId(json)
3155
+
3146
3156
  // Extract all fields from JSON (except "type")
3147
3157
  var fields: [String: Any] = [:]
3148
3158
  fields["version_info_type"] = type
@@ -3215,7 +3225,6 @@ class MentraLive: NSObject, SGCManager {
3215
3225
  // running them per chunk is safe.
3216
3226
  parsePeerWireCaps(json)
3217
3227
  maybeSendWireHandshake()
3218
- handleGlassesSessionId(json)
3219
3228
 
3220
3229
  Bridge.sendVersionInfo(fields, responseChunk: type)
3221
3230
  } else {
@@ -3714,7 +3723,19 @@ class MentraLive: NSObject, SGCManager {
3714
3723
  sendJson(json, wakeUp: true)
3715
3724
  }
3716
3725
 
3717
- func sendIncidentId(_ incidentId: String, apiBaseUrl: String?) {
3726
+ var incidentLogTransportReady: Bool {
3727
+ connectedPeripheral?.state == .connected && txCharacteristic != nil
3728
+ }
3729
+
3730
+ func sendIncidentId(_ incidentId: String, apiBaseUrl: String?) throws {
3731
+ guard incidentLogTransportReady else {
3732
+ throw BluetoothSdkError(code: "glasses_not_connected", message: "Cannot request incident logs because the glasses BLE link is not ready.")
3733
+ }
3734
+ // Token sync may happen after glasses_ready; never rely on the last pairing's token.
3735
+ let coreToken = DeviceStore.shared.get("bluetooth", "core_token") as? String ?? ""
3736
+ guard !coreToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
3737
+ throw BluetoothSdkError(code: "core_token_unavailable", message: "Cannot request incident logs without the current Core access token.")
3738
+ }
3718
3739
  var base = (apiBaseUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
3719
3740
  if base.isEmpty {
3720
3741
  base = "https://api.mentra.glass"
@@ -3731,10 +3752,20 @@ class MentraLive: NSObject, SGCManager {
3731
3752
  fileBaseKey: lKey, incidentId: incidentId, apiBaseUrl: base, kind: .logcat
3732
3753
  )
3733
3754
 
3734
- Bridge.log(
3735
- "LIVE: Sending incidentId to glasses for log upload: \(incidentId) (BLE relay \(bKey), \(lKey))"
3736
- )
3737
- sendJson(["type": "upload_incident_logs", "incidentId": incidentId, "apiBaseUrl": base], wakeUp: true)
3755
+ let auth: [String: Any] = [
3756
+ "type": "auth_token", "coreToken": coreToken,
3757
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
3758
+ ]
3759
+ // MainActor queue insertion preserves auth-before-upload order, including all fragments.
3760
+ guard sendJson(auth, wakeUp: true, requireAck: true, bridgeLogging: true),
3761
+ sendJson(["type": "upload_incident_logs", "incidentId": incidentId, "apiBaseUrl": base],
3762
+ wakeUp: true, requireAck: true, bridgeLogging: true)
3763
+ else {
3764
+ bleIncidentLogRelays.removeValue(forKey: bKey)
3765
+ bleIncidentLogRelays.removeValue(forKey: lKey)
3766
+ throw BluetoothSdkError(code: "incident_dispatch_failed", message: "Could not queue the glasses incident-log request.")
3767
+ }
3768
+ Bridge.log("LIVE: Queued incidentId to glasses for log upload: \(incidentId) (BLE relay \(bKey), \(lKey))")
3738
3769
  }
3739
3770
 
3740
3771
  private static func incidentBleFileBase(incidentId: String, prefix: Character) -> String {
@@ -4671,20 +4702,13 @@ class MentraLive: NSObject, SGCManager {
4671
4702
 
4672
4703
  func queueSend(_ data: Data, id: String, trace: BleWriteTrace?) {
4673
4704
  let significantQueueSize = SIGNIFICANT_BLE_TRACE_QUEUE_SIZE
4674
- // Capture the epoch at enqueue-request time: the Task body may run after a
4675
- // session reset, and a pre-reset payload stamped with the new generation would
4676
- // defeat the stale-write guard in the drain loop.
4705
+ // Insert synchronously on MainActor so fragments and dependent commands retain call order.
4677
4706
  let generation = wireSessionGeneration
4678
- Task {
4679
- let queueSize = await commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0, trace: trace, generation: generation))
4680
- guard trace != nil, queueSize >= significantQueueSize else {
4681
- return
4682
- }
4683
- await MainActor.run {
4684
- self.logBleChunkTrace("queued", trace, extra: [
4685
- "queueSizeAfterAdd": queueSize,
4686
- ])
4687
- }
4707
+ let queueSize = commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0, trace: trace, generation: generation))
4708
+ if trace != nil, queueSize >= significantQueueSize {
4709
+ logBleChunkTrace("queued", trace, extra: [
4710
+ "queueSizeAfterAdd": queueSize,
4711
+ ])
4688
4712
  }
4689
4713
  }
4690
4714
 
@@ -5201,7 +5225,10 @@ class MentraLive: NSObject, SGCManager {
5201
5225
  bridgeLogging: bridgeLogging
5202
5226
  )
5203
5227
  }
5204
- transportLog("LIVE: Sending data to glasses: \(jsonString)", bridgeLogging: bridgeLogging)
5228
+ // Credentials reach the wire unchanged; diagnostic logs omit their values.
5229
+ let loggedPayload = json["password"] == nil && json["coreToken"] == nil
5230
+ ? jsonString : "<\(commandInfo.commandType) with credentials omitted>"
5231
+ transportLog("LIVE: Sending data to glasses: \(loggedPayload)", bridgeLogging: bridgeLogging)
5205
5232
  let packedData =
5206
5233
  packJson(
5207
5234
  jsonString,
@@ -5724,7 +5751,7 @@ class MentraLive: NSObject, SGCManager {
5724
5751
  stopReadinessCheckLoop()
5725
5752
 
5726
5753
  readinessCheckCounter = 0
5727
- fullyBooted = false
5754
+ clearPhysicalReadiness()
5728
5755
  connected = false
5729
5756
  glassesSessionId = nil
5730
5757
  streamControlVersion = 0
@@ -5996,7 +6023,7 @@ class MentraLive: NSObject, SGCManager {
5996
6023
  // Stop all timers
5997
6024
  stopAllTimers()
5998
6025
  resetWireNegotiationState()
5999
- Task { await commandQueue.removeAll() } // stale writes die with the session
6026
+ commandQueue.removeAll() // stale writes die with the session
6000
6027
  closeL2capFileChannel()
6001
6028
 
6002
6029
  // Disconnect BLE
@@ -6005,8 +6032,8 @@ class MentraLive: NSObject, SGCManager {
6005
6032
  centralManager?.cancelPeripheralConnection(peripheral)
6006
6033
  }
6007
6034
 
6035
+ clearPhysicalReadiness()
6008
6036
  DeviceStore.shared.apply("glasses", "connected", false)
6009
- DeviceStore.shared.apply("glasses", "fullyBooted", false)
6010
6037
  DeviceStore.shared.apply("glasses", "wifiConnected", false)
6011
6038
  DeviceStore.shared.apply("glasses", "wifiSsid", "")
6012
6039
  DeviceStore.shared.apply("glasses", "wifiLocalIp", "")
@@ -6143,8 +6170,8 @@ extension MentraLive {
6143
6170
  /// pre-sid build) or differing from the recorded one = restart; same sid = no action.
6144
6171
  /// A restart is a LOGICAL session reset: send phone_ready immediately (bypassing the
6145
6172
  /// sr_hrt heartbeat's stale-readiness suppression); the returning glasses_ready runs
6146
- /// the full existing remote-wire-reset flow. fullyBooted is deliberately untouched -
6147
- /// the physical link never dropped, so the connection UI must not flap.
6173
+ /// the full existing remote-wire-reset flow. Keep the physical connection intact,
6174
+ /// but mark readiness pending until that new wire epoch has been established.
6148
6175
  private func handleGlassesSessionId(_ json: [String: Any]) {
6149
6176
  guard let sid = json["sid"] as? String, !sid.isEmpty else { return }
6150
6177
  let previous = glassesSessionId
@@ -6159,6 +6186,9 @@ extension MentraLive {
6159
6186
  "LIVE: 🔁 Glasses session changed (\(previous ?? "<pre-sid build>") -> \(sid)) - " +
6160
6187
  "asg restarted under a live link, re-running readiness"
6161
6188
  )
6189
+ // Emit the existing connected-but-not-ready status without invoking the
6190
+ // physical-disconnect side effects of DeviceStore.apply(fullyBooted: false).
6191
+ DeviceStore.shared.set("glasses", "fullyBooted", false)
6162
6192
  sendPhoneReady(reason: "glasses session changed")
6163
6193
  Bridge.sendTypedMessage(
6164
6194
  "glasses_session_changed",