@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.
Files changed (42) hide show
  1. package/README.md +52 -3
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +182 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +22 -3
  5. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +10 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +10 -5
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +76 -5
  8. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +15 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +34 -5
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +12 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -1
  13. package/build/BluetoothSdk.types.d.ts +33 -1
  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 +1 -1
  17. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  18. package/build/_private/BluetoothSdkModule.js.map +1 -1
  19. package/build/index.d.ts.map +1 -1
  20. package/build/index.js +2 -0
  21. package/build/index.js.map +1 -1
  22. package/ios/BluetoothSdkModule.swift +21 -7
  23. package/ios/Source/BluetoothSdkDefaults.swift +27 -0
  24. package/ios/Source/Bridge.swift +6 -0
  25. package/ios/Source/DeviceManager.swift +9 -6
  26. package/ios/Source/MentraBluetoothSDK.swift +160 -8
  27. package/ios/Source/camera/CameraModels.swift +49 -1
  28. package/ios/Source/events/BluetoothEvents.swift +3 -0
  29. package/ios/Source/internal/BluetoothAvailability.swift +20 -0
  30. package/ios/Source/internal/BluetoothSdkAnalytics.swift +160 -0
  31. package/ios/Source/sgcs/MentraLive.swift +29 -5
  32. package/ios/Source/sgcs/SGCManager.swift +13 -2
  33. package/ios/Source/types/DeviceModels.swift +5 -1
  34. package/package.json +1 -1
  35. package/plugin/build/index.d.ts +4 -0
  36. package/plugin/build/withAndroid.d.ts +2 -3
  37. package/plugin/build/withAndroid.js +46 -0
  38. package/plugin/build/withIos.d.ts +2 -3
  39. package/plugin/build/withIos.js +32 -0
  40. package/src/BluetoothSdk.types.ts +40 -1
  41. package/src/_private/BluetoothSdkModule.ts +5 -1
  42. 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(...)` and `stopVideoRecording(...)` resolve from successful ASG `video_recording_status` events only when the status matches the requested operation (`recording_started` or `recording_stopped`) and reject when the ASG reports `success: false`. Raw `video_recording_status` events remain available through listeners, including `recording_status` query events with `data.recording`.
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.11` or newer.
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
 
@@ -213,6 +213,7 @@ android {
213
213
  defaultConfig {
214
214
  versionCode 1
215
215
  versionName project.version
216
+ buildConfigField "String", "SDK_VERSION", "\"${project.version}\""
216
217
  }
217
218
  publishing {
218
219
  singleVariant("release") {
@@ -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 = MentraBluetoothSdk.create(context, sdkListener)
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
- AsyncFunction("stopVideoRecording") { requestId: String ->
513
- requireSdk().stopVideoRecording(requestId).values
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(requestId, save, true, sound, width, height, fps)
1514
+ sgc?.startVideoRecording(
1515
+ requestId, save, true, sound, width, height, fps, maxRecordingTimeMinutes)
1514
1516
  }
1515
1517
 
1516
- fun stopVideoRecording(requestId: String) {
1517
- Bridge.log("MAN: onStopVideoRecording: requestId=$requestId")
1518
- sgc?.stopVideoRecording(requestId)
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
- fun stopVideoRecording(requestId: String): VideoRecordingStatusEvent {
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 pendingRequest = PendingVideoRecordingRequest("recording_stopped", pending)
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
- return pending.await()
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.pending.resolve(event)
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) {}