@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,388 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ /**
4
+ * Pure decision logic for the Mentra Live usage events: one event per photo,
5
+ * recording, stream, mic session, setup attempt, OTA attempt and timed-out
6
+ * glasses command. It owns no threads and no I/O; [BluetoothSdkAnalytics] feeds
7
+ * it facts and captures what it returns, and only when analytics is enabled.
8
+ *
9
+ * Durations are measured on the phone's clock between the start and end
10
+ * statuses it observes. A recording or stream whose end never reaches this
11
+ * process (the app was killed mid-stream) produces only its start.
12
+ */
13
+ internal class BluetoothSdkUsageTracker(private val maxRequestTimeouts: Int = MAX_REQUEST_TIMEOUTS) {
14
+ private data class OpenRecording(
15
+ var startedAtMs: Long? = null,
16
+ val width: Int? = null,
17
+ val height: Int? = null,
18
+ val fps: Int? = null,
19
+ val save: Boolean? = null,
20
+ val maxDurationMinutes: Int? = null,
21
+ var stoppedByApp: Boolean = false,
22
+ )
23
+
24
+ private data class OpenStream(
25
+ val streamId: String,
26
+ val protocol: String,
27
+ /** Set by the first live status; null while the start is still pending. */
28
+ var startedAtMs: Long? = null,
29
+ var reconnectAttempts: Int = 0,
30
+ var stoppedByApp: Boolean = false,
31
+ var bitrate: Long? = null,
32
+ var fps: Double? = null,
33
+ var droppedFrames: Long? = null,
34
+ var temperatureC: Double? = null,
35
+ )
36
+
37
+ private data class OpenOta(
38
+ val startedAtMs: Long,
39
+ val fromBuildNumber: String,
40
+ val fromBesFirmwareVersion: String,
41
+ val fromMtkFirmwareVersion: String,
42
+ )
43
+
44
+ private val recordings = boundedMap<String, OpenRecording>(MAX_OPEN_RECORDINGS)
45
+ private var stream: OpenStream? = null
46
+ private var micSource: String? = null
47
+ private var micStartedAtMs = 0L
48
+ private var micTranscribed = false
49
+ private val otaSessions = boundedMap<String, OpenOta>(MAX_OTA_SESSIONS)
50
+ private val finishedOtaSessions = boundedMap<String, Boolean>(MAX_OTA_SESSIONS)
51
+ private var requestTimeouts = 0
52
+
53
+ fun photoSettled(
54
+ size: String,
55
+ mode: String,
56
+ compress: String,
57
+ save: Boolean,
58
+ transferMethod: String,
59
+ latencyMs: Long,
60
+ fileSizeBytes: Long?,
61
+ errorCode: String?,
62
+ ): AnalyticsEvent =
63
+ AnalyticsEvent(
64
+ EVENT_PHOTO_TAKEN,
65
+ buildMap {
66
+ put("trigger", TRIGGER_API)
67
+ putOutcome(errorCode)
68
+ put("latency_ms", latencyMs)
69
+ put("photo_size", size)
70
+ put("photo_mode", mode)
71
+ put("compress", compress)
72
+ put("save_to_gallery", save)
73
+ put("transfer_method", transferMethod)
74
+ fileSizeBytes?.takeIf { it > 0 }?.let { put("file_size_bytes", it) }
75
+ },
76
+ )
77
+
78
+ /** A button photo the glasses could not take; successful button photos never reach the phone. */
79
+ fun buttonPhotoFailed(errorCode: String?): AnalyticsEvent =
80
+ AnalyticsEvent(
81
+ EVENT_PHOTO_TAKEN,
82
+ buildMap {
83
+ put("trigger", TRIGGER_BUTTON)
84
+ putOutcome(errorCode ?: "photo_request_failed")
85
+ },
86
+ )
87
+
88
+ /** Records the API parameters of a recording so its end event can report them. */
89
+ fun videoRequested(requestId: String, width: Int, height: Int, fps: Int, save: Boolean, maxDurationMinutes: Int) {
90
+ recordings[requestId] =
91
+ OpenRecording(
92
+ width = width.takeIf { it > 0 },
93
+ height = height.takeIf { it > 0 },
94
+ fps = fps.takeIf { it > 0 },
95
+ save = save,
96
+ maxDurationMinutes = maxDurationMinutes.takeIf { it > 0 },
97
+ )
98
+ }
99
+
100
+ fun videoStopRequested(requestId: String) {
101
+ recordings[requestId]?.stoppedByApp = true
102
+ }
103
+
104
+ /**
105
+ * Glasses report every recording, button-started ones included, through
106
+ * `video_recording_status`. A recording is reported once: when it stops, or
107
+ * when a command for it fails. `not_recording` answers a stop for a recording
108
+ * that already ended and is not a recording of its own.
109
+ */
110
+ fun videoStatus(requestId: String, status: String, success: Boolean, nowMs: Long): AnalyticsEvent? {
111
+ if (requestId.isBlank()) return null
112
+ if (success) {
113
+ when (status) {
114
+ "recording_started" -> {
115
+ val recording = recordings[requestId] ?: OpenRecording().also { recordings[requestId] = it }
116
+ if (recording.startedAtMs == null) recording.startedAtMs = nowMs
117
+ return null
118
+ }
119
+ "recording_stopped" -> {
120
+ val recording = recordings.remove(requestId) ?: return null
121
+ return videoEvent(requestId, recording, nowMs, errorCode = null)
122
+ }
123
+ else -> return null
124
+ }
125
+ }
126
+ if (status == "not_recording") {
127
+ recordings.remove(requestId)
128
+ return null
129
+ }
130
+ val recording = recordings.remove(requestId) ?: OpenRecording()
131
+ return videoEvent(requestId, recording, nowMs, errorCode = status.ifBlank { "video_recording_failed" })
132
+ }
133
+
134
+ private fun videoEvent(requestId: String, recording: OpenRecording, nowMs: Long, errorCode: String?) =
135
+ AnalyticsEvent(
136
+ EVENT_VIDEO_RECORDED,
137
+ buildMap {
138
+ put("trigger", if (requestId.startsWith(LOCAL_VIDEO_REQUEST_PREFIX)) TRIGGER_BUTTON else TRIGGER_API)
139
+ putOutcome(errorCode)
140
+ recording.startedAtMs?.let { put("duration_ms", (nowMs - it).coerceAtLeast(0)) }
141
+ recording.width?.let { put("width", it) }
142
+ recording.height?.let { put("height", it) }
143
+ recording.fps?.let { put("fps", it) }
144
+ recording.save?.let { put("save_to_gallery", it) }
145
+ recording.maxDurationMinutes?.let { put("max_duration_minutes", it) }
146
+ put("stopped_by_app", recording.stoppedByApp)
147
+ },
148
+ )
149
+
150
+ /**
151
+ * Opens the stream as its start command is sent, so a status that arrives
152
+ * before `startStream` returns is still attributed to it. A new start
153
+ * supersedes a stream that was live (the glasses stop it), so that stream is
154
+ * reported as `replaced`.
155
+ */
156
+ fun streamRequested(streamId: String, streamUrl: String, nowMs: Long): AnalyticsEvent? {
157
+ val replaced =
158
+ stream
159
+ ?.takeIf { it.startedAtMs != null && it.streamId != streamId }
160
+ ?.let { streamEndedEvent(it, "replaced", errorDetails = null, nowMs = nowMs) }
161
+ stream = OpenStream(streamId = streamId, protocol = streamProtocol(streamUrl))
162
+ return replaced
163
+ }
164
+
165
+ fun streamStartSettled(
166
+ streamUrl: String,
167
+ width: Int?,
168
+ height: Int?,
169
+ fps: Int?,
170
+ bitrate: Int?,
171
+ captureAudio: Boolean,
172
+ glassesNetwork: String,
173
+ latencyMs: Long,
174
+ errorCode: String?,
175
+ ): AnalyticsEvent =
176
+ AnalyticsEvent(
177
+ EVENT_STREAM_STARTED,
178
+ buildMap {
179
+ putOutcome(errorCode)
180
+ put("protocol", streamProtocol(streamUrl))
181
+ width?.let { put("width", it) }
182
+ height?.let { put("height", it) }
183
+ fps?.let { put("fps", it) }
184
+ bitrate?.let { put("bitrate", it) }
185
+ put("capture_audio", captureAudio)
186
+ put("glasses_network", glassesNetwork)
187
+ put("latency_ms", latencyMs)
188
+ },
189
+ )
190
+
191
+ fun streamStopRequested() {
192
+ stream?.stoppedByApp = true
193
+ }
194
+
195
+ /**
196
+ * Folds one accepted `stream_status` into the open stream and reports the
197
+ * stream once, on its terminal status. Glasses with stream revisions flag the
198
+ * terminal status; older glasses only send a final `stopped` or
199
+ * `reconnect_failed`. A stream that never went live ends silently: its
200
+ * `bluetooth_sdk_stream_started` already reported the failure.
201
+ */
202
+ fun streamStatus(
203
+ streamId: String?,
204
+ state: String,
205
+ terminal: Boolean?,
206
+ reconnectAttempt: Int?,
207
+ bitrate: Long?,
208
+ fps: Double?,
209
+ droppedFrames: Long?,
210
+ temperatureC: Double?,
211
+ errorDetails: String?,
212
+ nowMs: Long,
213
+ ): AnalyticsEvent? {
214
+ val open = stream ?: return null
215
+ if (!streamId.isNullOrBlank() && streamId != open.streamId) return null
216
+ if (state in LIVE_STREAM_STATES && open.startedAtMs == null) open.startedAtMs = nowMs
217
+ reconnectAttempt?.let { open.reconnectAttempts = maxOf(open.reconnectAttempts, it) }
218
+ bitrate?.let { open.bitrate = it }
219
+ fps?.let { open.fps = it }
220
+ droppedFrames?.let { open.droppedFrames = it }
221
+ temperatureC?.let { open.temperatureC = it }
222
+ val ended = terminal == true || (terminal == null && state in LEGACY_TERMINAL_STREAM_STATES)
223
+ if (!ended) return null
224
+ stream = null
225
+ if (open.startedAtMs == null) return null
226
+ return streamEndedEvent(open, state, errorDetails, nowMs)
227
+ }
228
+
229
+ private fun streamEndedEvent(open: OpenStream, endState: String, errorDetails: String?, nowMs: Long) =
230
+ AnalyticsEvent(
231
+ EVENT_STREAM_ENDED,
232
+ buildMap {
233
+ put("protocol", open.protocol)
234
+ open.startedAtMs?.let { put("duration_ms", (nowMs - it).coerceAtLeast(0)) }
235
+ put("end_state", endState)
236
+ put("stopped_by_app", open.stoppedByApp)
237
+ put("reconnect_attempts", open.reconnectAttempts)
238
+ sanitizedDetail(errorDetails)?.let { put("error_code", it) }
239
+ open.bitrate?.let { put("bitrate", it) }
240
+ open.fps?.let { put("fps", it) }
241
+ open.droppedFrames?.let { put("dropped_frames", it) }
242
+ open.temperatureC?.let { put("temperature_c", it) }
243
+ },
244
+ )
245
+
246
+ /**
247
+ * Follows the active mic source. A session ends when the source turns off or
248
+ * changes; `local_transcription` is true if on-device transcription ran at
249
+ * any point during it.
250
+ */
251
+ fun observeMic(currentMic: String, transcribing: Boolean, nowMs: Long): AnalyticsEvent? {
252
+ val next = currentMic.takeIf { it.isNotBlank() }
253
+ val previous = micSource
254
+ if (next == previous) {
255
+ if (next != null && transcribing) micTranscribed = true
256
+ return null
257
+ }
258
+ val ended =
259
+ previous?.let {
260
+ AnalyticsEvent(
261
+ EVENT_MIC_SESSION,
262
+ mapOf(
263
+ "duration_ms" to (nowMs - micStartedAtMs).coerceAtLeast(0),
264
+ "mic_source" to it,
265
+ "local_transcription" to micTranscribed,
266
+ ),
267
+ )
268
+ }
269
+ micSource = next
270
+ micStartedAtMs = nowMs
271
+ micTranscribed = next != null && transcribing
272
+ return ended
273
+ }
274
+
275
+ fun wifiSetupSettled(latencyMs: Long, errorCode: String?): AnalyticsEvent =
276
+ AnalyticsEvent(EVENT_WIFI_SETUP, buildMap { putOutcome(errorCode); put("latency_ms", latencyMs) })
277
+
278
+ fun hotspotStartSettled(latencyMs: Long, errorCode: String?): AnalyticsEvent =
279
+ AnalyticsEvent(EVENT_HOTSPOT_STARTED, buildMap { putOutcome(errorCode); put("latency_ms", latencyMs) })
280
+
281
+ /**
282
+ * One event per OTA session, on `complete` or `failed`. The "from" versions are
283
+ * the glasses versions when this process first saw the session in progress.
284
+ */
285
+ fun otaStatus(
286
+ sessionId: String,
287
+ status: String,
288
+ stepType: String,
289
+ totalSteps: Int,
290
+ errorMessage: String?,
291
+ fromBuildNumber: String,
292
+ fromBesFirmwareVersion: String,
293
+ fromMtkFirmwareVersion: String,
294
+ nowMs: Long,
295
+ ): AnalyticsEvent? {
296
+ if (finishedOtaSessions.containsKey(sessionId)) return null
297
+ if (status != OTA_COMPLETE && status != OTA_FAILED) {
298
+ if (status != OTA_IDLE && !otaSessions.containsKey(sessionId)) {
299
+ otaSessions[sessionId] =
300
+ OpenOta(nowMs, fromBuildNumber, fromBesFirmwareVersion, fromMtkFirmwareVersion)
301
+ }
302
+ return null
303
+ }
304
+ finishedOtaSessions[sessionId] = true
305
+ val open = otaSessions.remove(sessionId)
306
+ val success = status == OTA_COMPLETE
307
+ return AnalyticsEvent(
308
+ EVENT_OTA_FINISHED,
309
+ buildMap {
310
+ put("success", success)
311
+ if (!success) {
312
+ stepType.takeIf { it.isNotBlank() }?.let { put("failed_step_type", it) }
313
+ sanitizedDetail(errorMessage)?.let { put("error_message", it) }
314
+ }
315
+ if (totalSteps > 0) put("total_steps", totalSteps)
316
+ open?.let { session ->
317
+ put("duration_ms", (nowMs - session.startedAtMs).coerceAtLeast(0))
318
+ session.fromBuildNumber.takeIf { it.isNotBlank() }?.let { put("from_build_number", it) }
319
+ session.fromBesFirmwareVersion.takeIf { it.isNotBlank() }?.let { put("from_bes_firmware_version", it) }
320
+ session.fromMtkFirmwareVersion.takeIf { it.isNotBlank() }?.let { put("from_mtk_firmware_version", it) }
321
+ }
322
+ },
323
+ )
324
+ }
325
+
326
+ /** At most [maxRequestTimeouts] per SDK instance, so a failure loop cannot run up event volume. */
327
+ fun requestTimedOut(operation: String): AnalyticsEvent? {
328
+ if (requestTimeouts >= maxRequestTimeouts) return null
329
+ requestTimeouts++
330
+ return AnalyticsEvent(EVENT_REQUEST_FAILED, mapOf("operation" to operation, "error_code" to "request_timeout"))
331
+ }
332
+
333
+ private fun MutableMap<String, Any>.putOutcome(errorCode: String?) {
334
+ put("success", errorCode == null)
335
+ sanitizedDetail(errorCode)?.let { put("error_code", it) }
336
+ }
337
+
338
+ companion object {
339
+ const val EVENT_PHOTO_TAKEN = "bluetooth_sdk_photo_taken"
340
+ const val EVENT_VIDEO_RECORDED = "bluetooth_sdk_video_recorded"
341
+ const val EVENT_STREAM_STARTED = "bluetooth_sdk_stream_started"
342
+ const val EVENT_STREAM_ENDED = "bluetooth_sdk_stream_ended"
343
+ const val EVENT_MIC_SESSION = "bluetooth_sdk_mic_session"
344
+ const val EVENT_WIFI_SETUP = "bluetooth_sdk_wifi_setup"
345
+ const val EVENT_HOTSPOT_STARTED = "bluetooth_sdk_hotspot_started"
346
+ const val EVENT_OTA_FINISHED = "bluetooth_sdk_ota_finished"
347
+ const val EVENT_REQUEST_FAILED = "bluetooth_sdk_request_failed"
348
+
349
+ const val MAX_REQUEST_TIMEOUTS = 100
350
+ private const val MAX_OPEN_RECORDINGS = 16
351
+ private const val MAX_OTA_SESSIONS = 16
352
+ private const val MAX_DETAIL_LENGTH = 200
353
+ private const val TRIGGER_API = "api"
354
+ private const val TRIGGER_BUTTON = "button"
355
+ /** Glasses report button-capture photo failures under this request id (ASG MediaCaptureService). */
356
+ const val LOCAL_PHOTO_REQUEST_ID = "local"
357
+ /** Button-started recordings use `local_video_<ts>_<rand>` request ids on the glasses. */
358
+ const val LOCAL_VIDEO_REQUEST_PREFIX = "local_video_"
359
+ private const val OTA_COMPLETE = "complete"
360
+ private const val OTA_FAILED = "failed"
361
+ private const val OTA_IDLE = "idle"
362
+ private val LEGACY_TERMINAL_STREAM_STATES = setOf("stopped", "reconnect_failed")
363
+ private val LIVE_STREAM_STATES = setOf("streaming", "reconnected")
364
+ private val URL_PATTERN = Regex("""[A-Za-z][A-Za-z0-9+.\-]*://\S+""")
365
+
366
+ /** Mirrors the glasses' scheme mapping in ASG StreamCommandHandler. */
367
+ fun streamProtocol(streamUrl: String): String =
368
+ when (streamUrl.trim().substringBefore("://", "").lowercase()) {
369
+ "srt" -> "srt"
370
+ "rtmp", "rtmps" -> "rtmp"
371
+ "http", "https" -> "whip"
372
+ else -> "unknown"
373
+ }
374
+
375
+ /** Free-text details may carry stream or upload URLs; those never leave the phone. */
376
+ fun sanitizedDetail(value: String?): String? =
377
+ value
378
+ ?.replace(URL_PATTERN, "<url>")
379
+ ?.trim()
380
+ ?.takeIf { it.isNotEmpty() }
381
+ ?.take(MAX_DETAIL_LENGTH)
382
+
383
+ private fun <K, V> boundedMap(capacity: Int): MutableMap<K, V> =
384
+ object : LinkedHashMap<K, V>() {
385
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry<K, V>?): Boolean = size > capacity
386
+ }
387
+ }
388
+ }
@@ -1989,8 +1989,11 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
1989
1989
  sgc?.requestSavedWifiNetworks(requestId, sid) ?: false
