@mentra/bluetooth-sdk 0.1.11 → 0.1.13

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 (80) hide show
  1. package/README.md +67 -11
  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 +73 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +10 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +83 -26
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +266 -31
  9. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdkDebug.kt +14 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/ObservableStore.kt +20 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +153 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +93 -10
  13. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +3 -14
  14. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +3 -6
  15. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/services/PhoneMic.kt +90 -4
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +3835 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +20 -23
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/{Mach1.java → Mach1.kt} +517 -560
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +8712 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +11 -1
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +15 -13
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +7 -14
  24. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -1
  25. package/build/BluetoothSdk.types.d.ts +72 -10
  26. package/build/BluetoothSdk.types.d.ts.map +1 -1
  27. package/build/BluetoothSdk.types.js.map +1 -1
  28. package/build/_private/BluetoothSdkModule.d.ts +9 -7
  29. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  30. package/build/_private/BluetoothSdkModule.js +0 -2
  31. package/build/_private/BluetoothSdkModule.js.map +1 -1
  32. package/build/_private/photoRequestPayload.d.ts +3 -1
  33. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  34. package/build/_private/photoRequestPayload.js +43 -1
  35. package/build/_private/photoRequestPayload.js.map +1 -1
  36. package/build/debug.d.ts +3 -0
  37. package/build/debug.d.ts.map +1 -0
  38. package/build/debug.js +8 -0
  39. package/build/debug.js.map +1 -0
  40. package/build/index.d.ts +1 -1
  41. package/build/index.d.ts.map +1 -1
  42. package/build/index.js +16 -5
  43. package/build/index.js.map +1 -1
  44. package/ios/BluetoothSdkModule.swift +59 -64
  45. package/ios/Source/BluetoothSdkDefaults.swift +47 -0
  46. package/ios/Source/Bridge.swift +6 -0
  47. package/ios/Source/DeviceManager.swift +46 -32
  48. package/ios/Source/DeviceStore.swift +5 -1
  49. package/ios/Source/MentraBluetoothSDK.swift +357 -34
  50. package/ios/Source/MentraBluetoothSDKDebug.swift +12 -0
  51. package/ios/Source/ObservableStore.swift +11 -0
  52. package/ios/Source/OtaManifest.swift +170 -0
  53. package/ios/Source/camera/CameraModels.swift +267 -9
  54. package/ios/Source/controllers/ControllerManager.swift +2 -5
  55. package/ios/Source/controllers/R1.swift +2 -5
  56. package/ios/Source/events/BluetoothEvents.swift +3 -0
  57. package/ios/Source/internal/BluetoothAvailability.swift +20 -0
  58. package/ios/Source/internal/BluetoothSdkAnalytics.swift +160 -0
  59. package/ios/Source/sgcs/G1.swift +6 -5
  60. package/ios/Source/sgcs/G2.swift +20 -18
  61. package/ios/Source/sgcs/Mach1.swift +6 -5
  62. package/ios/Source/sgcs/MentraLive.swift +158 -38
  63. package/ios/Source/sgcs/MentraNex.swift +6 -5
  64. package/ios/Source/sgcs/SGCManager.swift +16 -7
  65. package/ios/Source/sgcs/Simulated.swift +7 -3
  66. package/ios/Source/status/DeviceStatus.swift +1 -1
  67. package/ios/Source/types/DeviceModels.swift +5 -1
  68. package/package.json +6 -1
  69. package/plugin/build/index.d.ts +4 -0
  70. package/plugin/build/withAndroid.d.ts +2 -3
  71. package/plugin/build/withAndroid.js +46 -0
  72. package/plugin/build/withIos.d.ts +2 -3
  73. package/plugin/build/withIos.js +32 -0
  74. package/src/BluetoothSdk.types.ts +80 -10
  75. package/src/_private/BluetoothSdkModule.ts +13 -10
  76. package/src/_private/photoRequestPayload.ts +45 -2
  77. package/src/debug.ts +9 -0
  78. package/src/index.ts +25 -6
  79. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +0 -3974
  80. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +0 -7405
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,9 +323,9 @@ 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
- | `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. |
328
+ | `checkForOtaUpdate()` | `boolean`, true when the configured OTA manifest has an ASG APK, MTK, or BES update for the connected glasses; false only when the manifest was checked successfully and no update is available. | Rejects when the glasses are disconnected, version info is unavailable, the manifest cannot be fetched, or the manifest response is invalid/missing required ASG app version fields. |
280
329
 
281
330
  Android and iOS async APIs use `BluetoothException` / `BluetoothError` for the same error paths. Their returned event structs are the successful response in normal `try`/`await` code, while raw listener/delegate events still include both success and error payloads.
282
331
 
@@ -286,9 +335,16 @@ Android and iOS async APIs use `BluetoothException` / `BluetoothError` for the s
286
335
 
287
336
  Mentra Live firmware owns the OTA flow. The SDK mirrors the MentraOS app commands and events:
288
337
 
289
- - `checkForOtaUpdate()` sends `ota_query_status` and resolves with the ASG response (`ota_update_available` or the current `ota_status`).
290
- - `startOtaUpdate()` sends `ota_start` and resolves with the ASG start ack after your app presents the update and the user accepts it.
291
- - `retryOtaVersionCheck()` sends `ota_retry_version_check` and resolves with the same ASG response shape as `checkForOtaUpdate()`; use it only after fixing a known clock-skew/TLS failure.
338
+ - `checkForOtaUpdate()` fetches the configured manifest and resolves with `true` when an ASG APK, MTK, or BES update is available.
339
+ - `startOtaUpdate()` sends `ota_start` with the same configured manifest URL and resolves with the ASG start ack after your app presents the update and the user accepts it.
340
+
341
+ The default manifest is derived from the SDK version:
342
+ `https://github.com/Mentra-Community/MentraOS/releases/download/bluetooth-sdk-ota/bluetooth-sdk-<sdkVersion>-version.json`.
343
+ Each published SDK version points at a durable ASG client APK and firmware
344
+ manifest that were built for that SDK release. Pre-wall-clock ASG builds that
345
+ ignore `ota_start.ota_version_url` are checked against the URL they advertise,
346
+ or the production default if they do not advertise one, so the app does not
347
+ prompt for an update the glasses cannot install.
292
348
 
