@mentra/bluetooth-sdk 0.1.11 → 0.1.12
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 +52 -3
- package/android/build.gradle +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +182 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +22 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +10 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +10 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +76 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +15 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +34 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +12 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -1
- package/build/BluetoothSdk.types.d.ts +33 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +1 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +21 -7
- package/ios/Source/BluetoothSdkDefaults.swift +27 -0
- package/ios/Source/Bridge.swift +6 -0
- package/ios/Source/DeviceManager.swift +9 -6
- package/ios/Source/MentraBluetoothSDK.swift +160 -8
- package/ios/Source/camera/CameraModels.swift +49 -1
- package/ios/Source/events/BluetoothEvents.swift +3 -0
- package/ios/Source/internal/BluetoothAvailability.swift +20 -0
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +160 -0
- package/ios/Source/sgcs/MentraLive.swift +29 -5
- package/ios/Source/sgcs/SGCManager.swift +13 -2
- package/ios/Source/types/DeviceModels.swift +5 -1
- package/package.json +1 -1
- package/plugin/build/index.d.ts +4 -0
- package/plugin/build/withAndroid.d.ts +2 -3
- package/plugin/build/withAndroid.js +46 -0
- package/plugin/build/withIos.d.ts +2 -3
- package/plugin/build/withIos.js +32 -0
- package/src/BluetoothSdk.types.ts +40 -1
- package/src/_private/BluetoothSdkModule.ts +5 -1
- package/src/index.ts +2 -0
package/README.md
CHANGED
|
@@ -150,6 +150,55 @@ uses a CoreBluetooth identifier when available, and the SDK falls back to
|
|
|
150
150
|
platform reports RSSI, so picker UI should handle `undefined` and avoid
|
|
151
151
|
reordering rows just because RSSI metadata arrives later.
|
|
152
152
|
|
|
153
|
+
## Mentra SDK Usage Analytics
|
|
154
|
+
|
|
155
|
+
The SDK sends two anonymous usage events to Mentra's PostHog project by default
|
|
156
|
+
so Mentra can understand SDK adoption and successful glasses connections:
|
|
157
|
+
|
|
158
|
+
- `bluetooth_sdk_started`: sent once per app runtime after the native SDK starts.
|
|
159
|
+
- `bluetooth_sdk_glasses_connected`: sent when SDK status transitions from not connected to connected.
|
|
160
|
+
|
|
161
|
+
Analytics delivery is fire-and-forget: events are submitted asynchronously, do
|
|
162
|
+
not block Bluetooth SDK behavior, and are not retried if delivery fails.
|
|
163
|
+
|
|
164
|
+
React Native / Expo apps can disable these events before SDK startup through
|
|
165
|
+
the config plugin:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"expo": {
|
|
170
|
+
"plugins": [
|
|
171
|
+
[
|
|
172
|
+
"@mentra/bluetooth-sdk",
|
|
173
|
+
{
|
|
174
|
+
"analytics": false
|
|
175
|
+
}
|
|
176
|
+
]
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Native Android apps can pass `BluetoothSdkAnalyticsConfig.disabled()` in
|
|
183
|
+
`MentraBluetoothSdkConfig` or add
|
|
184
|
+
`com.mentra.bluetoothsdk.analytics.disabled=true` as application metadata.
|
|
185
|
+
Native iOS apps can pass `.disabled` in `MentraBluetoothSDKConfiguration` or set
|
|
186
|
+
`MentraBluetoothSdkAnalyticsDisabled` to `true` in `Info.plist`.
|
|
187
|
+
|
|
188
|
+
Mentra's PostHog project API key is embedded in the SDK as a public analytics
|
|
189
|
+
write token, not a private PostHog personal API key. Apps do not configure the
|
|
190
|
+
analytics destination; these SDK usage events are always sent to Mentra's
|
|
191
|
+
PostHog project unless analytics are disabled.
|
|
192
|
+
|
|
193
|
+
Captured properties are limited to non-sensitive SDK/app metadata:
|
|
194
|
+
`event_source`, `sdk_platform`, `sdk_surface`, `sdk_version`, the app package or
|
|
195
|
+
bundle identifier, OS platform/version, `event_kind`, and for connection events
|
|
196
|
+
only `fully_booted` plus a glasses model value when the SDK knows it. The SDK
|
|
197
|
+
does not upload BLE MAC addresses, CoreBluetooth identifiers, serial numbers,
|
|
198
|
+
Bluetooth device names, user ids, tokens, Wi-Fi credentials, microphone data,
|
|
199
|
+
photos, or transcripts. PostHog receives a locally generated anonymous SDK
|
|
200
|
+
install id as `distinct_id`, and events include `$process_person_profile: false`.
|
|
201
|
+
|
|
153
202
|
## React Hooks
|
|
154
203
|
|
|
155
204
|
React Native apps can import optional lifecycle helpers from the `react`
|
|
@@ -274,7 +323,7 @@ React Native narrows returned values to success shapes where the raw listener ev
|
|
|
274
323
|
| --- | --- | --- |
|
|
275
324
|
| `requestPhoto(...)` | Terminal `PhotoSuccessResponseEvent` with `state: "success"` after capture and delivery finish. `uploadUrl` is always present; webhook JSON metadata such as `photoUrl`, `statusUrl`, `contentType`, or `fileSizeBytes` is included when the receiver returns it. | Rejects when raw `photo_response.state === "error"`, the SDK cannot send the command, or the terminal photo response times out. |
|
|
276
325
|
| `startVideoRecording(...)` | `VideoRecordingStartedStatusEvent` with `success: true` and `status: "recording_started"`. | Rejects on `success: false` statuses such as `already_recording`, send failure, or timeout. |
|
|
277
|
-
| `stopVideoRecording(...)` | `VideoRecordingStoppedStatusEvent` with `success: true` and `status: "recording_stopped"`. | Rejects on `success: false` statuses such as `not_recording`, send failure, or timeout. |
|
|
326
|
+
| `stopVideoRecording(...)` | `VideoRecordingStoppedStatusEvent` with `success: true` and `status: "recording_stopped"`. When a webhook URL is supplied, resolves after the video upload succeeds. | Rejects on `success: false` statuses such as `not_recording`, webhook upload failure, send failure, or timeout. |
|
|
278
327
|
| `rgbLedControl(...)` | `RgbLedControlSuccessResponseEvent` with `state: "success"`. | Rejects when raw `rgb_led_control_response.state === "error"` or the response times out. |
|
|
279
328
|
| `checkForOtaUpdate()` / `retryOtaVersionCheck()` | `OtaQueryResult`, either `ota_update_available` or current `ota_status`. | Rejects on command transport timeout/failure. A returned `ota_status.status === "failed"` is glasses OTA state, not a command rejection. |
|
|
280
329
|
|
|
@@ -332,7 +381,7 @@ For one-shot manual capture tuning, pass `exposureTimeNs` and `iso` together. `e
|
|
|
332
381
|
|
|
333
382
|
Use `setCameraFov({fov, roiPosition})` to configure Mentra Live camera field of view and crop position. FOV is clamped to 62-118 degrees; ROI position is `"center"`, `"bottom"`, or `"top"`. You can also call `setCameraFov({preset: "narrow" | "standard" | "wide"})`; presets map to 82, 102, and 118 degrees with center ROI. The returned `CameraFovResult` resolves only after the ASG client reports that the setting was applied to camera hardware after the restart cooldown, and the promise rejects if the glasses report an error, persist the setting without hardware application, or time out. Raw `settings_ack` events remain available through `addListener("settings_ack", ...)` for diagnostic fields such as `hardwareApplied`. Treat FOV as a framing/ROI control; output resolution and effective detail can vary by capture path, firmware, and camera mode.
|
|
334
383
|
|
|
335
|
-
`startVideoRecording(...)`
|
|
384
|
+
`startVideoRecording(...)` resolves from the ASG `video_recording_status` event whose status is `recording_started`. `stopVideoRecording(...)` without a webhook resolves from `recording_stopped`; with a webhook it waits for `recording_stopped` plus a video `media_success`, and rejects on `media_error`. Raw `video_recording_status`, `media_success`, and `media_error` events remain available through listeners.
|
|
336
385
|
|
|
337
386
|
## Streaming
|
|
338
387
|
|
|
@@ -409,7 +458,7 @@ For bare native iOS apps, use the public SwiftPM repository:
|
|
|
409
458
|
https://github.com/Mentra-Community/mentra-bluetooth-sdk-ios.git
|
|
410
459
|
```
|
|
411
460
|
|
|
412
|
-
Add the `MentraBluetoothSDK` product to your app target at version `0.1.
|
|
461
|
+
Add the `MentraBluetoothSDK` product to your app target at version `0.1.12` or newer.
|
|
413
462
|
|
|
414
463
|
For local SDK development, add this package folder directly in Xcode:
|
|
415
464
|
|
package/android/build.gradle
CHANGED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.pm.PackageManager
|
|
5
|
+
import android.os.Build
|
|
6
|
+
import org.json.JSONObject
|
|
7
|
+
import java.net.HttpURLConnection
|
|
8
|
+
import java.net.URL
|
|
9
|
+
import java.util.UUID
|
|
10
|
+
import java.util.concurrent.ExecutorService
|
|
11
|
+
import java.util.concurrent.Executors
|
|
12
|
+
import java.util.concurrent.RejectedExecutionException
|
|
13
|
+
|
|
14
|
+
class BluetoothSdkAnalyticsConfig private constructor(
|
|
15
|
+
val enabled: Boolean,
|
|
16
|
+
internal val surface: String,
|
|
17
|
+
) {
|
|
18
|
+
@JvmOverloads
|
|
19
|
+
constructor(enabled: Boolean = true) : this(enabled, "android")
|
|
20
|
+
|
|
21
|
+
internal fun withSurface(surface: String): BluetoothSdkAnalyticsConfig =
|
|
22
|
+
BluetoothSdkAnalyticsConfig(enabled, surface)
|
|
23
|
+
|
|
24
|
+
companion object {
|
|
25
|
+
@JvmStatic
|
|
26
|
+
fun disabled(): BluetoothSdkAnalyticsConfig = BluetoothSdkAnalyticsConfig(enabled = false)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private data class BluetoothSdkAnalyticsRuntimeConfig(
|
|
31
|
+
val enabled: Boolean = true,
|
|
32
|
+
val surface: String = "android",
|
|
33
|
+
) {
|
|
34
|
+
val isReady: Boolean
|
|
35
|
+
get() = enabled
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
internal class BluetoothSdkAnalytics(
|
|
39
|
+
private val context: Context,
|
|
40
|
+
initialConfig: BluetoothSdkAnalyticsConfig,
|
|
41
|
+
) {
|
|
42
|
+
private val appContext = context.applicationContext
|
|
43
|
+
private val executor: ExecutorService = Executors.newSingleThreadExecutor { runnable ->
|
|
44
|
+
Thread(runnable, "MentraBluetoothSdkAnalytics").apply { isDaemon = true }
|
|
45
|
+
}
|
|
46
|
+
// startedCaptured/lastConnected are touched from store listeners and SDK
|
|
47
|
+
// entry points, hence @Synchronized on methods that read or write them.
|
|
48
|
+
private val config = initialConfig.toRuntimeConfig().resolvedForApp(appContext)
|
|
49
|
+
private var startedCaptured = false
|
|
50
|
+
private var lastConnected = false
|
|
51
|
+
|
|
52
|
+
@Synchronized
|
|
53
|
+
fun initializeGlassesStatus(status: GlassesStatus) {
|
|
54
|
+
lastConnected = status.analyticsConnected
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Synchronized
|
|
58
|
+
fun captureStarted() {
|
|
59
|
+
if (startedCaptured || !config.isReady) return
|
|
60
|
+
startedCaptured = true
|
|
61
|
+
capture("bluetooth_sdk_started", mapOf("event_kind" to "sdk_started"))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@Synchronized
|
|
65
|
+
fun observeGlassesStatus(status: GlassesStatus) {
|
|
66
|
+
val isConnected = status.analyticsConnected
|
|
67
|
+
val wasConnected = lastConnected
|
|
68
|
+
lastConnected = isConnected
|
|
69
|
+
if (!config.isReady) return
|
|
70
|
+
if (isConnected && !wasConnected) {
|
|
71
|
+
capture(
|
|
72
|
+
"bluetooth_sdk_glasses_connected",
|
|
73
|
+
buildMap {
|
|
74
|
+
put("event_kind", "glasses_connected")
|
|
75
|
+
put("fully_booted", status.fullyBooted)
|
|
76
|
+
status.deviceModel.takeIf { it.isNotBlank() }?.let { put("glasses_model", it) }
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fun shutdown() {
|
|
83
|
+
executor.shutdown()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private fun capture(
|
|
87
|
+
eventName: String,
|
|
88
|
+
eventProperties: Map<String, Any>,
|
|
89
|
+
) {
|
|
90
|
+
val activeConfig = config
|
|
91
|
+
if (!activeConfig.isReady) return
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// Payload construction stays on the executor: distinctId() reads
|
|
95
|
+
// SharedPreferences, which must not run on the caller (often main) thread.
|
|
96
|
+
executor.execute {
|
|
97
|
+
try {
|
|
98
|
+
val payload =
|
|
99
|
+
JSONObject(
|
|
100
|
+
mapOf(
|
|
101
|
+
"api_key" to DEFAULT_POSTHOG_API_KEY,
|
|
102
|
+
"event" to eventName,
|
|
103
|
+
"distinct_id" to distinctId(),
|
|
104
|
+
"properties" to baseProperties(activeConfig) + eventProperties,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
val connection = URL(captureUrl()).openConnection() as HttpURLConnection
|
|
108
|
+
connection.requestMethod = "POST"
|
|
109
|
+
connection.connectTimeout = 4_000
|
|
110
|
+
connection.readTimeout = 4_000
|
|
111
|
+
connection.doOutput = true
|
|
112
|
+
connection.setRequestProperty("Content-Type", "application/json")
|
|
113
|
+
connection.outputStream.use { output ->
|
|
114
|
+
output.write(payload.toString().toByteArray(Charsets.UTF_8))
|
|
115
|
+
}
|
|
116
|
+
connection.inputStream.close()
|
|
117
|
+
connection.disconnect()
|
|
118
|
+
} catch (_: Exception) {
|
|
119
|
+
// Analytics must never affect Bluetooth SDK behavior.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch (_: RejectedExecutionException) {
|
|
123
|
+
// The executor was shut down (SDK closed); dropping the event is fine.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private fun baseProperties(activeConfig: BluetoothSdkAnalyticsRuntimeConfig): Map<String, Any> =
|
|
128
|
+
buildMap {
|
|
129
|
+
put("\$process_person_profile", false)
|
|
130
|
+
put("event_source", "mentra_bluetooth_sdk")
|
|
131
|
+
put("sdk_platform", "android")
|
|
132
|
+
put("sdk_surface", activeConfig.surface)
|
|
133
|
+
put("sdk_version", BuildConfig.SDK_VERSION)
|
|
134
|
+
put("app_package", appContext.packageName)
|
|
135
|
+
put("os_platform", "android")
|
|
136
|
+
put("os_version", Build.VERSION.SDK_INT)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private fun distinctId(): String {
|
|
140
|
+
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
141
|
+
prefs.getString(PREFS_DISTINCT_ID, null)?.let { return it }
|
|
142
|
+
val generated = "mentra-bt-sdk-${UUID.randomUUID()}"
|
|
143
|
+
prefs.edit().putString(PREFS_DISTINCT_ID, generated).apply()
|
|
144
|
+
return generated
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private fun captureUrl(): String {
|
|
148
|
+
val normalized = DEFAULT_POSTHOG_HOST.trim().trimEnd('/')
|
|
149
|
+
return "$normalized/i/v0/e/"
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
companion object {
|
|
153
|
+
internal const val META_ANALYTICS_DISABLED = "com.mentra.bluetoothsdk.analytics.disabled"
|
|
154
|
+
private const val PREFS_NAME = "mentra_bluetooth_sdk_analytics"
|
|
155
|
+
private const val PREFS_DISTINCT_ID = "distinct_id"
|
|
156
|
+
private const val DEFAULT_POSTHOG_API_KEY = "phc_FCweXVAxVgU7wZK4Fk3okOx4RmyNqVHJf62YpZSfJt5"
|
|
157
|
+
private const val DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private fun BluetoothSdkAnalyticsConfig.toRuntimeConfig(): BluetoothSdkAnalyticsRuntimeConfig =
|
|
162
|
+
BluetoothSdkAnalyticsRuntimeConfig(enabled = enabled, surface = surface)
|
|
163
|
+
|
|
164
|
+
private fun BluetoothSdkAnalyticsRuntimeConfig.resolvedForApp(context: Context): BluetoothSdkAnalyticsRuntimeConfig {
|
|
165
|
+
val metadata =
|
|
166
|
+
try {
|
|
167
|
+
context.packageManager
|
|
168
|
+
.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA)
|
|
169
|
+
.metaData
|
|
170
|
+
} catch (_: Exception) {
|
|
171
|
+
null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
val disabledByApp = metadata?.getBoolean(BluetoothSdkAnalytics.META_ANALYTICS_DISABLED, false) == true
|
|
175
|
+
|
|
176
|
+
return copy(
|
|
177
|
+
enabled = enabled && !disabledByApp,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private val GlassesStatus.analyticsConnected: Boolean
|
|
182
|
+
get() = connectionState.isConnected || connected || fullyBooted
|
|
@@ -120,6 +120,10 @@ class BluetoothSdkModule : Module() {
|
|
|
120
120
|
sendEvent("video_recording_status", event.values)
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
override fun onMediaUpload(event: MediaUploadEvent) {
|
|
124
|
+
sendEvent(event.type, event.values)
|
|
125
|
+
}
|
|
126
|
+
|
|
123
127
|
override fun onRgbLedControlResponse(event: RgbLedControlResponseEvent) {
|
|
124
128
|
sendEvent("rgb_led_control_response", event.values)
|
|
125
129
|
}
|
|
@@ -213,6 +217,8 @@ class BluetoothSdkModule : Module() {
|
|
|
213
217
|
"photo_response",
|
|
214
218
|
"photo_status",
|
|
215
219
|
"video_recording_status",
|
|
220
|
+
"media_success",
|
|
221
|
+
"media_error",
|
|
216
222
|
"gallery_status",
|
|
217
223
|
"compatible_glasses_search_stop",
|
|
218
224
|
"heartbeat_sent",
|
|
@@ -256,7 +262,14 @@ class BluetoothSdkModule : Module() {
|
|
|
256
262
|
?: appContext.currentActivity
|
|
257
263
|
?: throw IllegalStateException("No context available")
|
|
258
264
|
BleTraceLogger.logLifecycle(context, "BluetoothSdkModule", "module_create")
|
|
259
|
-
sdk =
|
|
265
|
+
sdk =
|
|
266
|
+
MentraBluetoothSdk.create(
|
|
267
|
+
context,
|
|
268
|
+
MentraBluetoothSdkConfig(
|
|
269
|
+
analytics = BluetoothSdkAnalyticsConfig().withSurface("react_native")
|
|
270
|
+
),
|
|
271
|
+
sdkListener,
|
|
272
|
+
)
|
|
260
273
|
deviceManager = DeviceManager.getInstance()
|
|
261
274
|
}
|
|
262
275
|
|
|
@@ -505,12 +518,18 @@ class BluetoothSdkModule : Module() {
|
|
|
505
518
|
dim("width"),
|
|
506
519
|
dim("height"),
|
|
507
520
|
dim("fps"),
|
|
521
|
+
dim("maxRecordingTimeMinutes"),
|
|
508
522
|
)
|
|
509
523
|
).values
|
|
510
524
|
}
|
|
511
525
|
|
|
512
|
-
|
|
513
|
-
|
|
526
|
+
// webhookUrl/authToken are supplied at stop (not start) so the token is
|
|
527
|
+
// fresh when the upload runs. Empty/null webhook = keep on device.
|
|
528
|
+
AsyncFunction("stopVideoRecording") {
|
|
529
|
+
requestId: String,
|
|
530
|
+
webhookUrl: String?,
|
|
531
|
+
authToken: String? ->
|
|
532
|
+
requireSdk().stopVideoRecording(requestId, webhookUrl, authToken).values
|
|
514
533
|
}
|
|
515
534
|
|
|
516
535
|
// MARK: - Stream Commands
|
|
@@ -417,6 +417,16 @@ public class Bridge private constructor() {
|
|
|
417
417
|
sendTypedMessage("video_recording_status", body)
|
|
418
418
|
}
|
|
419
419
|
|
|
420
|
+
@JvmStatic
|
|
421
|
+
fun sendMediaUploadEvent(type: String, values: Map<String, Any>) {
|
|
422
|
+
val body = HashMap<String, Any>()
|
|
423
|
+
body["type"] = type
|
|
424
|
+
values.forEach { (key, value) ->
|
|
425
|
+
body[key] = value
|
|
426
|
+
}
|
|
427
|
+
sendTypedMessage(type, body)
|
|
428
|
+
}
|
|
429
|
+
|
|
420
430
|
@JvmStatic
|
|
421
431
|
fun sendVersionInfo(values: Map<String, Any>) {
|
|
422
432
|
fun stringField(vararg keys: String): String =
|
|
@@ -1505,17 +1505,22 @@ class DeviceManager {
|
|
|
1505
1505
|
width: Int = 0,
|
|
1506
1506
|
height: Int = 0,
|
|
1507
1507
|
fps: Int = 0,
|
|
1508
|
+
maxRecordingTimeMinutes: Int = 0,
|
|
1508
1509
|
) {
|
|
1509
1510
|
Bridge.log(
|
|
1510
1511
|
"MAN: onStartVideoRecording: requestId=$requestId, save=$save, flash=true, sound=$sound, " +
|
|
1511
|
-
"resolution=${width}x${height}@${fps}fps"
|
|
1512
|
+
"resolution=${width}x${height}@${fps}fps, maxRecordingTimeMinutes=$maxRecordingTimeMinutes"
|
|
1512
1513
|
)
|
|
1513
|
-
sgc?.startVideoRecording(
|
|
1514
|
+
sgc?.startVideoRecording(
|
|
1515
|
+
requestId, save, true, sound, width, height, fps, maxRecordingTimeMinutes)
|
|
1514
1516
|
}
|
|
1515
1517
|
|
|
1516
|
-
fun stopVideoRecording(requestId: String) {
|
|
1517
|
-
Bridge.log(
|
|
1518
|
-
|
|
1518
|
+
fun stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
|
|
1519
|
+
Bridge.log(
|
|
1520
|
+
"MAN: onStopVideoRecording: requestId=$requestId, webhook=" +
|
|
1521
|
+
if (webhookUrl.isNullOrEmpty()) "none" else "set"
|
|
1522
|
+
)
|
|
1523
|
+
sgc?.stopVideoRecording(requestId, webhookUrl, authToken)
|
|
1519
1524
|
}
|
|
1520
1525
|
|
|
1521
1526
|
fun setMicState() {
|
|
@@ -23,6 +23,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
23
23
|
private val deviceManager: DeviceManager
|
|
24
24
|
private val listeners =
|
|
25
25
|
Collections.synchronizedSet(mutableSetOf<MentraBluetoothSdkListener>())
|
|
26
|
+
private val analytics = BluetoothSdkAnalytics(appContext, config.analytics)
|
|
26
27
|
private val discoveredDeviceNames = mutableSetOf<String>()
|
|
27
28
|
private val bridgeEventSinkId: String
|
|
28
29
|
private val storeListenerId: String
|
|
@@ -50,7 +51,13 @@ class MentraBluetoothSdk private constructor(
|
|
|
50
51
|
Bridge.initialize(appContext)
|
|
51
52
|
deviceManager = DeviceManager.getInstance()
|
|
52
53
|
bridgeEventSinkId = Bridge.addEventSink { eventName, data -> dispatchBridgeEvent(eventName, data) }
|
|
54
|
+
// Baseline the analytics connection state before subscribing to the store:
|
|
55
|
+
// store updates invoke listeners synchronously on the updating thread, so a
|
|
56
|
+
// connected status observed before the baseline would be reported as a fresh
|
|
57
|
+
// bluetooth_sdk_glasses_connected transition.
|
|
58
|
+
analytics.initializeGlassesStatus(getRawGlassesStatus())
|
|
53
59
|
storeListenerId = DeviceStore.store.addListener { category, changes -> dispatchStoreUpdate(category, changes) }
|
|
60
|
+
analytics.captureStarted()
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
companion object {
|
|
@@ -58,6 +65,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
58
65
|
private val SCAN_STATE_KEYS = setOf("searching", "searchingController", "searchResults")
|
|
59
66
|
private const val DEFAULT_SCAN_TIMEOUT_MS = 15_000L
|
|
60
67
|
private const val DEFAULT_REQUEST_TIMEOUT_MS = 15_000L
|
|
68
|
+
private const val VIDEO_UPLOAD_STOP_TIMEOUT_MS = 10 * 60 * 1000L
|
|
61
69
|
private const val STREAM_START_TIMEOUT_MS = 30_000L
|
|
62
70
|
private const val STREAM_STOP_TIMEOUT_MS = 15_000L
|
|
63
71
|
private const val DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS = 5
|
|
@@ -97,6 +105,9 @@ class MentraBluetoothSdk private constructor(
|
|
|
97
105
|
private data class PendingVideoRecordingRequest(
|
|
98
106
|
val expectedStatus: String,
|
|
99
107
|
val pending: PendingResponse<VideoRecordingStatusEvent>,
|
|
108
|
+
val waitForUpload: Boolean = false,
|
|
109
|
+
var stoppedEvent: VideoRecordingStatusEvent? = null,
|
|
110
|
+
var uploadSucceeded: Boolean = false,
|
|
100
111
|
)
|
|
101
112
|
|
|
102
113
|
private data class PendingWifiScan(
|
|
@@ -184,6 +195,15 @@ class MentraBluetoothSdk private constructor(
|
|
|
184
195
|
|
|
185
196
|
fun getDefaultDevice(): Device? = currentDefaultDevice()
|
|
186
197
|
|
|
198
|
+
private fun requireGlassesConnected(operation: String) {
|
|
199
|
+
if (!getRawGlassesStatus().connected) {
|
|
200
|
+
throw BluetoothException(
|
|
201
|
+
"glasses_not_connected",
|
|
202
|
+
"Cannot $operation because glasses are not connected.",
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
187
207
|
fun setDefaultDevice(device: Device?) {
|
|
188
208
|
if (device == null) {
|
|
189
209
|
clearDefaultDevice()
|
|
@@ -771,6 +791,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
771
791
|
|
|
772
792
|
fun startVideoRecording(request: VideoRecordingRequest): VideoRecordingStatusEvent {
|
|
773
793
|
require(request.requestId.isNotBlank()) { "requestId is required to start video recording." }
|
|
794
|
+
requireGlassesConnected("start video recording")
|
|
774
795
|
val pending = PendingResponse<VideoRecordingStatusEvent>("start video recording")
|
|
775
796
|
val pendingRequest = PendingVideoRecordingRequest("recording_started", pending)
|
|
776
797
|
if (pendingVideoRecordingRequests.putIfAbsent(request.requestId, pendingRequest) != null) {
|
|
@@ -787,6 +808,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
787
808
|
request.width,
|
|
788
809
|
request.height,
|
|
789
810
|
request.fps,
|
|
811
|
+
request.maxRecordingTimeMinutes,
|
|
790
812
|
)
|
|
791
813
|
return pending.await()
|
|
792
814
|
} finally {
|
|
@@ -794,10 +816,22 @@ class MentraBluetoothSdk private constructor(
|
|
|
794
816
|
}
|
|
795
817
|
}
|
|
796
818
|
|
|
797
|
-
|
|
819
|
+
@JvmOverloads
|
|
820
|
+
fun stopVideoRecording(
|
|
821
|
+
requestId: String,
|
|
822
|
+
webhookUrl: String? = null,
|
|
823
|
+
authToken: String? = null,
|
|
824
|
+
): VideoRecordingStatusEvent {
|
|
798
825
|
require(requestId.isNotBlank()) { "requestId is required to stop video recording." }
|
|
826
|
+
requireGlassesConnected("stop video recording")
|
|
799
827
|
val pending = PendingResponse<VideoRecordingStatusEvent>("stop video recording")
|
|
800
|
-
val
|
|
828
|
+
val waitForUpload = !webhookUrl.isNullOrBlank()
|
|
829
|
+
val pendingRequest =
|
|
830
|
+
PendingVideoRecordingRequest(
|
|
831
|
+
expectedStatus = "recording_stopped",
|
|
832
|
+
pending = pending,
|
|
833
|
+
waitForUpload = waitForUpload,
|
|
834
|
+
)
|
|
801
835
|
if (pendingVideoRecordingRequests.putIfAbsent(requestId, pendingRequest) != null) {
|
|
802
836
|
throw BluetoothException(
|
|
803
837
|
"request_in_flight",
|
|
@@ -805,8 +839,10 @@ class MentraBluetoothSdk private constructor(
|
|
|
805
839
|
)
|
|
806
840
|
}
|
|
807
841
|
try {
|
|
808
|
-
deviceManager.stopVideoRecording(requestId)
|
|
809
|
-
|
|
842
|
+
deviceManager.stopVideoRecording(requestId, webhookUrl, authToken)
|
|
843
|
+
val timeoutMs =
|
|
844
|
+
if (waitForUpload) VIDEO_UPLOAD_STOP_TIMEOUT_MS else DEFAULT_REQUEST_TIMEOUT_MS
|
|
845
|
+
return pending.await(timeoutMs)
|
|
810
846
|
} finally {
|
|
811
847
|
pendingVideoRecordingRequests.remove(requestId, pendingRequest)
|
|
812
848
|
}
|
|
@@ -917,12 +953,14 @@ class MentraBluetoothSdk private constructor(
|
|
|
917
953
|
stopStreamKeepAliveMonitor()
|
|
918
954
|
Bridge.removeEventSink(bridgeEventSinkId)
|
|
919
955
|
DeviceStore.store.removeListener(storeListenerId)
|
|
956
|
+
analytics.shutdown()
|
|
920
957
|
listeners.clear()
|
|
921
958
|
}
|
|
922
959
|
|
|
923
960
|
private fun dispatchStoreUpdate(category: String, changes: Map<String, Any>) {
|
|
924
961
|
when (ObservableStore.normalizeCategory(category)) {
|
|
925
962
|
"glasses" -> {
|
|
963
|
+
analytics.observeGlassesStatus(getRawGlassesStatus())
|
|
926
964
|
val state = getState()
|
|
927
965
|
dispatchToListeners {
|
|
928
966
|
it.onStateChanged(state)
|
|
@@ -1112,6 +1150,11 @@ class MentraBluetoothSdk private constructor(
|
|
|
1112
1150
|
handleVideoRecordingStatusForRequests(event)
|
|
1113
1151
|
dispatchToListeners { it.onVideoRecordingStatus(event) }
|
|
1114
1152
|
}
|
|
1153
|
+
"media_success", "media_error" -> {
|
|
1154
|
+
val event = MediaUploadEvent(data)
|
|
1155
|
+
handleMediaUploadForRequests(event)
|
|
1156
|
+
dispatchToListeners { it.onMediaUpload(event) }
|
|
1157
|
+
}
|
|
1115
1158
|
"rgb_led_control_response" -> {
|
|
1116
1159
|
val event = RgbLedControlResponseEvent(data)
|
|
1117
1160
|
handleRgbLedResponseForRequests(event)
|
|
@@ -1341,7 +1384,14 @@ class MentraBluetoothSdk private constructor(
|
|
|
1341
1384
|
val request = pendingVideoRecordingRequests[event.requestId] ?: return
|
|
1342
1385
|
if (event.success) {
|
|
1343
1386
|
if (event.status == request.expectedStatus) {
|
|
1344
|
-
request.
|
|
1387
|
+
if (request.waitForUpload) {
|
|
1388
|
+
request.stoppedEvent = event
|
|
1389
|
+
if (request.uploadSucceeded) {
|
|
1390
|
+
request.pending.resolve(event)
|
|
1391
|
+
}
|
|
1392
|
+
} else {
|
|
1393
|
+
request.pending.resolve(event)
|
|
1394
|
+
}
|
|
1345
1395
|
}
|
|
1346
1396
|
} else {
|
|
1347
1397
|
request.pending.reject(
|
|
@@ -1353,6 +1403,27 @@ class MentraBluetoothSdk private constructor(
|
|
|
1353
1403
|
}
|
|
1354
1404
|
}
|
|
1355
1405
|
|
|
1406
|
+
private fun handleMediaUploadForRequests(event: MediaUploadEvent) {
|
|
1407
|
+
if (!event.isVideo) return
|
|
1408
|
+
val request = pendingVideoRecordingRequests[event.requestId] ?: return
|
|
1409
|
+
if (!request.waitForUpload) return
|
|
1410
|
+
if (event.isSuccess) {
|
|
1411
|
+
val stoppedEvent = request.stoppedEvent
|
|
1412
|
+
if (stoppedEvent != null) {
|
|
1413
|
+
request.pending.resolve(stoppedEvent)
|
|
1414
|
+
} else {
|
|
1415
|
+
request.uploadSucceeded = true
|
|
1416
|
+
}
|
|
1417
|
+
} else {
|
|
1418
|
+
request.pending.reject(
|
|
1419
|
+
BluetoothException(
|
|
1420
|
+
"video_upload_failed",
|
|
1421
|
+
event.errorMessage ?: "Video upload failed.",
|
|
1422
|
+
)
|
|
1423
|
+
)
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1356
1427
|
private fun handleRgbLedResponseForRequests(event: RgbLedControlResponseEvent) {
|
|
1357
1428
|
val pending = pendingRgbLedRequests[event.requestId] ?: return
|
|
1358
1429
|
if (event.state == "success") {
|
|
@@ -227,6 +227,8 @@ data class VideoRecordingRequest(
|
|
|
227
227
|
val width: Int = 0,
|
|
228
228
|
val height: Int = 0,
|
|
229
229
|
val fps: Int = 0,
|
|
230
|
+
// Optional auto-stop timer in minutes; 0 = record until stopped/interrupted.
|
|
231
|
+
val maxRecordingTimeMinutes: Int = 0,
|
|
230
232
|
)
|
|
231
233
|
|
|
232
234
|
data class VideoRecordingStatusEvent(
|
|
@@ -240,6 +242,19 @@ data class VideoRecordingStatusEvent(
|
|
|
240
242
|
val data: Map<String, Any>? get() = stringMapValue(values["data"])
|
|
241
243
|
}
|
|
242
244
|
|
|
245
|
+
data class MediaUploadEvent(
|
|
246
|
+
val values: Map<String, Any>,
|
|
247
|
+
) {
|
|
248
|
+
val type: String get() = stringValue(values, "type").orEmpty()
|
|
249
|
+
val requestId: String get() = stringValue(values, "requestId").orEmpty()
|
|
250
|
+
val mediaUrl: String? get() = stringValue(values, "mediaUrl")
|
|
251
|
+
val errorMessage: String? get() = stringValue(values, "errorMessage")
|
|
252
|
+
val mediaType: Int? get() = numberValue(values, "mediaType")
|
|
253
|
+
val timestamp: Long get() = longValue(values, "timestamp") ?: System.currentTimeMillis()
|
|
254
|
+
val isSuccess: Boolean get() = type == "media_success"
|
|
255
|
+
val isVideo: Boolean get() = mediaType == 2
|
|
256
|
+
}
|
|
257
|
+
|
|
243
258
|
data class GalleryStatusEvent(
|
|
244
259
|
val values: Map<String, Any>,
|
|
245
260
|
)
|
|
@@ -154,6 +154,7 @@ interface MentraBluetoothSdkListener {
|
|
|
154
154
|
fun onPhotoResponse(event: PhotoResponseEvent) {}
|
|
155
155
|
fun onPhotoStatus(event: PhotoStatusEvent) {}
|
|
156
156
|
fun onVideoRecordingStatus(event: VideoRecordingStatusEvent) {}
|
|
157
|
+
fun onMediaUpload(event: MediaUploadEvent) {}
|
|
157
158
|
fun onRgbLedControlResponse(event: RgbLedControlResponseEvent) {}
|
|
158
159
|
fun onStreamStatus(event: StreamStatusEvent) {}
|
|
159
160
|
fun onKeepAliveAck(event: KeepAliveAckEvent) {}
|