@mentra/bluetooth-sdk 0.1.21-beta.0 → 0.1.21-beta.1
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 +22 -11
- package/android/build.gradle +3 -0
- package/android/src/main/AndroidManifest.xml +3 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +34 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +43 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +7 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +82 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +32 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +17 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +161 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +38 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +30 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
- package/build/BluetoothSdk.types.d.ts +58 -25
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +2 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +1 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +1 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +8 -2
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +6 -3
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/cameraRequestPayload.d.ts +1 -1
- package/build/_private/cameraRequestPayload.d.ts.map +1 -1
- package/build/_private/cameraRequestPayload.js +6 -0
- package/build/_private/cameraRequestPayload.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +10 -1
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/_private/sdkOtaManifest.d.ts +20 -0
- package/build/_private/sdkOtaManifest.d.ts.map +1 -0
- package/build/_private/sdkOtaManifest.js +28 -0
- package/build/_private/sdkOtaManifest.js.map +1 -0
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +7 -1
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +41 -2
- package/ios/Source/BluetoothSdkDefaults.swift +2 -1
- package/ios/Source/Bridge.swift +15 -3
- package/ios/Source/DeviceManager.swift +67 -17
- package/ios/Source/DeviceStore.swift +5 -1
- package/ios/Source/MentraBluetoothSDK.swift +37 -8
- package/ios/Source/OtaManifest.swift +47 -4
- package/ios/Source/camera/CameraModels.swift +20 -5
- package/ios/Source/internal/BleTraceLogger.swift +1 -1
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
- package/ios/Source/sgcs/Ar99.swift +2451 -0
- package/ios/Source/sgcs/MentraLive.swift +174 -38
- package/ios/Source/sgcs/SGCManager.swift +8 -0
- package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
- package/ios/Source/streaming/StreamModels.swift +62 -5
- package/ios/Source/types/DeviceModels.swift +13 -0
- package/ios/Source/utils/BleJsonCompact.swift +8 -0
- package/ios/Source/utils/Constants.swift +2 -0
- package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +61 -28
- package/src/_internal.ts +1 -0
- package/src/_private/BluetoothSdkModule.ts +20 -6
- package/src/_private/cameraRequestPayload.ts +8 -2
- package/src/_private/photoRequestPayload.ts +12 -2
- package/src/_private/sdkOtaManifest.ts +31 -0
- package/src/index.ts +9 -1
package/ios/Source/Bridge.swift
CHANGED
|
@@ -177,12 +177,15 @@ class Bridge {
|
|
|
177
177
|
_ deviceModel: String,
|
|
178
178
|
_ deviceName: String,
|
|
179
179
|
deviceAddress: String = "",
|
|
180
|
-
rssi: Int? = nil
|
|
180
|
+
rssi: Int? = nil,
|
|
181
|
+
projectName: String? = nil
|
|
181
182
|
) {
|
|
182
183
|
Task {
|
|
183
184
|
await MainActor.run {
|
|
184
185
|
let searchResults = DeviceStore.shared.get("bluetooth", "searchResults") as? [[String: Any]] ?? []
|
|
185
|
-
let id =
|
|
186
|
+
let id = [deviceModel, projectName?.isEmpty == false ? projectName! : nil, deviceName]
|
|
187
|
+
.compactMap { $0 }
|
|
188
|
+
.joined(separator: ":")
|
|
186
189
|
var newResult: [String: Any] = [
|
|
187
190
|
"id": id,
|
|
188
191
|
"model": deviceModel,
|
|
@@ -191,6 +194,9 @@ class Bridge {
|
|
|
191
194
|
if !deviceAddress.isEmpty {
|
|
192
195
|
newResult["address"] = deviceAddress
|
|
193
196
|
}
|
|
197
|
+
if let projectName, !projectName.isEmpty {
|
|
198
|
+
newResult["projectName"] = projectName
|
|
199
|
+
}
|
|
194
200
|
if let rssi {
|
|
195
201
|
newResult["rssi"] = rssi
|
|
196
202
|
}
|
|
@@ -495,7 +501,7 @@ class Bridge {
|
|
|
495
501
|
Bridge.sendTypedMessage("mtk_update_complete", body: eventBody)
|
|
496
502
|
}
|
|
497
503
|
|
|
498
|
-
/// Send ota_start_ack —
|
|
504
|
+
/// Send ota_start_ack —glasses confirmed receipt of ota_start command
|
|
499
505
|
static func sendOtaStartAck() {
|
|
500
506
|
let eventBody: [String: Any] = [
|
|
501
507
|
"timestamp": Int64(Date().timeIntervalSince1970 * 1000),
|
|
@@ -607,3 +613,9 @@ class Bridge {
|
|
|
607
613
|
return payload
|
|
608
614
|
}
|
|
609
615
|
}
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
|
|
@@ -22,7 +22,7 @@ struct ViewState {
|
|
|
22
22
|
var text: String
|
|
23
23
|
var data: String?
|
|
24
24
|
var animationData: [String: Any]?
|
|
25
|
-
// Optional container position/size
|
|
25
|
+
// Optional container position/size - used by bitmap_view and positioned_text (G2; ignored by others)
|
|
26
26
|
var bmpX: Int32? = nil
|
|
27
27
|
var bmpY: Int32? = nil
|
|
28
28
|
var bmpWidth: Int32? = nil
|
|
@@ -76,7 +76,7 @@ struct ViewState {
|
|
|
76
76
|
|
|
77
77
|
if isPaired {
|
|
78
78
|
// Device is paired! Don't activate it - let PhoneMic.swift activate when recording starts
|
|
79
|
-
Bridge.log("Audio:
|
|
79
|
+
Bridge.log("Audio: Mentra Live is paired (preserving A2DP for music)")
|
|
80
80
|
glassesBluetoothClassicConnected = true
|
|
81
81
|
} else {
|
|
82
82
|
glassesBluetoothClassicConnected = false
|
|
@@ -88,7 +88,7 @@ struct ViewState {
|
|
|
88
88
|
guard let self = self else { return }
|
|
89
89
|
|
|
90
90
|
if connected {
|
|
91
|
-
Bridge.log("Audio:
|
|
91
|
+
Bridge.log("Audio: Device paired and connected")
|
|
92
92
|
// Don't activate - let PhoneMic.swift handle that when recording starts
|
|
93
93
|
self.glassesBluetoothClassicConnected = true
|
|
94
94
|
} else {
|
|
@@ -320,7 +320,7 @@ struct ViewState {
|
|
|
320
320
|
),
|
|
321
321
|
]
|
|
322
322
|
|
|
323
|
-
// Scene slots
|
|
323
|
+
// Scene slots - one whole SceneFrame per view (main/dashboard), parallel to
|
|
324
324
|
// viewStates. When a slot holds a scene, viewStates carries a "scene"
|
|
325
325
|
// sentinel so sendCurrentState routes here. Holding the WHOLE frame keeps
|
|
326
326
|
// native re-dispatch coherent (dashboard exit re-applies a complete scene).
|
|
@@ -417,6 +417,10 @@ struct ViewState {
|
|
|
417
417
|
handlePcm(pcmData)
|
|
418
418
|
}
|
|
419
419
|
|
|
420
|
+
func reportGlassesAudioActivity() {
|
|
421
|
+
lastLc3Event = Date()
|
|
422
|
+
}
|
|
423
|
+
|
|
420
424
|
func handlePcm(_ pcmData: Data) {
|
|
421
425
|
handleSendingPcm(pcmData)
|
|
422
426
|
|
|
@@ -626,6 +630,8 @@ struct ViewState {
|
|
|
626
630
|
sgc = MentraLive()
|
|
627
631
|
} else if wearable.contains(DeviceTypes.NIMO) {
|
|
628
632
|
sgc = Nimo()
|
|
633
|
+
} else if wearable.contains(DeviceTypes.AR99) {
|
|
634
|
+
sgc = Ar99()
|
|
629
635
|
} else if wearable.contains(DeviceTypes.FRAME) {
|
|
630
636
|
// sgc = FrameManager()
|
|
631
637
|
}
|
|
@@ -722,7 +728,7 @@ struct ViewState {
|
|
|
722
728
|
)
|
|
723
729
|
case "positioned_text":
|
|
724
730
|
Bridge.log(
|
|
725
|
-
"MAN: positioned_text
|
|
731
|
+
"MAN: positioned_text -> text='\(currentViewState.text)' rect=\(currentViewState.bmpX ?? 0),\(currentViewState.bmpY ?? 0) \(currentViewState.bmpWidth ?? 576)x\(currentViewState.bmpHeight ?? 288)"
|
|
726
732
|
)
|
|
727
733
|
await sgc?.sendPositionedText(
|
|
728
734
|
currentViewState.text,
|
|
@@ -804,6 +810,11 @@ struct ViewState {
|
|
|
804
810
|
return
|
|
805
811
|
}
|
|
806
812
|
|
|
813
|
+
if PhoneAudioMonitor.getInstance().isOwnAppAudioPlaying() {
|
|
814
|
+
Bridge.log("MAN: Mentra audio is playing; skipping glasses mic recovery")
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
|
|
807
818
|
let timeSinceLastLc3Event = Date().timeIntervalSince(lastLc3Event ?? Date())
|
|
808
819
|
if timeSinceLastLc3Event > 5 {
|
|
809
820
|
Bridge.log("MAN: No audio activity in the last 5 seconds from glasses, reinitializing glasses mic")
|
|
@@ -861,7 +872,7 @@ struct ViewState {
|
|
|
861
872
|
let deviceName = session.availableInputs?.first(where: {
|
|
862
873
|
$0.portName.localizedCaseInsensitiveContains(audioDevicePattern)
|
|
863
874
|
})?.portName
|
|
864
|
-
Bridge.log("MAN:
|
|
875
|
+
Bridge.log("MAN: Successfully detected newly paired device '\(deviceName)'")
|
|
865
876
|
glassesBluetoothClassicConnected = true
|
|
866
877
|
} else {
|
|
867
878
|
glassesBluetoothClassicConnected = false
|
|
@@ -928,7 +939,7 @@ struct ViewState {
|
|
|
928
939
|
// stale/torn-down connection.
|
|
929
940
|
guard let sgc = self?.sgc else { return }
|
|
930
941
|
let h = DeviceStore.shared.get("bluetooth", "dashboard_height") as? Int ?? 4
|
|
931
|
-
// Fall back to the canonical default (2), matching DeviceStore
|
|
942
|
+
// Fall back to the canonical default (2), matching DeviceStore, not 1.
|
|
932
943
|
let rawDepth = DeviceStore.shared.get("bluetooth", "dashboard_depth") as? Int ?? 2
|
|
933
944
|
let d = min(max(rawDepth, 1), 4)
|
|
934
945
|
sgc.setDashboardPosition(h, d)
|
|
@@ -962,6 +973,10 @@ struct ViewState {
|
|
|
962
973
|
Bridge.saveSetting("default_wearable", defaultWearable)
|
|
963
974
|
Bridge.saveSetting("device_name", deviceName)
|
|
964
975
|
Bridge.saveSetting("device_address", deviceAddress)
|
|
976
|
+
if defaultWearable.contains(DeviceTypes.AR99) {
|
|
977
|
+
let projectName = (DeviceStore.shared.get("bluetooth", "project_name") as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
978
|
+
Bridge.saveSetting("project_name", projectName)
|
|
979
|
+
}
|
|
965
980
|
|
|
966
981
|
}
|
|
967
982
|
|
|
@@ -1168,7 +1183,7 @@ struct ViewState {
|
|
|
1168
1183
|
// Legacy→scene handoff: stale legacy content (e.g. a cloud app's
|
|
1169
1184
|
// text wall) must not linger under the scene's elements.
|
|
1170
1185
|
// clearDisplay is the per-device "wipe what's there" (blank-in-place
|
|
1171
|
-
// on G2
|
|
1186
|
+
// on G2 - no page rebuild).
|
|
1172
1187
|
let prevLegacyType = viewStates[stateIndex].layoutType
|
|
1173
1188
|
if !prevLegacyType.isEmpty, prevLegacyType != "clear_view", prevLegacyType != "scene" {
|
|
1174
1189
|
sgc?.clearDisplay()
|
|
@@ -1187,7 +1202,7 @@ struct ViewState {
|
|
|
1187
1202
|
}
|
|
1188
1203
|
|
|
1189
1204
|
// Store the REDISPATCH form: any later sendCurrentState (dashboard
|
|
1190
|
-
// exit, head-up return) must repaint the whole frame
|
|
1205
|
+
// exit, head-up return) must repaint the whole frame - the original
|
|
1191
1206
|
// annotations are only valid for the first dispatch right now.
|
|
1192
1207
|
sceneStates[stateIndex] = frame.asReplay()
|
|
1193
1208
|
viewStates[stateIndex] = ViewState(
|
|
@@ -1201,7 +1216,7 @@ struct ViewState {
|
|
|
1201
1216
|
}
|
|
1202
1217
|
}
|
|
1203
1218
|
|
|
1204
|
-
/// Guarded scene dispatch
|
|
1219
|
+
/// Guarded scene dispatch - mirrors sendCurrentState's send conditions.
|
|
1205
1220
|
private func dispatchSceneFrame(_ frame: SceneFrame) {
|
|
1206
1221
|
if screenDisabled { return }
|
|
1207
1222
|
if sgc?.type.contains(DeviceTypes.SIMULATED) ?? true { return }
|
|
@@ -1330,6 +1345,24 @@ struct ViewState {
|
|
|
1330
1345
|
(sgc as? MentraLive)?.sendOtaQueryStatus()
|
|
1331
1346
|
}
|
|
1332
1347
|
|
|
1348
|
+
func startAr99OtaFromFile(_ path: String) throws -> Bool {
|
|
1349
|
+
guard let ar99 = sgc as? Ar99 else {
|
|
1350
|
+
throw BluetoothSdkError(code: "unsupported_device", message: "This command requires AR99 glasses.")
|
|
1351
|
+
}
|
|
1352
|
+
return ar99.startOtaFromFile(path)
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
func cancelAr99Ota() {
|
|
1356
|
+
(sgc as? Ar99)?.cancelAr99Ota()
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
func sendAr99FactoryReset() throws {
|
|
1360
|
+
guard let ar99 = sgc as? Ar99 else {
|
|
1361
|
+
throw BluetoothSdkError(code: "unsupported_device", message: "This command requires AR99 glasses.")
|
|
1362
|
+
}
|
|
1363
|
+
ar99.sendFactoryReset()
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1333
1366
|
private func liveSgc() throws -> MentraLive {
|
|
1334
1367
|
guard let live = sgc as? MentraLive else {
|
|
1335
1368
|
throw BluetoothSdkError(code: "unsupported_device", message: "This command requires Mentra Live glasses.")
|
|
@@ -1409,7 +1442,9 @@ struct ViewState {
|
|
|
1409
1442
|
size: PhotoSize,
|
|
1410
1443
|
mode: PhotoMode = .photo,
|
|
1411
1444
|
exposureTimeNs: Double?,
|
|
1412
|
-
durationMs: Int
|
|
1445
|
+
durationMs: Int,
|
|
1446
|
+
zsl: Bool? = nil,
|
|
1447
|
+
mfnr: Bool? = nil
|
|
1413
1448
|
) throws {
|
|
1414
1449
|
guard let live = sgc as? MentraLive else {
|
|
1415
1450
|
// Fail fast like other camera commands so the SDK promise rejects immediately instead
|
|
@@ -1422,7 +1457,9 @@ struct ViewState {
|
|
|
1422
1457
|
size: size,
|
|
1423
1458
|
mode: mode,
|
|
1424
1459
|
exposureTimeNs: exposureTimeNs,
|
|
1425
|
-
durationMs: durationMs
|
|
1460
|
+
durationMs: durationMs,
|
|
1461
|
+
zsl: zsl,
|
|
1462
|
+
mfnr: mfnr
|
|
1426
1463
|
)
|
|
1427
1464
|
}
|
|
1428
1465
|
|
|
@@ -1498,7 +1535,7 @@ struct ViewState {
|
|
|
1498
1535
|
)
|
|
1499
1536
|
}
|
|
1500
1537
|
|
|
1501
|
-
/// Mentra Live only: K900 `cs_getvol` / `sr_getvol` (step volume 0
|
|
1538
|
+
/// Mentra Live only: K900 `cs_getvol` / `sr_getvol` (step volume 0-5).
|
|
1502
1539
|
func getGlassesMediaVolume() async throws -> [String: Any] {
|
|
1503
1540
|
guard let live = sgc as? MentraLive else {
|
|
1504
1541
|
throw NSError(
|
|
@@ -1561,7 +1598,7 @@ struct ViewState {
|
|
|
1561
1598
|
)
|
|
1562
1599
|
guard let sgc else {
|
|
1563
1600
|
Bridge.log(
|
|
1564
|
-
"MAN: PHOTO PIPELINE
|
|
1601
|
+
"MAN: PHOTO PIPELINE - sgc is null (glasses not connected); dropping requestId=\(routed.requestId)"
|
|
1565
1602
|
)
|
|
1566
1603
|
return
|
|
1567
1604
|
}
|
|
@@ -1573,13 +1610,19 @@ struct ViewState {
|
|
|
1573
1610
|
Bridge.log("MAN: No default wearable, returning")
|
|
1574
1611
|
return
|
|
1575
1612
|
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1613
|
+
let reconnectTarget =
|
|
1614
|
+
if defaultWearable.contains(DeviceTypes.AR99), !deviceAddress.isEmpty {
|
|
1615
|
+
deviceAddress
|
|
1616
|
+
} else {
|
|
1617
|
+
deviceName
|
|
1618
|
+
}
|
|
1619
|
+
if reconnectTarget.isEmpty {
|
|
1620
|
+
Bridge.log("MAN: No reconnect target, returning")
|
|
1578
1621
|
return
|
|
1579
1622
|
}
|
|
1580
1623
|
initSGC(defaultWearable)
|
|
1581
1624
|
searching = true
|
|
1582
|
-
sgc?.connectById(
|
|
1625
|
+
sgc?.connectById(reconnectTarget)
|
|
1583
1626
|
connectDefaultController()
|
|
1584
1627
|
}
|
|
1585
1628
|
|
|
@@ -1669,6 +1712,11 @@ struct ViewState {
|
|
|
1669
1712
|
shouldSendBootingMessage = true // Reset for next first connect
|
|
1670
1713
|
// clear glasses properties:
|
|
1671
1714
|
DeviceStore.shared.apply("glasses", "deviceModel", "")
|
|
1715
|
+
// A manufacturing serial is session-bound. Clear it on every disconnect so a
|
|
1716
|
+
// previously connected pair's serial can never be reported for the next
|
|
1717
|
+
// connection (e.g. switching from G1/Ar99, which populate it from the
|
|
1718
|
+
// advertisement, to a model that never writes it, like G2).
|
|
1719
|
+
DeviceStore.shared.apply("glasses", "serialNumber", "")
|
|
1672
1720
|
DeviceStore.shared.apply("glasses", "fullyBooted", false)
|
|
1673
1721
|
DeviceStore.shared.apply("glasses", "connected", false)
|
|
1674
1722
|
DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
|
|
@@ -1700,6 +1748,7 @@ struct ViewState {
|
|
|
1700
1748
|
Bridge.saveSetting("default_wearable", "")
|
|
1701
1749
|
Bridge.saveSetting("device_name", "")
|
|
1702
1750
|
Bridge.saveSetting("device_address", "")
|
|
1751
|
+
Bridge.saveSetting("project_name", "")
|
|
1703
1752
|
}
|
|
1704
1753
|
|
|
1705
1754
|
func forgetController() {
|
|
@@ -1758,3 +1807,4 @@ struct ViewState {
|
|
|
1758
1807
|
cancellables.removeAll()
|
|
1759
1808
|
}
|
|
1760
1809
|
}
|
|
1810
|
+
|
|
@@ -88,12 +88,13 @@ class DeviceStore {
|
|
|
88
88
|
store.set("bluetooth", "contextual_dashboard", true)
|
|
89
89
|
store.set("bluetooth", "gallery_mode", true)
|
|
90
90
|
store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.voiceActivityDetectionEnabled)
|
|
91
|
+
store.set("bluetooth", "loudness_gate_enabled", BluetoothSdkDefaults.loudnessGateEnabled)
|
|
91
92
|
// Mentra Nex feature flag (off by default; toggled from Nex Developer Settings):
|
|
92
93
|
store.set("bluetooth", "nex_chinese_captions", false)
|
|
93
94
|
store.set("bluetooth", "screen_disabled", false)
|
|
94
95
|
store.set("bluetooth", "button_photo_size", "max")
|
|
95
96
|
store.set("bluetooth", "button_max_recording_time", 10)
|
|
96
|
-
store.set("bluetooth", "camera_fov", ["fov":
|
|
97
|
+
store.set("bluetooth", "camera_fov", ["fov": 118, "roi_position": 0])
|
|
97
98
|
store.set("bluetooth", "button_video_width", 1280)
|
|
98
99
|
store.set("bluetooth", "button_video_height", 720)
|
|
99
100
|
store.set("bluetooth", "button_video_fps", 30)
|
|
@@ -245,6 +246,9 @@ class DeviceStore {
|
|
|
245
246
|
case ("bluetooth", "voice_activity_detection_enabled"):
|
|
246
247
|
DeviceManager.shared.sgc?.sendVoiceActivityDetectionSetting()
|
|
247
248
|
|
|
249
|
+
case ("bluetooth", "loudness_gate_enabled"):
|
|
250
|
+
DeviceManager.shared.sgc?.sendLoudnessGateSetting()
|
|
251
|
+
|
|
248
252
|
case ("bluetooth", "screen_disabled"):
|
|
249
253
|
if let disabled = value as? Bool {
|
|
250
254
|
if disabled {
|
|
@@ -190,7 +190,7 @@ public final class MentraBluetoothSDK {
|
|
|
190
190
|
private var shouldRestoreControllerOnBluetoothRestore = false
|
|
191
191
|
private var bridgeEventSinkId: String?
|
|
192
192
|
private var storeListenerId: String?
|
|
193
|
-
private let defaultDeviceKeys: Set<String> = ["default_wearable", "device_name", "device_address"]
|
|
193
|
+
private let defaultDeviceKeys: Set<String> = ["default_wearable", "device_name", "device_address", "project_name"]
|
|
194
194
|
private let videoUploadStopTimeoutMs = 10 * 60 * 1000
|
|
195
195
|
private var suppressDefaultDeviceEvents = false
|
|
196
196
|
private var defaultDeviceApplyGeneration = 0
|
|
@@ -308,6 +308,7 @@ public final class MentraBluetoothSDK {
|
|
|
308
308
|
DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "default_wearable", device.model.deviceType)
|
|
309
309
|
DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_name", device.name)
|
|
310
310
|
DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_address", device.identifier ?? "")
|
|
311
|
+
DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "project_name", device.projectName ?? "")
|
|
311
312
|
finishDefaultDeviceApply(generation: generation)
|
|
312
313
|
}
|
|
313
314
|
|
|
@@ -318,6 +319,7 @@ public final class MentraBluetoothSDK {
|
|
|
318
319
|
DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "default_wearable", "")
|
|
319
320
|
DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_name", "")
|
|
320
321
|
DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_address", "")
|
|
322
|
+
DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "project_name", "")
|
|
321
323
|
finishDefaultDeviceApply(generation: generation)
|
|
322
324
|
}
|
|
323
325
|
|
|
@@ -534,7 +536,11 @@ public final class MentraBluetoothSDK {
|
|
|
534
536
|
DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "voice_activity_detection_enabled", enabled)
|
|
535
537
|
}
|
|
536
538
|
|
|
537
|
-
|
|
539
|
+
public func setLoudnessGateEnabled(_ enabled: Bool) async throws {
|
|
540
|
+
DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "loudness_gate_enabled", enabled)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
@available(*, deprecated, message: "Sticky action-button photo presets are deprecated. Prefer per-request requestPhoto(...) options (e.g. mode .text for text sensor size/crop, or explicit per-shot fields). This method still works but will be removed in a future release.")
|
|
538
544
|
public func setPhotoCaptureDefaults(_ settings: PhotoCaptureDefaults) async throws -> SettingsAckEvent {
|
|
539
545
|
try await performSettingsCommand(
|
|
540
546
|
setting: "button_photo",
|
|
@@ -543,7 +549,7 @@ public final class MentraBluetoothSDK {
|
|
|
543
549
|
// Mirror Android: clear all cached scan-tuning keys so reconnect sync
|
|
544
550
|
// does not replay stale values after a reset.
|
|
545
551
|
let cat = ObservableStore.bluetoothCategory
|
|
546
|
-
for key in ["button_photo_mfnr", "button_photo_zsl", "button_photo_noise_reduction",
|
|
552
|
+
for key in ["button_photo_zsl_mfnr", "button_photo_mfnr", "button_photo_zsl", "button_photo_noise_reduction",
|
|
547
553
|
"button_photo_edge_enhancement", "button_photo_isp_digital_gain",
|
|
548
554
|
"button_photo_isp_analog_gain", "button_photo_ae_exposure_divisor",
|
|
549
555
|
"button_photo_iso_cap", "button_photo_compress", "button_photo_sound"] {
|
|
@@ -912,7 +918,9 @@ public final class MentraBluetoothSDK {
|
|
|
912
918
|
size: PhotoSize,
|
|
913
919
|
mode: PhotoMode = .photo,
|
|
914
920
|
exposureTimeNs: Double?,
|
|
915
|
-
durationMs: Int
|
|
921
|
+
durationMs: Int,
|
|
922
|
+
zsl: Bool? = nil,
|
|
923
|
+
mfnr: Bool? = nil
|
|
916
924
|
) async throws -> CameraStatusEvent {
|
|
917
925
|
let effectiveRequestId = nonBlankRequestId(requestId) ?? generatedCameraRequestId("warm")
|
|
918
926
|
let pending = PendingResponse<CameraStatusEvent>(operation: "camera warm up \(effectiveRequestId)")
|
|
@@ -924,7 +932,9 @@ public final class MentraBluetoothSDK {
|
|
|
924
932
|
size: size,
|
|
925
933
|
mode: mode,
|
|
926
934
|
exposureTimeNs: exposureTimeNs,
|
|
927
|
-
durationMs: durationMs
|
|
935
|
+
durationMs: durationMs,
|
|
936
|
+
zsl: zsl,
|
|
937
|
+
mfnr: mfnr
|
|
928
938
|
)
|
|
929
939
|
let event = try await pending.wait()
|
|
930
940
|
pendingCameraStatusRequests.removeValue(forKey: effectiveRequestId)
|
|
@@ -1261,6 +1271,20 @@ public final class MentraBluetoothSDK {
|
|
|
1261
1271
|
|
|
1262
1272
|
func sendOtaQueryStatus() async throws -> OtaQueryResult { try await queryOtaStatus() }
|
|
1263
1273
|
|
|
1274
|
+
func startAr99OtaFromFile(_ path: String) throws -> Bool {
|
|
1275
|
+
try requireGlassesConnected(operation: "start AR99 OTA")
|
|
1276
|
+
return try DeviceManager.shared.startAr99OtaFromFile(path)
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
func cancelAr99Ota() {
|
|
1280
|
+
DeviceManager.shared.cancelAr99Ota()
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
func sendAr99FactoryReset() throws {
|
|
1284
|
+
try requireGlassesConnected(operation: "factory reset AR99")
|
|
1285
|
+
try DeviceManager.shared.sendAr99FactoryReset()
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1264
1288
|
private func getFreshGlassesStatus() async -> GlassesStatus {
|
|
1265
1289
|
let status = glassesStatus
|
|
1266
1290
|
if !status.connected || !status.buildNumber.isEmpty {
|
|
@@ -1967,14 +1991,15 @@ public final class MentraBluetoothSDK {
|
|
|
1967
1991
|
guard let model = core["default_wearable"] as? String, !model.isEmpty else { return nil }
|
|
1968
1992
|
guard let name = core["device_name"] as? String, !name.isEmpty else { return nil }
|
|
1969
1993
|
let identifier = (core["device_address"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
1994
|
+
let projectName = (core["project_name"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
1970
1995
|
return Device(
|
|
1971
1996
|
model: DeviceModel.fromDeviceType(model),
|
|
1972
1997
|
name: name,
|
|
1973
|
-
identifier: identifier
|
|
1998
|
+
identifier: identifier,
|
|
1999
|
+
projectName: projectName
|
|
1974
2000
|
)
|
|
1975
2001
|
}
|
|
1976
|
-
|
|
1977
|
-
private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
|
|
2002
|
+
private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
|
|
1978
2003
|
guard let results = rawSearchResults as? [[String: Any]] else { return }
|
|
1979
2004
|
for result in results {
|
|
1980
2005
|
guard let name = result["name"] as? String else { continue }
|
|
@@ -2146,3 +2171,7 @@ public final class MentraBluetoothSDK {
|
|
|
2146
2171
|
}
|
|
2147
2172
|
}
|
|
2148
2173
|
}
|
|
2174
|
+
|
|
2175
|
+
|
|
2176
|
+
|
|
2177
|
+
|
|
@@ -88,9 +88,18 @@ enum OtaManifestChecker {
|
|
|
88
88
|
currentBuildNumber: String,
|
|
89
89
|
currentMtkVersion: String,
|
|
90
90
|
currentBesVersion: String,
|
|
91
|
-
manifest: OtaManifest
|
|
91
|
+
manifest: OtaManifest,
|
|
92
|
+
// Downgrade floor: a non-positive value (the default) disables downgrades entirely,
|
|
93
|
+
// matching ASG's fail-closed DowngradeGate and the engine. OEM SDK consumers that do not
|
|
94
|
+
// set a floor get upgrade-only behavior and are never told a downgrade is available that
|
|
95
|
+
// the glasses would refuse.
|
|
96
|
+
downgradeFloorVersionCode: Int = 0
|
|
92
97
|
) throws -> Bool {
|
|
93
|
-
try hasApkUpdate(
|
|
98
|
+
try hasApkUpdate(
|
|
99
|
+
currentBuildNumber: currentBuildNumber,
|
|
100
|
+
manifest: manifest,
|
|
101
|
+
downgradeFloorVersionCode: downgradeFloorVersionCode
|
|
102
|
+
) ||
|
|
94
103
|
hasMtkUpdate(patches: manifest.mtkPatches, currentVersion: currentMtkVersion) ||
|
|
95
104
|
hasBesUpdate(besFirmware: manifest.besFirmware, currentVersion: currentBesVersion)
|
|
96
105
|
}
|
|
@@ -113,7 +122,11 @@ enum OtaManifestChecker {
|
|
|
113
122
|
throw BluetoothSdkError(code: "invalid_ota_manifest", message: "OTA manifest is missing ASG app versionCode.")
|
|
114
123
|
}
|
|
115
124
|
|
|
116
|
-
private static func hasApkUpdate(
|
|
125
|
+
private static func hasApkUpdate(
|
|
126
|
+
currentBuildNumber: String,
|
|
127
|
+
manifest: OtaManifest,
|
|
128
|
+
downgradeFloorVersionCode: Int
|
|
129
|
+
) throws -> Bool {
|
|
117
130
|
guard let currentVersion = Int(currentBuildNumber) else {
|
|
118
131
|
throw BluetoothSdkError(
|
|
119
132
|
code: "invalid_glasses_version",
|
|
@@ -123,7 +136,37 @@ enum OtaManifestChecker {
|
|
|
123
136
|
guard let serverVersion = try latestAppInfo(manifest).versionCode else {
|
|
124
137
|
throw BluetoothSdkError(code: "invalid_ota_manifest", message: "OTA manifest is missing ASG app versionCode.")
|
|
125
138
|
}
|
|
126
|
-
|
|
139
|
+
// Only apps-shaped manifests are exact pins. A legacy top-level manifest (versionCode at
|
|
140
|
+
// the root, no apps entry) is NOT a pin and stays strictly upgrade-only, matching the
|
|
141
|
+
// engine TS checker and Kotlin.
|
|
142
|
+
let isExactPin = manifest.apps?[asgClientPackage]?.versionCode != nil
|
|
143
|
+
if !isExactPin {
|
|
144
|
+
return serverVersion > currentVersion
|
|
145
|
+
}
|
|
146
|
+
// Exact pin: any mismatch is an update in either direction (downgrades take the
|
|
147
|
+
// uninstall-then-reinstall detour on the glasses). A non-positive pin is only legitimate
|
|
148
|
+
// in the frozen legacy rescue manifests that pre-39 glasses are checked against; for
|
|
149
|
+
// modern glasses it means the manifest cannot verify anything, and that must surface as
|
|
150
|
+
// an error — never as "no update". Mirrors the Kotlin OtaManifestChecker.
|
|
151
|
+
if serverVersion <= 0 {
|
|
152
|
+
if currentVersion >= 39 {
|
|
153
|
+
throw BluetoothSdkError(
|
|
154
|
+
code: "invalid_ota_manifest",
|
|
155
|
+
message: "OTA manifest ASG pin is missing or zero; cannot verify update state."
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
if serverVersion > currentVersion {
|
|
161
|
+
return true
|
|
162
|
+
}
|
|
163
|
+
// Exact match: already on the pin, no update.
|
|
164
|
+
if serverVersion == currentVersion {
|
|
165
|
+
return false
|
|
166
|
+
}
|
|
167
|
+
// Downgrade: only actionable at/above the enabled floor; a non-positive floor disables
|
|
168
|
+
// downgrades (fail closed, matching ASG and the engine).
|
|
169
|
+
return downgradeFloorVersionCode > 0 && serverVersion >= downgradeFloorVersionCode
|
|
127
170
|
}
|
|
128
171
|
|
|
129
172
|
private static func hasMtkUpdate(patches: [MtkPatch]?, currentVersion: String) throws -> Bool {
|
|
@@ -169,15 +169,16 @@ public enum CameraRoiPosition: Int {
|
|
|
169
169
|
public struct CameraFov {
|
|
170
170
|
public static let minFov = 62
|
|
171
171
|
public static let maxFov = 118
|
|
172
|
-
public static let defaultFov =
|
|
172
|
+
public static let defaultFov = 118
|
|
173
173
|
public static let narrowFov = 82
|
|
174
|
+
public static let standardFov = 102
|
|
174
175
|
public static let defaultRoiPosition = CameraRoiPosition.center
|
|
175
176
|
public static let narrow = CameraFov(
|
|
176
177
|
fov: CameraFov.narrowFov,
|
|
177
178
|
roiPosition: CameraFov.defaultRoiPosition
|
|
178
179
|
)
|
|
179
180
|
public static let standard = CameraFov(
|
|
180
|
-
fov: CameraFov.
|
|
181
|
+
fov: CameraFov.standardFov,
|
|
181
182
|
roiPosition: CameraFov.defaultRoiPosition
|
|
182
183
|
)
|
|
183
184
|
public static let wide = CameraFov(
|
|
@@ -302,12 +303,26 @@ public struct PhotoRequest {
|
|
|
302
303
|
self.ispDigitalGain = ispDigitalGain
|
|
303
304
|
self.ispAnalogGain = ispAnalogGain
|
|
304
305
|
self.mode = mode
|
|
305
|
-
self.transferMethod = transferMethod
|
|
306
|
+
self.transferMethod = transferMethod
|
|
306
307
|
}
|
|
307
308
|
|
|
308
|
-
public static func from(params: [String: Any]) -> PhotoRequest {
|
|
309
|
+
public static func from(params: [String: Any]) throws -> PhotoRequest {
|
|
309
310
|
let sizeRaw = params["size"] as? String ?? "medium"
|
|
310
311
|
let compressRaw = params["compress"] as? String ?? "none"
|
|
312
|
+
let transferMethod: String
|
|
313
|
+
if let rawValue = params["transferMethod"] {
|
|
314
|
+
guard let rawString = rawValue as? String,
|
|
315
|
+
rawString == "auto" || rawString == "direct" || rawString == "ble"
|
|
316
|
+
else {
|
|
317
|
+
throw BluetoothSdkError(
|
|
318
|
+
code: "invalid_photo_transfer_method",
|
|
319
|
+
message: "Invalid transferMethod \(String(describing: rawValue)). Expected auto, direct, or ble."
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
transferMethod = rawString
|
|
323
|
+
} else {
|
|
324
|
+
transferMethod = "auto"
|
|
325
|
+
}
|
|
311
326
|
let exposureTimeNs: Double?
|
|
312
327
|
switch params["exposureTimeNs"] {
|
|
313
328
|
case let value as Double:
|
|
@@ -371,7 +386,7 @@ public struct PhotoRequest {
|
|
|
371
386
|
ispDigitalGain: optionalInt("ispDigitalGain"),
|
|
372
387
|
ispAnalogGain: params["ispAnalogGain"] as? String,
|
|
373
388
|
mode: PhotoMode(normalizedRawValue: params["mode"] as? String),
|
|
374
|
-
transferMethod:
|
|
389
|
+
transferMethod: transferMethod
|
|
375
390
|
)
|
|
376
391
|
}
|
|
377
392
|
|
|
@@ -6,7 +6,7 @@ enum BleTraceLogger {
|
|
|
6
6
|
private static let log = OSLog(subsystem: "com.mentra.bluetoothsdk", category: "MentraBleTrace")
|
|
7
7
|
private static let maxPayloadChars = 3000
|
|
8
8
|
private static let k900Type = "k900"
|
|
9
|
-
private static let sensitiveKeyParts = ["password", "pass", "token", "secret", "authorization", "auth", "email"]
|
|
9
|
+
private static let sensitiveKeyParts = ["password", "pass", "token", "secret", "authorization", "auth", "email", "serial"]
|
|
10
10
|
|
|
11
11
|
static func logJson(
|
|
12
12
|
direction: String,
|
|
@@ -39,6 +39,7 @@ final class BluetoothSdkAnalytics {
|
|
|
39
39
|
private let configuration: BluetoothSdkAnalyticsConfiguration
|
|
40
40
|
private var startedCaptured = false
|
|
41
41
|
private var lastConnected = false
|
|
42
|
+
private var identifiedCapturedForConnection = false
|
|
42
43
|
|
|
43
44
|
init(configuration: BluetoothSdkAnalyticsConfiguration) {
|
|
44
45
|
self.configuration = configuration.resolvedForApp()
|
|
@@ -47,6 +48,12 @@ final class BluetoothSdkAnalytics {
|
|
|
47
48
|
func initializeGlassesStatus(_ status: GlassesStatus) {
|
|
48
49
|
stateQueue.sync {
|
|
49
50
|
lastConnected = status.analyticsConnected
|
|
51
|
+
// Only treat identification as already captured when a valid serial is
|
|
52
|
+
// present at init. If the glasses are connected but the serial has not
|
|
53
|
+
// arrived yet (Mentra Live fills it via version_info after connect), leave
|
|
54
|
+
// this false so the identify event still fires once the serial arrives.
|
|
55
|
+
identifiedCapturedForConnection =
|
|
56
|
+
status.analyticsConnected && status.serialNumber.validManufacturingSerial != nil
|
|
50
57
|
}
|
|
51
58
|
}
|
|
52
59
|
|
|
@@ -72,7 +79,12 @@ final class BluetoothSdkAnalytics {
|
|
|
72
79
|
let wasConnected = lastConnected
|
|
73
80
|
lastConnected = isConnected
|
|
74
81
|
guard configuration.isReady else { return }
|
|
82
|
+
guard isConnected else {
|
|
83
|
+
identifiedCapturedForConnection = false
|
|
84
|
+
return
|
|
85
|
+
}
|
|
75
86
|
if isConnected, !wasConnected {
|
|
87
|
+
identifiedCapturedForConnection = false
|
|
76
88
|
var properties: [String: Any] = [
|
|
77
89
|
"event_kind": "glasses_connected",
|
|
78
90
|
"fully_booted": status.fullyBooted,
|
|
@@ -81,7 +93,25 @@ final class BluetoothSdkAnalytics {
|
|
|
81
93
|
properties["glasses_model"] = status.deviceModel
|
|
82
94
|
}
|
|
83
95
|
capture(event: "bluetooth_sdk_glasses_connected", properties: properties, configuration: configuration)
|
|
96
|
+
// Fall through: a serial already present at connect time (G1/Ar99
|
|
97
|
+
// report it in the advertisement) should be identified now rather than
|
|
98
|
+
// waiting for some later, unrelated glasses-store update to run.
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
guard !identifiedCapturedForConnection,
|
|
102
|
+
let serialNumber = status.serialNumber.validManufacturingSerial
|
|
103
|
+
else { return }
|
|
104
|
+
identifiedCapturedForConnection = true
|
|
105
|
+
var properties: [String: Any] = [
|
|
106
|
+
"event_kind": "glasses_identified",
|
|
107
|
+
"fully_booted": status.fullyBooted,
|
|
108
|
+
"glasses_device_id": serialNumber,
|
|
109
|
+
"glasses_device_id_type": "manufacturing_serial",
|
|
110
|
+
]
|
|
111
|
+
if !status.deviceModel.isEmpty {
|
|
112
|
+
properties["glasses_model"] = status.deviceModel
|
|
84
113
|
}
|
|
114
|
+
capture(event: "bluetooth_sdk_glasses_identified", properties: properties, configuration: configuration)
|
|
85
115
|
}
|
|
86
116
|
}
|
|
87
117
|
|
|
@@ -116,6 +146,7 @@ final class BluetoothSdkAnalytics {
|
|
|
116
146
|
"event_source": "mentra_bluetooth_sdk",
|
|
117
147
|
"sdk_platform": "ios",
|
|
118
148
|
"sdk_surface": configuration.surface,
|
|
149
|
+
"app_identifier": Bundle.main.bundleIdentifier ?? "",
|
|
119
150
|
"app_bundle_identifier": Bundle.main.bundleIdentifier ?? "",
|
|
120
151
|
"os_platform": "ios",
|
|
121
152
|
"os_version": ProcessInfo.processInfo.operatingSystemVersionString,
|
|
@@ -148,6 +179,14 @@ private extension GlassesStatus {
|
|
|
148
179
|
}
|
|
149
180
|
}
|
|
150
181
|
|
|
182
|
+
private extension String {
|
|
183
|
+
var validManufacturingSerial: String? {
|
|
184
|
+
let normalized = trimmingCharacters(in: .whitespacesAndNewlines)
|
|
185
|
+
guard !normalized.isEmpty, normalized.contains(where: { $0 != "0" }) else { return nil }
|
|
186
|
+
return normalized
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
151
190
|
private extension BluetoothSdkAnalyticsConfiguration {
|
|
152
191
|
func resolvedForApp() -> BluetoothSdkAnalyticsConfiguration {
|
|
153
192
|
let disabledByApp = Bundle.main.object(forInfoDictionaryKey: "MentraBluetoothSdkAnalyticsDisabled") as? Bool == true
|