@mentra/bluetooth-sdk 3.3.0-dev.822 → 3.3.0-dev.825

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +68 -6
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +1 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  9. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +223 -36
  10. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  11. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  12. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  13. package/build/BluetoothSdk.types.d.ts +3 -0
  14. package/build/BluetoothSdk.types.d.ts.map +1 -1
  15. package/build/BluetoothSdk.types.js.map +1 -1
  16. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  17. package/build/_private/BluetoothSdkModule.js +2 -1
  18. package/build/_private/BluetoothSdkModule.js.map +1 -1
  19. package/build/_private/nativeLogConsole.d.ts +19 -0
  20. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  21. package/build/_private/nativeLogConsole.js +12 -0
  22. package/build/_private/nativeLogConsole.js.map +1 -1
  23. package/build/generated/releaseMetadata.js +5 -5
  24. package/build/generated/releaseMetadata.js.map +1 -1
  25. package/ios/BluetoothSdkModule.swift +1 -0
  26. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  27. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  28. package/ios/Source/MentraBluetoothSDK.swift +247 -48
  29. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  30. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  31. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  32. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  33. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  34. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  35. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  36. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  37. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  38. package/package.json +2 -2
  39. package/src/BluetoothSdk.types.ts +1 -0
  40. package/src/_private/BluetoothSdkModule.ts +2 -1
  41. package/src/_private/nativeLogConsole.ts +19 -0
  42. package/src/generated/releaseMetadata.ts +5 -5
@@ -0,0 +1,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
+ }
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
5
  const val FAMILY_BASE_VERSION: String = "3.3.0"
6
- const val RELEASE_IDENTITY: String = "3.3.0-dev.822"
7
- const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.822"
8
- const val SOURCE_COMMIT: String = "a25d68b92eb4940b747ef237ae78d4a03e9e3019"
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-dev.822.json"
10
- const val OTA_MANIFEST_SHA256: String = "205896bf98e9ab1dc8d9bf61bdabbb927a909d65ef79b0032cdc14115223cf69"
6
+ const val RELEASE_IDENTITY: String = "3.3.0-dev.825"
7
+ const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.825"
8
+ const val SOURCE_COMMIT: String = "bfe3ee5bcc8f6fbea3227d2842cdab3d9189680a"
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-dev.825.json"
10
+ const val OTA_MANIFEST_SHA256: String = "873a8f4d6fb2e63bb190ccff76eb3fc0bd70e1eb8d31e9e056307e353ec6324a"
11
11
  }