293
349
  ```ts
294
350
  import BluetoothSdk from '@mentra/bluetooth-sdk'
@@ -297,9 +353,9 @@ BluetoothSdk.addListener('ota_status', (event) => {
297
353
  console.log(`OTA ${event.status}: ${event.overall_percent}%`)
298
354
  })
299
355
 
300
- const ota = await BluetoothSdk.checkForOtaUpdate()
301
- if (ota.type === 'ota_update_available') {
302
- const userAccepted = await promptUserToInstallUpdate(ota) // your app's UI
356
+ const hasUpdate = await BluetoothSdk.checkForOtaUpdate()
357
+ if (hasUpdate) {
358
+ const userAccepted = await promptUserToInstallUpdate() // your app's UI
303
359
  if (userAccepted) {
304
360
  const startAck = await BluetoothSdk.startOtaUpdate()
305
361
  console.log('OTA start acknowledged', startAck.timestamp)
@@ -332,7 +388,7 @@ For one-shot manual capture tuning, pass `exposureTimeNs` and `iso` together. `e
332
388
 
333
389
  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
390
 
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`.
391
+ `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
392
 
337
393
  ## Streaming
338
394
 
@@ -376,7 +432,7 @@ export function HardwareEventLogger() {
376
432
 
377
433
  For non-React modules, `BluetoothSdk.addListener(...)` is the low-level subscription API. Keep the returned subscription and call `remove()` when the listener is no longer needed.
378
434
 
379
- Common event names include `button_press`, `touch_event`, `head_up`, `battery_status`, `wifi_status_change`, `wifi_scan_result`, `hotspot_status_change`, `photo_status`, `photo_response`, `gallery_status`, `settings_ack`, `version_info`, `stream_status`, `ota_update_available`, `ota_start_ack`, `ota_status`, `mic_pcm`, `mic_lc3`, `local_transcription`, `rgb_led_control_response`, `audio_connected`, `audio_disconnected`, and `log`.
435
+ Common event names include `button_press`, `touch_event`, `head_up`, `battery_status`, `wifi_status_change`, `wifi_scan_result`, `hotspot_status_change`, `photo_status`, `photo_response`, `gallery_status`, `settings_ack`, `version_info`, `stream_status`, `ota_start_ack`, `ota_status`, `mic_pcm`, `mic_lc3`, `local_transcription`, `rgb_led_control_response`, `audio_connected`, `audio_disconnected`, and `log`.
380
436
 
381
437
  React Native event payload fields usually use camelCase. OTA events intentionally mirror the glasses firmware field names, such as `overall_percent` and `version_name`. For example, `touch_event` includes `gestureName`, `wifi_scan_result` includes `networks` and `scanComplete`, `version_info` matches the `requestVersionInfo()` result shape, `photo_response` success includes `uploadUrl` and may include webhook-returned `photoUrl`, `statusUrl`, `contentType`, and `fileSizeBytes`, and `gallery_status` includes `hasContent`, `cameraBusy`, and optional `cameraBusyReason`. `photo_status` reports intermediate photo states such as `accepted`, `queued`, `configuring`, `capturing`, `captured`, `compressing`, `ble_fallback_compression`, `uploading`, `ready_for_transfer`, `transferring`, and `failed`; the `configuring` event includes `resolvedConfig` with the effective JPEG dimensions, quality, requested size, transfer method, compression, and manual exposure fields when present. The `capturing` event may include `requestedCaptureConfig` and `meteredPreview`; the `captured` event may include `captureMetadata` with the HAL-applied exposure, ISO, frame duration, and AE state. `mic_pcm` includes `sampleRate`, `bitsPerSample`, `channels`, and `encoding`; `mic_lc3` includes `sampleRate`, `channels`, `encoding`, `frameDurationMs`, `frameSizeBytes`, `bitrate`, and `packetizedFromGlasses`.
382
438
 
@@ -409,7 +465,7 @@ For bare native iOS apps, use the public SwiftPM repository:
409
465
  https://github.com/Mentra-Community/mentra-bluetooth-sdk-ios.git
410
466
  ```
411
467
 
412
- Add the `MentraBluetoothSDK` product to your app target at version `0.1.11` or newer.
468
+ Select version `0.1.13`, then add the `MentraBluetoothSDK` product to your app target.
413
469
 
414
470
  For local SDK development, add this package folder directly in Xcode:
415
471
 
@@ -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
 
@@ -428,8 +441,37 @@ class BluetoothSdkModule : Module() {
428
441
  sdk?.setVoiceActivityDetectionEnabled(enabled)
429
442
  }
430
443
 
431
- AsyncFunction("setButtonPhotoSettings") { size: String ->
432
- requireSdk().setButtonPhotoSettings(ButtonPhotoSize.fromValue(size)).values
444
+ AsyncFunction("setButtonPhotoCaptureSettings") { params: Map<String, Any?> ->
445
+ val size = (params["size"] as? String)?.let { ButtonPhotoSize.fromValue(it) }
446
+ val mfnr = params["mfnr"] as? Boolean
447
+ val zsl = params["zsl"] as? Boolean
448
+ val noiseReduction = params["noiseReduction"] as? Boolean
449
+ val edgeEnhancement = params["edgeEnhancement"] as? Boolean
450
+ val ispDigitalGain = (params["ispDigitalGain"] as? Number)?.toInt()
451
+ val ispAnalogGain = params["ispAnalogGain"] as? String
452
+ val aeExposureDivisor = (params["aeExposureDivisor"] as? Number)?.toInt()
453
+ val isoCap = (params["isoCap"] as? Number)?.toInt()
454
+ val compress = params["compress"] as? String
455
+ val sound = params["sound"] as? Boolean
456
+ val resetCaptureTuning = params["resetCaptureTuning"] as? Boolean == true
457
+ requireSdk()
458
+ .setButtonPhotoSettings(
459
+ ButtonPhotoSettings(
460
+ size = size,
461
+ mfnr = mfnr,
462
+ zsl = zsl,
463
+ noiseReduction = noiseReduction,
464
+ edgeEnhancement = edgeEnhancement,
465
+ ispDigitalGain = ispDigitalGain,
466
+ ispAnalogGain = ispAnalogGain,
467
+ aeExposureDivisor = aeExposureDivisor,
468
+ isoCap = isoCap,
469
+ compress = compress,
470
+ sound = sound,
471
+ resetCaptureTuning = resetCaptureTuning,
472
+ ),
473
+ )
474
+ .values
433
475
  }
434
476
 
435
477
  AsyncFunction("setButtonVideoRecordingSettings") { width: Int, height: Int, fps: Int ->
@@ -470,7 +512,26 @@ class BluetoothSdkModule : Module() {
470
512
 
471
513
  // MARK: - OTA Commands
472
514
 
473
- AsyncFunction("sendOtaStart") { requireSdk().sendOtaStart().values }
515
+ Function("setOtaVersionUrl") { otaVersionUrl: String ->
516
+ requireSdk().setOtaVersionUrl(otaVersionUrl)
517
+ }
518
+
519
+ Function("getOtaVersionUrl") { requireSdk().getOtaVersionUrl() }
520
+
521
+ // Runs on Dispatchers.IO, not the shared Expo AsyncFunctionQueue:
522
+ // manifest fetches and version waits can block for several seconds.
523
+ AsyncFunction("checkForOtaUpdate") Coroutine { ->
524
+ withContext(Dispatchers.IO) { requireSdk().checkForOtaUpdate() }
525
+ }
526
+
527
+ AsyncFunction("startOtaUpdate") { otaVersionUrl: String? ->
528
+ val sdk = requireSdk()
529
+ if (otaVersionUrl.isNullOrBlank()) {
530
+ sdk.startOtaUpdate().values
531
+ } else {
532
+ sdk.startOtaUpdate(otaVersionUrl).values
533
+ }
534
+ }
474
535
 
475
536
  AsyncFunction("sendOtaQueryStatus") { requireSdk().sendOtaQueryStatus().values }
476
537
 
@@ -505,12 +566,18 @@ class BluetoothSdkModule : Module() {
505
566
  dim("width"),
506
567
  dim("height"),
507
568
  dim("fps"),
569
+ dim("maxRecordingTimeMinutes"),
508
570
  )
509
571
  ).values
510
572
  }
511
573
 
512
- AsyncFunction("stopVideoRecording") { requestId: String ->
513
- requireSdk().stopVideoRecording(requestId).values
574
+ // webhookUrl/authToken are supplied at stop (not start) so the token is
575
+ // fresh when the upload runs. Empty/null webhook = keep on device.
576
+ AsyncFunction("stopVideoRecording") {
577
+ requestId: String,
578
+ webhookUrl: String?,
579
+ authToken: String? ->
580
+ requireSdk().stopVideoRecording(requestId, webhookUrl, authToken).values
514
581
  }
515
582
 
516
583
  // 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 =