@mentra/bluetooth-sdk 3.3.0-dev.821 → 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,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.
|
|
7
|
-
const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
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.
|
|
10
|
-
const val OTA_MANIFEST_SHA256: String = "
|
|
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
|
}
|