@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
@@ -81,10 +81,14 @@ private final class PendingHotspotStatusRequest {
81
81
  private final class PendingVideoRecordingRequest {
82
82
  let expectedStatus: String
83
83
  let pending: PendingResponse<VideoRecordingStatusEvent>
84
+ let waitForUpload: Bool
85
+ var stoppedEvent: VideoRecordingStatusEvent?
86
+ var uploadSucceeded = false
84
87
 
85
- init(expectedStatus: String, pending: PendingResponse<VideoRecordingStatusEvent>) {
88
+ init(expectedStatus: String, pending: PendingResponse<VideoRecordingStatusEvent>, waitForUpload: Bool = false) {
86
89
  self.expectedStatus = expectedStatus
87
90
  self.pending = pending
91
+ self.waitForUpload = waitForUpload
88
92
  }
89
93
  }
90
94
 
@@ -147,13 +151,18 @@ public final class MentraBluetoothSDK {
147
151
 
148
152
  private let configuration: MentraBluetoothSDKConfiguration
149
153
  private var discoveredDeviceNames = Set<String>()
154
+ private var bluetoothAvailabilityListenerId: UUID?
155
+ private var shouldRestoreGlassesOnBluetoothRestore = false
156
+ private var shouldRestoreControllerOnBluetoothRestore = false
150
157
  private var bridgeEventSinkId: String?
151
158
  private var storeListenerId: String?
152
159
  private let defaultDeviceKeys: Set<String> = ["default_wearable", "device_name", "device_address"]
160
+ private let videoUploadStopTimeoutMs = 10 * 60 * 1000
153
161
  private var suppressDefaultDeviceEvents = false
154
162
  private var defaultDeviceApplyGeneration = 0
155
163
  private var activeScanSessions: [UUID: ActiveScanSession] = [:]
156
164
  private var activeStreamKeepAlive: ActiveStreamKeepAlive?
165
+ private let analytics: BluetoothSdkAnalytics
157
166
  private var pendingPhotoRequests: [String: PendingResponse<PhotoResponseEvent>] = [:]
158
167
  private var pendingVideoRecordingRequests: [String: PendingVideoRecordingRequest] = [:]
159
168
  private var pendingRgbLedRequests: [String: PendingResponse<RgbLedControlResponseEvent>] = [:]
@@ -170,7 +179,12 @@ public final class MentraBluetoothSDK {
170
179
 
171
180
  public init(configuration: MentraBluetoothSDKConfiguration = .default) {
172
181
  self.configuration = configuration
173
- _ = BluetoothAvailability.shared
182
+ analytics = BluetoothSdkAnalytics(configuration: configuration.analytics)
183
+ bluetoothAvailabilityListenerId = BluetoothAvailability.shared.addStateListener { [weak self] state in
184
+ Task { @MainActor [weak self] in
185
+ self?.handleBluetoothAvailability(state)
186
+ }
187
+ }
174
188
  bridgeEventSinkId = Bridge.addEventSink { [weak self] eventName, data in
175
189
  Task { @MainActor [weak self] in
176
190
  self?.dispatchBridgeEvent(eventName, data)
@@ -181,6 +195,8 @@ public final class MentraBluetoothSDK {
181
195
  self?.dispatchStoreUpdate(category, changes)
182
196
  }
183
197
  }
198
+ analytics.initializeGlassesStatus(glassesStatus)
199
+ analytics.captureStarted()
184
200
  }
185
201
 
186
202
  public var state: MentraBluetoothState {
@@ -211,6 +227,15 @@ public final class MentraBluetoothSDK {
211
227
  currentDefaultDevice()
212
228
  }
213
229
 
230
+ private func requireGlassesConnected(operation: String) throws {
231
+ guard glassesStatus.connected else {
232
+ throw BluetoothError(
233
+ code: "glasses_not_connected",
234
+ message: "Cannot \(operation) because glasses are not connected."
235
+ )
236
+ }
237
+ }
238
+
214
239
  public func getDefaultDevice() -> Device? {
215
240
  currentDefaultDevice()
216
241
  }
@@ -296,6 +321,7 @@ public final class MentraBluetoothSDK {
296
321
  }
297
322
 
298
323
  public func connect(to device: Device, options: ConnectOptions = ConnectOptions()) throws {
324
+ clearBluetoothRestoreIntent()
299
325
  if device.model != .simulated {
300
326
  try BluetoothAvailability.shared.requirePoweredOn(operation: "connect to glasses")
301
327
  }
@@ -315,6 +341,7 @@ public final class MentraBluetoothSDK {
315
341
  }
316
342
 
317
343
  public func connectDefault(options: ConnectOptions = ConnectOptions()) throws {
344
+ clearBluetoothRestoreIntent()
318
345
  guard let device = currentDefaultDevice() else {
319
346
  throw BluetoothError(
320
347
  code: "default_device_missing",
@@ -331,18 +358,22 @@ public final class MentraBluetoothSDK {
331
358
  }
332
359
 
333
360
  public func cancelConnectionAttempt() {
361
+ clearBluetoothRestoreIntent()
334
362
  DeviceManager.shared.disconnect()
335
363
  }
336
364
 
337
365
  func connectSimulated() {
366
+ clearBluetoothRestoreIntent()
338
367
  DeviceManager.shared.connectSimulated()
339
368
  }
340
369
 
341
370
  public func disconnect() {
371
+ clearBluetoothRestoreIntent()
342
372
  DeviceManager.shared.disconnect()
343
373
  }
344
374
 
345
375
  public func forget() {
376
+ clearBluetoothRestoreIntent()
346
377
  DeviceManager.shared.forget()
347
378
  }
348
379
 
@@ -806,6 +837,7 @@ public final class MentraBluetoothSDK {
806
837
  guard !request.requestId.isEmpty else {
807
838
  throw BluetoothError(code: "missing_request_id", message: "requestId is required to start video recording.")
808
839
  }
840
+ try requireGlassesConnected(operation: "start video recording")
809
841
  let pending = PendingResponse<VideoRecordingStatusEvent>(
810
842
  operation: "start video recording \(request.requestId)"
811
843
  )
@@ -825,7 +857,8 @@ public final class MentraBluetoothSDK {
825
857
  request.sound,
826
858
  request.width,
827
859
  request.height,
828
- request.fps
860
+ request.fps,
861
+ request.maxRecordingTimeMinutes
829
862
  )
830
863
  do {
831
864
  let event = try await pending.wait()
@@ -837,10 +870,13 @@ public final class MentraBluetoothSDK {
837
870
  }
838
871
  }
839
872
 
840
- public func stopVideoRecording(requestId: String) async throws -> VideoRecordingStatusEvent {
873
+ public func stopVideoRecording(
874
+ requestId: String, webhookUrl: String? = nil, authToken: String? = nil
875
+ ) async throws -> VideoRecordingStatusEvent {
841
876
  guard !requestId.isEmpty else {
842
877
  throw BluetoothError(code: "missing_request_id", message: "requestId is required to stop video recording.")
843
878
  }
879
+ try requireGlassesConnected(operation: "stop video recording")
844
880
  let pending = PendingResponse<VideoRecordingStatusEvent>(operation: "stop video recording \(requestId)")
845
881
  guard pendingVideoRecordingRequests[requestId] == nil else {
846
882
  throw BluetoothError(
@@ -848,13 +884,16 @@ public final class MentraBluetoothSDK {
848
884
  message: "A video recording command is already waiting for requestId \(requestId)."
849
885
  )
850
886
  }
887
+ let waitForUpload = !(webhookUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
851
888
  pendingVideoRecordingRequests[requestId] = PendingVideoRecordingRequest(
852
889
  expectedStatus: "recording_stopped",
853
- pending: pending
890
+ pending: pending,
891
+ waitForUpload: waitForUpload
854
892
  )
855
- DeviceManager.shared.stopVideoRecording(requestId)
893
+ DeviceManager.shared.stopVideoRecording(requestId, webhookUrl, authToken)
856
894
  do {
857
- let event = try await pending.wait()
895
+ let timeoutMs = waitForUpload ? videoUploadStopTimeoutMs : 15_000
896
+ let event = try await pending.wait(timeoutMs: timeoutMs)
858
897
  pendingVideoRecordingRequests.removeValue(forKey: requestId)
859
898
  return event
860
899
  } catch {
@@ -971,6 +1010,10 @@ public final class MentraBluetoothSDK {
971
1010
 
972
1011
  public func invalidate() {
973
1012
  stopStreamKeepAliveMonitor()
1013
+ if let bluetoothAvailabilityListenerId {
1014
+ BluetoothAvailability.shared.removeStateListener(bluetoothAvailabilityListenerId)
1015
+ self.bluetoothAvailabilityListenerId = nil
1016
+ }
974
1017
  if let bridgeEventSinkId {
975
1018
  Bridge.removeEventSink(bridgeEventSinkId)
976
1019
  self.bridgeEventSinkId = nil
@@ -982,6 +1025,83 @@ public final class MentraBluetoothSDK {
982
1025
  delegate = nil
983
1026
  }
984
1027
 
1028
+ private func handleBluetoothAvailability(_ state: CBManagerState) {
1029
+ switch state {
1030
+ case .poweredOff, .resetting, .unauthorized, .unsupported:
1031
+ handleBluetoothUnavailable()
1032
+ case .poweredOn:
1033
+ handleBluetoothRestored()
1034
+ case .unknown:
1035
+ break
1036
+ @unknown default:
1037
+ handleBluetoothUnavailable()
1038
+ }
1039
+ }
1040
+
1041
+ private func handleBluetoothUnavailable() {
1042
+ cancelActiveScanSessions(reason: .cancelled)
1043
+ clearBluetoothDiscoveryState()
1044
+ disconnectActiveConnections()
1045
+ }
1046
+
1047
+ private func disconnectActiveConnections() {
1048
+ if glassesStatus.controllerConnected {
1049
+ DeviceManager.shared.disconnectController()
1050
+ shouldRestoreControllerOnBluetoothRestore = true
1051
+ }
1052
+ if glassesStatus.deviceModel == DeviceTypes.SIMULATED
1053
+ || DeviceManager.shared.sgc?.type.contains(DeviceTypes.SIMULATED) == true
1054
+ {
1055
+ return
1056
+ }
1057
+ if glassesStatus.connected || glassesStatus.connectionState != .disconnected {
1058
+ DeviceManager.shared.disconnect()
1059
+ shouldRestoreGlassesOnBluetoothRestore = true
1060
+ }
1061
+ }
1062
+
1063
+ /// Reconnect only what `handleBluetoothUnavailable` tore down, never a
1064
+ /// connection the user closed themselves (explicit connect/disconnect
1065
+ /// calls clear the restore intent).
1066
+ private func handleBluetoothRestored() {
1067
+ let restoreGlasses = shouldRestoreGlassesOnBluetoothRestore
1068
+ let restoreController = shouldRestoreControllerOnBluetoothRestore
1069
+ clearBluetoothRestoreIntent()
1070
+
1071
+ if restoreGlasses, !glassesStatus.connected, glassesStatus.connectionState == .disconnected {
1072
+ DeviceManager.shared.connectDefault() // also restores the controller
1073
+ } else if restoreController, !glassesStatus.controllerConnected {
1074
+ DeviceManager.shared.connectDefaultController()
1075
+ }
1076
+ }
1077
+
1078
+ private func clearBluetoothRestoreIntent() {
1079
+ shouldRestoreGlassesOnBluetoothRestore = false
1080
+ shouldRestoreControllerOnBluetoothRestore = false
1081
+ }
1082
+
1083
+ private func clearBluetoothDiscoveryState() {
1084
+ discoveredDeviceNames.removeAll()
1085
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searching", false)
1086
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searchingController", false)
1087
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searchResults", [] as [[String: Any]])
1088
+ }
1089
+
1090
+ private func cancelActiveScanSessions(reason: ScanStopReason) {
1091
+ let ids = Array(activeScanSessions.keys)
1092
+ guard !ids.isEmpty else {
1093
+ if bluetoothStatus.searching || bluetoothStatus.searchingController {
1094
+ stopScan(reason: reason)
1095
+ }
1096
+ return
1097
+ }
1098
+ for (index, id) in ids.enumerated() {
1099
+ // Stop the underlying scan once (first session); the rest only
1100
+ // complete their callbacks.
1101
+ finishScanSession(id, reason: reason, shouldStopScan: index == 0)
1102
+ }
1103
+ }
1104
+
985
1105
  private func startStreamKeepAliveMonitor(streamId: String, intervalSeconds requestedIntervalSeconds: Int) {
986
1106
  let intervalSeconds = requestedIntervalSeconds > 0 ? requestedIntervalSeconds : 5
987
1107
  let tracker = ActiveStreamKeepAlive(streamId: streamId, intervalSeconds: intervalSeconds)
@@ -1162,7 +1282,14 @@ public final class MentraBluetoothSDK {
1162
1282
  guard let request = pendingVideoRecordingRequests[event.requestId] else { return }
1163
1283
  if event.success {
1164
1284
  if event.status == request.expectedStatus {
1165
- request.pending.resolve(event)
1285
+ if request.waitForUpload {
1286
+ request.stoppedEvent = event
1287
+ if request.uploadSucceeded {
1288
+ request.pending.resolve(event)
1289
+ }
1290
+ } else {
1291
+ request.pending.resolve(event)
1292
+ }
1166
1293
  }
1167
1294
  } else {
1168
1295
  request.pending.reject(
@@ -1174,6 +1301,26 @@ public final class MentraBluetoothSDK {
1174
1301
  }
1175
1302
  }
1176
1303
 
1304
+ private func handleMediaUploadForRequests(_ event: MediaUploadEvent) {
1305
+ guard event.isVideo, let request = pendingVideoRecordingRequests[event.requestId], request.waitForUpload else {
1306
+ return
1307
+ }
1308
+ if event.isSuccess {
1309
+ if let stoppedEvent = request.stoppedEvent {
1310
+ request.pending.resolve(stoppedEvent)
1311
+ } else {
1312
+ request.uploadSucceeded = true
1313
+ }
1314
+ } else {
1315
+ request.pending.reject(
1316
+ BluetoothError(
1317
+ code: "video_upload_failed",
1318
+ message: event.errorMessage ?? "Video upload failed."
1319
+ )
1320
+ )
1321
+ }
1322
+ }
1323
+
1177
1324
  private func handleRgbLedResponseForRequests(_ event: RgbLedControlResponseEvent) {
1178
1325
  guard let pending = pendingRgbLedRequests[event.requestId] else { return }
1179
1326
  if event.state == "success" {
@@ -1273,6 +1420,7 @@ public final class MentraBluetoothSDK {
1273
1420
  private func dispatchStoreUpdate(_ category: String, _ changes: [String: Any]) {
1274
1421
  switch ObservableStore.normalizeCategory(category) {
1275
1422
  case "glasses":
1423
+ analytics.observeGlassesStatus(glassesStatus)
1276
1424
  let nextState = state
1277
1425
  delegate?.mentraBluetoothSDK(self, didUpdate: nextState)
1278
1426
  delegate?.mentraBluetoothSDK(self, didUpdateGlasses: nextState.glasses)
@@ -1424,6 +1572,10 @@ public final class MentraBluetoothSDK {
1424
1572
  let event = VideoRecordingStatusEvent(values: data)
1425
1573
  handleVideoRecordingStatusForRequests(event)
1426
1574
  delegate?.mentraBluetoothSDK(self, didReceive: .videoRecordingStatus(event))
1575
+ case "media_success", "media_error":
1576
+ let event = MediaUploadEvent(values: data)
1577
+ handleMediaUploadForRequests(event)
1578
+ delegate?.mentraBluetoothSDK(self, didReceive: .mediaUpload(event))
1427
1579
  case "rgb_led_control_response":
1428
1580
  let event = RgbLedControlResponseEvent(values: data)
1429
1581
  handleRgbLedResponseForRequests(event)
@@ -229,9 +229,12 @@ public struct VideoRecordingRequest {
229
229
  public let width: Int
230
230
  public let height: Int
231
231
  public let fps: Int
232
+ // Optional auto-stop timer in minutes; 0 = record until stopped/interrupted.
233
+ public let maxRecordingTimeMinutes: Int
232
234
 
233
235
  public init(
234
- requestId: String, save: Bool, sound: Bool, width: Int = 0, height: Int = 0, fps: Int = 0
236
+ requestId: String, save: Bool, sound: Bool, width: Int = 0, height: Int = 0, fps: Int = 0,
237
+ maxRecordingTimeMinutes: Int = 0
235
238
  ) {
236
239
  self.requestId = requestId
237
240
  self.save = save
@@ -239,6 +242,7 @@ public struct VideoRecordingRequest {
239
242
  self.width = width
240
243
  self.height = height
241
244
  self.fps = fps
245
+ self.maxRecordingTimeMinutes = maxRecordingTimeMinutes
242
246
  }
243
247
  }
244
248
 
@@ -280,6 +284,50 @@ public struct VideoRecordingStatusEvent: CustomStringConvertible {
280
284
  }
281
285
  }
282
286
 
287
+ public struct MediaUploadEvent: CustomStringConvertible {
288
+ public let values: [String: Any]
289
+
290
+ public init(values: [String: Any]) {
291
+ self.values = values
292
+ }
293
+
294
+ public var type: String {
295
+ stringValue(values, "type") ?? ""
296
+ }
297
+
298
+ public var requestId: String {
299
+ stringValue(values, "requestId") ?? ""
300
+ }
301
+
302
+ public var mediaUrl: String? {
303
+ stringValue(values, "mediaUrl")
304
+ }
305
+
306
+ public var errorMessage: String? {
307
+ stringValue(values, "errorMessage")
308
+ }
309
+
310
+ public var mediaType: Int? {
311
+ intValue(values["mediaType"])
312
+ }
313
+
314
+ public var timestamp: Int {
315
+ intValue(values["timestamp"]) ?? Int(Date().timeIntervalSince1970 * 1000)
316
+ }
317
+
318
+ public var isSuccess: Bool {
319
+ type == "media_success"
320
+ }
321
+
322
+ public var isVideo: Bool {
323
+ mediaType == 2
324
+ }
325
+
326
+ public var description: String {
327
+ "MediaUploadEvent(requestId: \(requestId), type: \(type), mediaType: \(mediaType ?? -1))"
328
+ }
329
+ }
330
+
283
331
  public enum PhotoResponse: CustomStringConvertible, Equatable {
284
332
  public enum State: String {
285
333
  case success
@@ -244,6 +244,7 @@ public enum BluetoothEvent: CustomStringConvertible {
244
244
  case photoResponse(PhotoResponseEvent)
245
245
  case photoStatus(PhotoStatusEvent)
246
246
  case videoRecordingStatus(VideoRecordingStatusEvent)
247
+ case mediaUpload(MediaUploadEvent)
247
248
  case rgbLedControlResponse(RgbLedControlResponseEvent)
248
249
  case streamStatus(StreamStatusEvent)
249
250
  case keepAliveAck(KeepAliveAckEvent)
@@ -277,6 +278,8 @@ public enum BluetoothEvent: CustomStringConvertible {
277
278
  event.description
278
279
  case let .videoRecordingStatus(event):
279
280
  event.description
281
+ case let .mediaUpload(event):
282
+ event.description
280
283
  case let .rgbLedControlResponse(event):
281
284
  event.description
282
285
  case let .streamStatus(event):
@@ -6,6 +6,7 @@ final class BluetoothAvailability: NSObject, CBCentralManagerDelegate {
6
6
 
7
7
  private var centralManager: CBCentralManager?
8
8
  private var state: CBManagerState = .unknown
9
+ private var listeners: [UUID: (CBManagerState) -> Void] = [:]
9
10
 
10
11
  override private init() {
11
12
  super.init()
@@ -19,6 +20,25 @@ final class BluetoothAvailability: NSObject, CBCentralManagerDelegate {
19
20
 
20
21
  func centralManagerDidUpdateState(_ central: CBCentralManager) {
21
22
  state = central.state
23
+ for listener in Array(listeners.values) {
24
+ listener(state)
25
+ }
26
+ }
27
+
28
+ @discardableResult
29
+ func addStateListener(_ listener: @escaping (CBManagerState) -> Void) -> UUID {
30
+ // `listeners` is unsynchronized; it is only safe because the central
31
+ // manager queue is `.main` and all callers are main-thread bound.
32
+ dispatchPrecondition(condition: .onQueue(.main))
33
+ let id = UUID()
34
+ listeners[id] = listener
35
+ listener(state)
36
+ return id
37
+ }
38
+
39
+ func removeStateListener(_ id: UUID) {
40
+ dispatchPrecondition(condition: .onQueue(.main))
41
+ listeners[id] = nil
22
42
  }
23
43
 
24
44
  func requirePoweredOn(operation: String) throws {
@@ -0,0 +1,160 @@
1
+ import Foundation
2
+
3
+ public struct BluetoothSdkAnalyticsConfiguration {
4
+ public static let disabled = BluetoothSdkAnalyticsConfiguration(enabled: false)
5
+
6
+ public let enabled: Bool
7
+ let surface: String
8
+
9
+ public init(enabled: Bool = true) {
10
+ self.enabled = enabled
11
+ surface = "ios"
12
+ }
13
+
14
+ var isReady: Bool {
15
+ enabled
16
+ }
17
+
18
+ func withSurface(_ surface: String) -> BluetoothSdkAnalyticsConfiguration {
19
+ BluetoothSdkAnalyticsConfiguration(
20
+ enabled: enabled,
21
+ surface: surface
22
+ )
23
+ }
24
+
25
+ private init(
26
+ enabled: Bool,
27
+ surface: String
28
+ ) {
29
+ self.enabled = enabled
30
+ self.surface = surface
31
+ }
32
+ }
33
+
34
+ final class BluetoothSdkAnalytics {
35
+ private static let defaultPostHogApiKey = "phc_FCweXVAxVgU7wZK4Fk3okOx4RmyNqVHJf62YpZSfJt5"
36
+ private static let defaultPostHogHost = "https://us.i.posthog.com"
37
+ private let stateQueue = DispatchQueue(label: "com.mentra.bluetoothsdk.analytics.state")
38
+ private let transportQueue = DispatchQueue(label: "com.mentra.bluetoothsdk.analytics.transport")
39
+ private let configuration: BluetoothSdkAnalyticsConfiguration
40
+ private var startedCaptured = false
41
+ private var lastConnected = false
42
+
43
+ init(configuration: BluetoothSdkAnalyticsConfiguration) {
44
+ self.configuration = configuration.resolvedForApp()
45
+ }
46
+
47
+ func initializeGlassesStatus(_ status: GlassesStatus) {
48
+ stateQueue.sync {
49
+ lastConnected = status.analyticsConnected
50
+ }
51
+ }
52
+
53
+ func captureStarted() {
54
+ stateQueue.sync {
55
+ captureStartedLocked()
56
+ }
57
+ }
58
+
59
+ private func captureStartedLocked() {
60
+ guard !startedCaptured, configuration.isReady else { return }
61
+ startedCaptured = true
62
+ capture(
63
+ event: "bluetooth_sdk_started",
64
+ properties: ["event_kind": "sdk_started"],
65
+ configuration: configuration
66
+ )
67
+ }
68
+
69
+ func observeGlassesStatus(_ status: GlassesStatus) {
70
+ stateQueue.sync {
71
+ let isConnected = status.analyticsConnected
72
+ let wasConnected = lastConnected
73
+ lastConnected = isConnected
74
+ guard configuration.isReady else { return }
75
+ if isConnected, !wasConnected {
76
+ var properties: [String: Any] = [
77
+ "event_kind": "glasses_connected",
78
+ "fully_booted": status.fullyBooted,
79
+ ]
80
+ if !status.deviceModel.isEmpty {
81
+ properties["glasses_model"] = status.deviceModel
82
+ }
83
+ capture(event: "bluetooth_sdk_glasses_connected", properties: properties, configuration: configuration)
84
+ }
85
+ }
86
+ }
87
+
88
+ private func capture(
89
+ event: String,
90
+ properties: [String: Any],
91
+ configuration activeConfiguration: BluetoothSdkAnalyticsConfiguration
92
+ ) {
93
+ guard activeConfiguration.isReady else { return }
94
+
95
+ transportQueue.async {
96
+ let payload: [String: Any] = [
97
+ "api_key": Self.defaultPostHogApiKey,
98
+ "event": event,
99
+ "distinct_id": self.distinctId(),
100
+ "properties": self.baseProperties(configuration: activeConfiguration).merging(properties) { _, new in new },
101
+ ]
102
+ guard let body = try? JSONSerialization.data(withJSONObject: payload) else { return }
103
+ guard let captureURL = self.captureURL() else { return }
104
+ var request = URLRequest(url: captureURL)
105
+ request.httpMethod = "POST"
106
+ request.timeoutInterval = 4
107
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
108
+ request.httpBody = body
109
+ URLSession.shared.dataTask(with: request).resume()
110
+ }
111
+ }
112
+
113
+ private func baseProperties(configuration: BluetoothSdkAnalyticsConfiguration) -> [String: Any] {
114
+ var properties: [String: Any] = [
115
+ "$process_person_profile": false,
116
+ "event_source": "mentra_bluetooth_sdk",
117
+ "sdk_platform": "ios",
118
+ "sdk_surface": configuration.surface,
119
+ "app_bundle_identifier": Bundle.main.bundleIdentifier ?? "",
120
+ "os_platform": "ios",
121
+ "os_version": ProcessInfo.processInfo.operatingSystemVersionString,
122
+ ]
123
+ if let sdkVersion = BluetoothSdkDefaults.sdkVersion {
124
+ properties["sdk_version"] = sdkVersion
125
+ }
126
+ return properties
127
+ }
128
+
129
+ private func distinctId() -> String {
130
+ let key = "mentra_bluetooth_sdk_analytics_distinct_id"
131
+ if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
132
+ return existing
133
+ }
134
+ let generated = "mentra-bt-sdk-\(UUID().uuidString)"
135
+ UserDefaults.standard.set(generated, forKey: key)
136
+ return generated
137
+ }
138
+
139
+ private func captureURL() -> URL? {
140
+ let normalized = Self.defaultPostHogHost.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
141
+ return URL(string: "\(normalized)/i/v0/e/")
142
+ }
143
+ }
144
+
145
+ private extension GlassesStatus {
146
+ var analyticsConnected: Bool {
147
+ connectionState.isConnected || connected || fullyBooted
148
+ }
149
+ }
150
+
151
+ private extension BluetoothSdkAnalyticsConfiguration {
152
+ func resolvedForApp() -> BluetoothSdkAnalyticsConfiguration {
153
+ let disabledByApp = Bundle.main.object(forInfoDictionaryKey: "MentraBluetoothSdkAnalyticsDisabled") as? Bool == true
154
+
155
+ return BluetoothSdkAnalyticsConfiguration(
156
+ enabled: enabled && !disabledByApp,
157
+ surface: surface
158
+ )
159
+ }
160
+ }
@@ -2171,6 +2171,9 @@ class MentraLive: NSObject, SGCManager {
2171
2171
  case "video_recording_status":
2172
2172
  emitVideoRecordingStatus(json)
2173
2173
 
2174
+ case "media_success", "media_error":
2175
+ Bridge.sendMediaUploadEvent(type: type, values: json)
2176
+
2174
2177
  case "photo_status":
2175
2178
  emitPhotoStatus(json)
2176
2179
 
@@ -5269,7 +5272,8 @@ extension MentraLive {
5269
5272
 
5270
5273
  func startVideoRecording(requestId: String, save: Bool, flash: Bool, sound: Bool) {
5271
5274
  startVideoRecording(
5272
- requestId: requestId, save: save, flash: flash, sound: sound, width: 0, height: 0, fps: 0
5275
+ requestId: requestId, save: save, flash: flash, sound: sound, width: 0, height: 0, fps: 0,
5276
+ maxRecordingTimeMinutes: 0
5273
5277
  )
5274
5278
  }
5275
5279
 
@@ -5280,10 +5284,11 @@ extension MentraLive {
5280
5284
  }
5281
5285
 
5282
5286
  func startVideoRecording(
5283
- requestId: String, save: Bool, flash: Bool, sound: Bool, width: Int, height: Int, fps: Int
5287
+ requestId: String, save: Bool, flash: Bool, sound: Bool, width: Int, height: Int, fps: Int,
5288
+ maxRecordingTimeMinutes: Int
5284
5289
  ) {
5285
5290
  Bridge.log(
5286
- "Starting video recording on glasses: requestId=\(requestId), save=\(save), flash=\(flash), sound=\(sound), resolution=\(width)x\(height)@\(fps)fps"
5291
+ "Starting video recording on glasses: requestId=\(requestId), save=\(save), flash=\(flash), sound=\(sound), resolution=\(width)x\(height)@\(fps)fps, maxRecordingTimeMinutes=\(maxRecordingTimeMinutes)"
5287
5292
  )
5288
5293
 
5289
5294
  guard connectionState == ConnTypes.CONNECTED else {
@@ -5299,6 +5304,11 @@ extension MentraLive {
5299
5304
  "sound": sound,
5300
5305
  ]
5301
5306
 
5307
+ // Auto-stop timer; only sent when set (> 0). 0 = record until stopped.
5308
+ if maxRecordingTimeMinutes > 0 {
5309
+ json["maxRecordingTimeMinutes"] = maxRecordingTimeMinutes
5310
+ }
5311
+
5302
5312
  // Add video settings when any field is overridden. Each field is sent
5303
5313
  // only when > 0; the glasses merge the missing fields onto their saved
5304
5314
  // button-video defaults, so a partial override (e.g. fps-only) still
@@ -5314,17 +5324,31 @@ extension MentraLive {
5314
5324
  }
5315
5325
 
5316
5326
  func stopVideoRecording(requestId: String) {
5317
- Bridge.log("Stopping video recording on glasses: requestId=\(requestId)")
5327
+ stopVideoRecording(requestId: requestId, webhookUrl: nil, authToken: nil)
5328
+ }
5329
+
5330
+ func stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
5331
+ Bridge.log(
5332
+ "Stopping video recording on glasses: requestId=\(requestId), webhook=\((webhookUrl?.isEmpty ?? true) ? "none" : "set")"
5333
+ )
5318
5334
 
5319
5335
  guard connectionState == ConnTypes.CONNECTED else {
5320
5336
  Bridge.log("Cannot stop video recording - not connected")
5321
5337
  return
5322
5338
  }
5323
5339
 
5324
- let json: [String: Any] = [
5340
+ var json: [String: Any] = [
5325
5341
  "type": "stop_video_recording",
5326
5342
  "requestId": requestId,
5327
5343
  ]
5344
+ // Webhook upload target, supplied at stop so the token is fresh.
5345
+ // Only sent when present; empty webhook = keep video on device.
5346
+ if let webhookUrl, !webhookUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
5347
+ json["webhookUrl"] = webhookUrl
5348
+ }
5349
+ if let authToken, !authToken.isEmpty {
5350
+ json["authToken"] = authToken
5351
+ }
5328
5352
  sendJson(json)
5329
5353
  }
5330
5354
  }