1990
1990
 
1991
1991
  fun sendIncidentId(incidentId: String, apiBaseUrl: String? = null) {
1992
+ val driver = sgc ?: throw BluetoothSdkException(
1993
+ "glasses_not_connected", "Cannot request incident logs because glasses are not connected.",
1994
+ )
1992
1995
  Bridge.log("MAN: Sending incidentId to glasses for log upload: $incidentId")
1993
- sgc?.sendIncidentId(incidentId, apiBaseUrl)
1996
+ driver.sendIncidentId(incidentId, apiBaseUrl)
1994
1997
  }
1995
1998
 
1996
1999
  /** Push a notification into the glasses' own notification centre; rejects unsupported or disconnected devices. */
@@ -3,8 +3,9 @@ package com.mentra.bluetoothsdk
3
3
  /** Generated from /changelogs. Do not edit directly. */
4
4
  internal val GENERATED_RELEASE_CHANGELOGS: List<ReleaseChangelog> =
5
5
  listOf(
6
+ ReleaseChangelog(version = "3.3.0", markdown = "This release is under active development. User-facing changes will be documented as they land."),
6
7
  ReleaseChangelog(version = "3.2.1", markdown = "- **Livestreamer:** \"Stream Here\" now works without a Wi-Fi network. Improved stream startup and connection reliability.\n- **Photos and gallery:** More reliable photo capture and gallery syncing.\n- **Mentra AI:** Reliability improvements and bug fixes.\n- **Miniapps:** Smoother transitions, with back buttons and gestures that take you to the previous screen inside a miniapp.\n- **Pairing and connections:** Improved G1 pairing and G2 background reconnection.\n- **Glasses updates:** Fixed repeated update checks and improved recovery after interrupted updates.\n- **Mentra Maps:** Bug fixes.\n- **NIMO:** Improved text and map layouts.\n- **Self-hosting:** Connect the Mentra App to your own self-hosted Mentra backend."),
7
- ReleaseChangelog(version = "3.2.0", markdown = "- Improved glasses connectivity and Wi-Fi setup."),
8
+ ReleaseChangelog(version = "3.2.0", markdown = "- Improved glasses connectivity and Wi-Fi setup.\n\n- Breaking Bluetooth SDK change: `forgetWifiNetwork(ssid)` now returns `WifiForgetResult` instead of `WifiStatusChangeEvent`. Migrate status-only consumers to the semantic `outcome`; the requested network remains `ssid`, while optional post-command connectivity is reported by `connected`, `currentSsid`, and `localIp`."),
8
9
  ReleaseChangelog(version = "3.1.1", markdown = "- Improved Google login on iOS.\n- Pairing no longer requires phone microphone access."),
9
10
  ReleaseChangelog(version = "3.1.0", markdown = "**Mentra App and glasses**\n\n- Improved glasses pairing and reconnection, including on iPad.\n- Improved Android background reliability for captions and miniapps.\n- Added Xingyi AR99 support.\n- More reliable photo and video capture, with improved shutter sounds and privacy-light timing. Taking photos no longer interrupts your running miniapp.\n- Added video thumbnails and smoother gallery browsing.\n- Easier Mentra Live updates through your phone, with clearer progress and better recovery from interruptions.\n\n**Miniapp updates**\n\n- **Mentra AI:** Control whether photos are included with questions. Improved visual answers and connected-app follow-ups.\n- **Captions:** Added offline transcription settings and top/bottom positioning on supported displays.\n- **Translation:** Restored all 60 languages.\n- **Mentra Maps:** Clearer directions on G1/G2, less display flickering, and improved Android offline routing.\n- **Livestreamer:** More video-quality controls and custom streaming options, plus improved startup and sharing.\n- **Merge:** Tap to expand an insight; swipe down or back to dismiss it.\n- **Mentra Notes:** Smoother back navigation.\n- **Recorder:** Fixed audio sharing."),
10
11
  )
@@ -2,10 +2,10 @@ package com.mentra.bluetoothsdk
2
2
 
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
- const val FAMILY_BASE_VERSION: String = "3.2.1"
6
- const val RELEASE_IDENTITY: String = "3.2.1"
7
- const val RELEASE_SET_ID: String = "mentra-3.2.1"
8
- const val SOURCE_COMMIT: String = "be56e1456c69f681985642f376ed3d397ec022cc"
9
- const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.578.json"
10
- const val OTA_MANIFEST_SHA256: String = "21af62269e29fd2ee6b9737745a895694bc221672b191d80795057b5a832f400"
5
+ const val FAMILY_BASE_VERSION: String = "3.3.0"
6
+ const val RELEASE_IDENTITY: String = "3.3.0-beta.954"
7
+ const val RELEASE_SET_ID: String = "mentra-3.3.0-beta.954"
8
+ const val SOURCE_COMMIT: String = "25f6b1d70ce39dfe5d454021542f1627e256775a"
9
+ const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-beta.954.json"
10
+ const val OTA_MANIFEST_SHA256: String = "03d976cb897215b97b5c40a53dabbc8356a045d1fbb57d5ad2c95bbc21203e21"
11
11
  }