@mentra/bluetooth-sdk 3.3.0-dev.549 → 3.3.0-dev.553
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/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothBackgroundMode.swift +32 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +14 -0
- package/ios/Source/sgcs/MentraLive.swift +111 -3
- package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
- package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
- package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
- package/package.json +2 -2
- package/plugin/build/withIos.d.ts +2 -0
- package/plugin/build/withIos.js +12 -1
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
|
|
|
3
3
|
/** Generated by release CI. Do not edit in a release checkout. */
|
|
4
4
|
internal object GeneratedReleaseMetadata {
|
|
5
5
|
const val FAMILY_BASE_VERSION: String = "3.3.0"
|
|
6
|
-
const val RELEASE_IDENTITY: String = "3.3.0-dev.
|
|
7
|
-
const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
9
|
-
const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.
|
|
10
|
-
const val OTA_MANIFEST_SHA256: String = "
|
|
6
|
+
const val RELEASE_IDENTITY: String = "3.3.0-dev.553"
|
|
7
|
+
const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.553"
|
|
8
|
+
const val SOURCE_COMMIT: String = "afdba2145753b018cd3942282d93cf21b8bd1675"
|
|
9
|
+
const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.553.json"
|
|
10
|
+
const val OTA_MANIFEST_SHA256: String = "899e2022f1b22cfe4bd24763e5ddd641178ad782ae9f9c9d5edf43ea5e269f00"
|
|
11
11
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"familyBaseVersion": "3.3.0",
|
|
4
|
-
"releaseIdentity": "3.3.0-dev.
|
|
5
|
-
"releaseSetId": "mentra-3.3.0-dev.
|
|
6
|
-
"sourceCommit": "
|
|
7
|
-
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.
|
|
8
|
-
"otaManifestSha256": "
|
|
4
|
+
"releaseIdentity": "3.3.0-dev.553",
|
|
5
|
+
"releaseSetId": "mentra-3.3.0-dev.553",
|
|
6
|
+
"sourceCommit": "afdba2145753b018cd3942282d93cf21b8bd1675",
|
|
7
|
+
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.553.json",
|
|
8
|
+
"otaManifestSha256": "899e2022f1b22cfe4bd24763e5ddd641178ad782ae9f9c9d5edf43ea5e269f00"
|
|
9
9
|
});
|
|
10
10
|
//# sourceMappingURL=releaseMetadata.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.3.0\",\n \"releaseIdentity\": \"3.3.0-dev.
|
|
1
|
+
{"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.3.0\",\n \"releaseIdentity\": \"3.3.0-dev.553\",\n \"releaseSetId\": \"mentra-3.3.0-dev.553\",\n \"sourceCommit\": \"afdba2145753b018cd3942282d93cf21b8bd1675\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.553.json\",\n \"otaManifestSha256\": \"899e2022f1b22cfe4bd24763e5ddd641178ad782ae9f9c9d5edf43ea5e269f00\"\n})\n"]}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Whether the host app lets iOS keep the glasses link working while the app is suspended.
|
|
4
|
+
///
|
|
5
|
+
/// The SDK keeps a pending reconnection open after the glasses drop the link (for example while
|
|
6
|
+
/// their Bluetooth chip restarts during a software update). iOS completes it, and delivers BLE
|
|
7
|
+
/// events, only to apps that declare the `bluetooth-central` background mode. A Swift package
|
|
8
|
+
/// cannot declare it for the app, so the SDK checks it and warns instead.
|
|
9
|
+
enum BluetoothBackgroundMode {
|
|
10
|
+
static let requiredMode = "bluetooth-central"
|
|
11
|
+
|
|
12
|
+
/// Platform-independent plist check, so tests exercise it on every host.
|
|
13
|
+
static func declaresBluetoothCentral(_ infoDictionary: [String: Any]?) -> Bool {
|
|
14
|
+
guard let modes = infoDictionary?["UIBackgroundModes"] as? [String] else { return false }
|
|
15
|
+
return modes.contains(requiredMode)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static func isDeclared(in infoDictionary: [String: Any]?) -> Bool {
|
|
19
|
+
#if os(iOS)
|
|
20
|
+
return declaresBluetoothCentral(infoDictionary)
|
|
21
|
+
#else
|
|
22
|
+
// macOS apps are not suspended the same way and have no background modes.
|
|
23
|
+
return true
|
|
24
|
+
#endif
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static let missingWarning =
|
|
28
|
+
"MentraBluetoothSDK: WARNING Info.plist is missing UIBackgroundModes `bluetooth-central`. "
|
|
29
|
+
+ "While the phone is locked or the app is in the background, iOS will not reconnect to "
|
|
30
|
+
+ "the glasses after a link drop (for example during a software update) until the app "
|
|
31
|
+
+ "is opened. See https://docs.mentraglass.com/bluetooth-sdk/ios#background-operation"
|
|
32
|
+
}
|
|
@@ -16,7 +16,7 @@ enum BluetoothSdkDefaults {
|
|
|
16
16
|
/// silently disable it on every pair the app touches.
|
|
17
17
|
static let autoPowerOffEnabled = false
|
|
18
18
|
private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
|
|
19
|
-
private static let swiftPackageSdkVersion = "3.3.0-dev.
|
|
19
|
+
private static let swiftPackageSdkVersion = "3.3.0-dev.553"
|
|
20
20
|
private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
|
|
21
21
|
|
|
22
22
|
private static func normalizedSdkVersion(_ value: String?) -> String? {
|
|
@@ -3,9 +3,9 @@ import Foundation
|
|
|
3
3
|
/// Generated by release CI. Do not edit in a release checkout.
|
|
4
4
|
enum GeneratedReleaseMetadata {
|
|
5
5
|
static let familyBaseVersion = "3.3.0"
|
|
6
|
-
static let releaseIdentity = "3.3.0-dev.
|
|
7
|
-
static let releaseSetId = "mentra-3.3.0-dev.
|
|
8
|
-
static let sourceCommit = "
|
|
9
|
-
static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.
|
|
10
|
-
static let otaManifestSha256 = "
|
|
6
|
+
static let releaseIdentity = "3.3.0-dev.553"
|
|
7
|
+
static let releaseSetId = "mentra-3.3.0-dev.553"
|
|
8
|
+
static let sourceCommit = "afdba2145753b018cd3942282d93cf21b8bd1675"
|
|
9
|
+
static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.553.json"
|
|
10
|
+
static let otaManifestSha256 = "899e2022f1b22cfe4bd24763e5ddd641178ad782ae9f9c9d5edf43ea5e269f00"
|
|
11
11
|
}
|
|
@@ -299,8 +299,22 @@ public final class MentraBluetoothSDK {
|
|
|
299
299
|
private let wifiSessionCapabilities = WifiSessionCapabilities()
|
|
300
300
|
private var configuredOtaVersionUrl: String?
|
|
301
301
|
|
|
302
|
+
/// Whether the app declares the `bluetooth-central` background mode. When false on iOS, the
|
|
303
|
+
/// glasses stay disconnected after a link drop until the user opens the app, so a software
|
|
304
|
+
/// update started on a locked phone stalls. Always true on macOS.
|
|
305
|
+
public static var supportsBackgroundReconnect: Bool {
|
|
306
|
+
BluetoothBackgroundMode.isDeclared(in: Bundle.main.infoDictionary)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private static var didWarnMissingBackgroundMode = false
|
|
310
|
+
|
|
302
311
|
public init(configuration: MentraBluetoothSDKConfiguration = .default) {
|
|
303
312
|
self.configuration = configuration
|
|
313
|
+
// Once per process: apps may create the SDK more than once.
|
|
314
|
+
if !Self.didWarnMissingBackgroundMode, !Self.supportsBackgroundReconnect {
|
|
315
|
+
Self.didWarnMissingBackgroundMode = true
|
|
316
|
+
Bridge.log(BluetoothBackgroundMode.missingWarning)
|
|
317
|
+
}
|
|
304
318
|
analytics = BluetoothSdkAnalytics(configuration: configuration.analytics)
|
|
305
319
|
bluetoothAvailabilityListenerId = BluetoothAvailability.shared.addStateListener { [weak self] state in
|
|
306
320
|
Task { @MainActor [weak self] in
|
|
@@ -862,6 +862,7 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
862
862
|
Bridge.log("Connected to GATT server, discovering services...")
|
|
863
863
|
|
|
864
864
|
self.stopConnectionTimeout()
|
|
865
|
+
self.clearPendingReconnectTracking()
|
|
865
866
|
self.isConnecting = false
|
|
866
867
|
self.connectingPeripheral = nil
|
|
867
868
|
self.connectedPeripheral = peripheral
|
|
@@ -916,6 +917,7 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
916
917
|
return
|
|
917
918
|
}
|
|
918
919
|
self.connectingPeripheral = nil
|
|
920
|
+
self.clearPendingReconnectTracking()
|
|
919
921
|
Bridge.log("LIVE: Disconnected from GATT server")
|
|
920
922
|
|
|
921
923
|
self.isConnecting = false
|
|
@@ -945,8 +947,9 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
945
947
|
return
|
|
946
948
|
}
|
|
947
949
|
|
|
948
|
-
//
|
|
949
|
-
|
|
950
|
+
// Prefer a pending direct reconnect: it is the only path iOS completes while the
|
|
951
|
+
// app is suspended. Fall back to the scan backoff when it does not apply.
|
|
952
|
+
if !self.isKilled, !self.reconnectDirectly(to: peripheral) {
|
|
950
953
|
self.handleReconnection()
|
|
951
954
|
}
|
|
952
955
|
}
|
|
@@ -966,6 +969,7 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
966
969
|
Bridge.log("LIVE: Failed to connect to peripheral: \(errorDescription)")
|
|
967
970
|
|
|
968
971
|
self.stopConnectionTimeout()
|
|
972
|
+
self.clearPendingReconnectTracking()
|
|
969
973
|
self.isConnecting = false
|
|
970
974
|
self.closeL2capFileChannel()
|
|
971
975
|
self.connectedPeripheral = nil
|
|
@@ -1646,6 +1650,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1646
1650
|
private static let glassesMediaVolumeTimeoutSec: TimeInterval = 2.0
|
|
1647
1651
|
|
|
1648
1652
|
private var connectionTimeoutTimer: Timer?
|
|
1653
|
+
/// Peripheral of an armed pending reconnect (see `reconnectDirectly`).
|
|
1654
|
+
private var pendingReconnectPeripheral: CBPeripheral?
|
|
1655
|
+
private var pendingReconnect = MentraLivePendingReconnect()
|
|
1656
|
+
private var pendingReconnectSettleWorkItem: DispatchWorkItem?
|
|
1649
1657
|
private var reconnectionWorkItem: DispatchWorkItem?
|
|
1650
1658
|
private var requiresAncs = true
|
|
1651
1659
|
|
|
@@ -1713,6 +1721,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1713
1721
|
|
|
1714
1722
|
// clear the saved device name:
|
|
1715
1723
|
UserDefaults.standard.set("", forKey: PREFS_DEVICE_NAME)
|
|
1724
|
+
cancelPendingReconnect(reason: "discovery")
|
|
1716
1725
|
|
|
1717
1726
|
manualDiscoveryActive = true
|
|
1718
1727
|
startScan()
|
|
@@ -1725,6 +1734,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1725
1734
|
}
|
|
1726
1735
|
|
|
1727
1736
|
private func enterPairingYield(windowMs: Int) {
|
|
1737
|
+
cancelPendingReconnect(reason: "pairing_yield")
|
|
1728
1738
|
pairingYieldActive = true
|
|
1729
1739
|
pairingYieldAwaitingReclaim = false
|
|
1730
1740
|
pairingYieldEndWorkItem?.cancel()
|
|
@@ -1805,6 +1815,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1805
1815
|
return
|
|
1806
1816
|
}
|
|
1807
1817
|
Bridge.log("connectById: \(deviceName)")
|
|
1818
|
+
cancelPendingReconnect(reason: "connect_by_id")
|
|
1808
1819
|
// Save the device name for future reconnection
|
|
1809
1820
|
UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
|
|
1810
1821
|
|
|
@@ -2357,15 +2368,59 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2357
2368
|
}
|
|
2358
2369
|
Bridge.log("LIVE: Connecting to device: \(peripheral.identifier.uuidString)")
|
|
2359
2370
|
|
|
2371
|
+
// A timed attempt supersedes any pending reconnect: withdraw one aimed at other
|
|
2372
|
+
// glasses, and drop tracking for this one so its settle timer cannot touch this attempt.
|
|
2373
|
+
if let pending = pendingReconnectPeripheral, pending !== peripheral {
|
|
2374
|
+
cancelPendingReconnect(reason: "superseded")
|
|
2375
|
+
} else {
|
|
2376
|
+
clearPendingReconnectTracking()
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2360
2379
|
isConnecting = true
|
|
2361
2380
|
updateConnectionState(ConnTypes.CONNECTING)
|
|
2362
2381
|
connectingPeripheral = peripheral
|
|
2363
2382
|
connectedPeripheral = peripheral
|
|
2364
|
-
peripheral.delegate = self
|
|
2365
2383
|
|
|
2366
2384
|
// Set connection timeout
|
|
2367
2385
|
startConnectionTimeout()
|
|
2368
2386
|
|
|
2387
|
+
issueConnect(peripheral)
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
/// Re-arms a pending connection to the glasses that just dropped. Unlike `connectToDevice`,
|
|
2391
|
+
/// it sets no timeout: CoreBluetooth keeps the request open until the glasses advertise
|
|
2392
|
+
/// again (for example after a BES firmware reset), completes it in the background and wakes
|
|
2393
|
+
/// the app. Discovery, connect-by-id, pairing yield and `destroy()` cancel it through
|
|
2394
|
+
/// `cancelPendingReconnect`. Returns false when the policy does not apply, so the caller can
|
|
2395
|
+
/// fall back to scanning.
|
|
2396
|
+
private func reconnectDirectly(to peripheral: CBPeripheral) -> Bool {
|
|
2397
|
+
guard let centralManager,
|
|
2398
|
+
MentraLiveConnectionAttemptPolicy.shouldReconnectDirectly(
|
|
2399
|
+
isKilled: isKilled,
|
|
2400
|
+
pairingYieldActive: pairingYieldActive,
|
|
2401
|
+
bluetoothPoweredOn: centralManager.state == .poweredOn,
|
|
2402
|
+
peripheralName: peripheral.name,
|
|
2403
|
+
savedDeviceName: UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME)
|
|
2404
|
+
)
|
|
2405
|
+
else {
|
|
2406
|
+
return false
|
|
2407
|
+
}
|
|
2408
|
+
Bridge.log(
|
|
2409
|
+
"LIVE: Pending direct reconnect to \(peripheral.identifier.uuidString) (no timeout, completes in background)"
|
|
2410
|
+
)
|
|
2411
|
+
isConnecting = true
|
|
2412
|
+
updateConnectionState(ConnTypes.CONNECTING)
|
|
2413
|
+
connectingPeripheral = peripheral
|
|
2414
|
+
connectedPeripheral = peripheral
|
|
2415
|
+
pendingReconnectPeripheral = peripheral
|
|
2416
|
+
issueConnect(peripheral)
|
|
2417
|
+
schedulePendingReconnectSettle(attempt: pendingReconnect.arm())
|
|
2418
|
+
return true
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
/// The single CoreBluetooth connect call, shared by user-initiated and pending reconnects.
|
|
2422
|
+
private func issueConnect(_ peripheral: CBPeripheral) {
|
|
2423
|
+
peripheral.delegate = self
|
|
2369
2424
|
#if os(macOS)
|
|
2370
2425
|
centralManager?.connect(peripheral, options: nil)
|
|
2371
2426
|
#else
|
|
@@ -2380,6 +2435,58 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2380
2435
|
#endif
|
|
2381
2436
|
}
|
|
2382
2437
|
|
|
2438
|
+
/// A pending connect never times out, so the glasses may simply never return (battery
|
|
2439
|
+
/// dead, left behind). After the old scan backoff's span, report DISCONNECTED like that
|
|
2440
|
+
/// backoff did, but keep the request armed: iOS still completes it whenever the glasses
|
|
2441
|
+
/// advertise, and `didConnect` accepts it through `connectingPeripheral`.
|
|
2442
|
+
private func schedulePendingReconnectSettle(attempt: Int) {
|
|
2443
|
+
pendingReconnectSettleWorkItem?.cancel()
|
|
2444
|
+
let work = DispatchWorkItem { [weak self] in
|
|
2445
|
+
// Only the attempt that armed this timer may settle state; a failure, fallback
|
|
2446
|
+
// connect or re-arm clears or replaces it.
|
|
2447
|
+
guard let self, self.pendingReconnect.owns(attempt),
|
|
2448
|
+
self.connectionState != ConnTypes.CONNECTED
|
|
2449
|
+
else { return }
|
|
2450
|
+
Bridge.log("LIVE: Glasses still unreachable; reporting disconnected, pending reconnect stays armed")
|
|
2451
|
+
self.pendingReconnectSettleWorkItem = nil
|
|
2452
|
+
self.isConnecting = false
|
|
2453
|
+
self.connectedPeripheral = nil
|
|
2454
|
+
self.updateConnectionState(ConnTypes.DISCONNECTED)
|
|
2455
|
+
}
|
|
2456
|
+
pendingReconnectSettleWorkItem = work
|
|
2457
|
+
DispatchQueue.main.asyncAfter(
|
|
2458
|
+
deadline: .now() + .milliseconds(MentraLiveConnectionAttemptPolicy.pendingReconnectSettleMs),
|
|
2459
|
+
execute: work
|
|
2460
|
+
)
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
/// Stops tracking a pending reconnect after it connected; no CoreBluetooth call needed.
|
|
2464
|
+
private func clearPendingReconnectTracking() {
|
|
2465
|
+
pendingReconnectSettleWorkItem?.cancel()
|
|
2466
|
+
pendingReconnectSettleWorkItem = nil
|
|
2467
|
+
pendingReconnect.clear()
|
|
2468
|
+
pendingReconnectPeripheral = nil
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
/// Withdraws a pending reconnect before the user retargets (discovery, connect-by-id,
|
|
2472
|
+
/// pairing yield) or tears down, so iOS cannot later complete it to the previous glasses.
|
|
2473
|
+
private func cancelPendingReconnect(reason: String) {
|
|
2474
|
+
guard let peripheral = pendingReconnectPeripheral else {
|
|
2475
|
+
clearPendingReconnectTracking()
|
|
2476
|
+
return
|
|
2477
|
+
}
|
|
2478
|
+
clearPendingReconnectTracking()
|
|
2479
|
+
Bridge.log("LIVE: Cancelling pending reconnect to \(peripheral.identifier.uuidString) (\(reason))")
|
|
2480
|
+
if connectingPeripheral === peripheral {
|
|
2481
|
+
connectingPeripheral = nil
|
|
2482
|
+
}
|
|
2483
|
+
if connectedPeripheral === peripheral, connectionState != ConnTypes.CONNECTED {
|
|
2484
|
+
connectedPeripheral = nil
|
|
2485
|
+
}
|
|
2486
|
+
isConnecting = false
|
|
2487
|
+
centralManager?.cancelPeripheralConnection(peripheral)
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2383
2490
|
/// Opt the firmware into ANCS only after iOS has authorized this accessory.
|
|
2384
2491
|
/// Old firmware safely ignores the command, while new firmware stays inert
|
|
2385
2492
|
/// for old mobile clients that never send it.
|
|
@@ -5897,6 +6004,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5897
6004
|
closeL2capFileChannel()
|
|
5898
6005
|
|
|
5899
6006
|
// Disconnect BLE
|
|
6007
|
+
cancelPendingReconnect(reason: "destroy")
|
|
5900
6008
|
if let peripheral = connectedPeripheral {
|
|
5901
6009
|
centralManager?.cancelPeripheralConnection(peripheral)
|
|
5902
6010
|
}
|
|
@@ -61,4 +61,51 @@ enum MentraLiveConnectionAttemptPolicy {
|
|
|
61
61
|
) -> Bool {
|
|
62
62
|
!pairingYieldActive && matchesActiveAttempt
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
/// How long a pending reconnect reports CONNECTING before settling to DISCONNECTED. Matches
|
|
66
|
+
/// the span of the scan backoff it replaces (1+2+4+8+16 s, then 30 s × 5 attempts).
|
|
67
|
+
static let pendingReconnectSettleMs = 181_000
|
|
68
|
+
|
|
69
|
+
/// Whether an unexpected link loss should re-arm a pending connection to the same
|
|
70
|
+
/// peripheral. iOS completes a pending `connect` whenever the glasses advertise again,
|
|
71
|
+
/// even while the app is suspended (phone locked), and wakes the app to handle it. A scan
|
|
72
|
+
/// without service filters returns nothing in the background, and the backoff timers that
|
|
73
|
+
/// drive it stop while the app is suspended, so the scan path alone never reconnects there.
|
|
74
|
+
static func shouldReconnectDirectly(
|
|
75
|
+
isKilled: Bool,
|
|
76
|
+
pairingYieldActive: Bool,
|
|
77
|
+
bluetoothPoweredOn: Bool,
|
|
78
|
+
peripheralName: String?,
|
|
79
|
+
savedDeviceName: String?
|
|
80
|
+
) -> Bool {
|
|
81
|
+
guard !isKilled, !pairingYieldActive, bluetoothPoweredOn,
|
|
82
|
+
let peripheralName, !peripheralName.isEmpty,
|
|
83
|
+
let savedDeviceName
|
|
84
|
+
else {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
return peripheralName == savedDeviceName
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Ownership of the one armed pending reconnect. Each arm returns a new attempt number, so a
|
|
92
|
+
/// timer captured for an earlier attempt can tell it no longer owns the connection state after
|
|
93
|
+
/// a failure, a fallback connect or a re-arm.
|
|
94
|
+
struct MentraLivePendingReconnect {
|
|
95
|
+
private(set) var attempt = 0
|
|
96
|
+
private(set) var isArmed = false
|
|
97
|
+
|
|
98
|
+
mutating func arm() -> Int {
|
|
99
|
+
attempt += 1
|
|
100
|
+
isArmed = true
|
|
101
|
+
return attempt
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
mutating func clear() {
|
|
105
|
+
isArmed = false
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func owns(_ candidate: Int) -> Bool {
|
|
109
|
+
isArmed && candidate == attempt
|
|
110
|
+
}
|
|
64
111
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
final class BluetoothBackgroundModeTests: XCTestCase {
|
|
5
|
+
func testDetectsDeclaredBluetoothCentral() {
|
|
6
|
+
XCTAssertTrue(
|
|
7
|
+
BluetoothBackgroundMode.declaresBluetoothCentral(
|
|
8
|
+
["UIBackgroundModes": ["audio", "bluetooth-central"]]
|
|
9
|
+
)
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
func testReportsMissingOrMalformedBackgroundModes() {
|
|
14
|
+
XCTAssertFalse(BluetoothBackgroundMode.declaresBluetoothCentral(nil))
|
|
15
|
+
XCTAssertFalse(BluetoothBackgroundMode.declaresBluetoothCentral([:]))
|
|
16
|
+
XCTAssertFalse(BluetoothBackgroundMode.declaresBluetoothCentral(["UIBackgroundModes": ["audio"]]))
|
|
17
|
+
XCTAssertFalse(
|
|
18
|
+
BluetoothBackgroundMode.declaresBluetoothCentral(["UIBackgroundModes": "bluetooth-central"])
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func testMacOSHasNoBackgroundModeRequirement() {
|
|
23
|
+
#if os(macOS)
|
|
24
|
+
XCTAssertTrue(BluetoothBackgroundMode.isDeclared(in: nil))
|
|
25
|
+
#endif
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func testWarningPointsToTheDocs() {
|
|
29
|
+
XCTAssertTrue(BluetoothBackgroundMode.missingWarning.contains("bluetooth-central"))
|
|
30
|
+
XCTAssertTrue(BluetoothBackgroundMode.missingWarning.contains("#background-operation"))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
final class MentraLiveReconnectPolicyTests: XCTestCase {
|
|
5
|
+
private func decide(
|
|
6
|
+
isKilled: Bool = false,
|
|
7
|
+
pairingYieldActive: Bool = false,
|
|
8
|
+
bluetoothPoweredOn: Bool = true,
|
|
9
|
+
peripheralName: String? = "Mentra_Live_D910",
|
|
10
|
+
savedDeviceName: String? = "Mentra_Live_D910"
|
|
11
|
+
) -> Bool {
|
|
12
|
+
MentraLiveConnectionAttemptPolicy.shouldReconnectDirectly(
|
|
13
|
+
isKilled: isKilled,
|
|
14
|
+
pairingYieldActive: pairingYieldActive,
|
|
15
|
+
bluetoothPoweredOn: bluetoothPoweredOn,
|
|
16
|
+
peripheralName: peripheralName,
|
|
17
|
+
savedDeviceName: savedDeviceName
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func testRemembersLostGlassesWithPendingConnect() {
|
|
22
|
+
XCTAssertTrue(decide())
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func testDoesNotReconnectAfterUserDisconnectOrKill() {
|
|
26
|
+
XCTAssertFalse(decide(isKilled: true))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func testYieldsToAnotherPhonePairing() {
|
|
30
|
+
XCTAssertFalse(decide(pairingYieldActive: true))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func testFallsBackWhenBluetoothIsOff() {
|
|
34
|
+
XCTAssertFalse(decide(bluetoothPoweredOn: false))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func testDoesNotReconnectForgottenOrDifferentGlasses() {
|
|
38
|
+
XCTAssertFalse(decide(savedDeviceName: nil))
|
|
39
|
+
XCTAssertFalse(decide(savedDeviceName: "Mentra_Live_023B"))
|
|
40
|
+
XCTAssertFalse(decide(peripheralName: nil))
|
|
41
|
+
XCTAssertFalse(decide(peripheralName: "", savedDeviceName: ""))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func testFailureThenFallbackLeavesTheStaleSettleTimerPowerless() {
|
|
45
|
+
var pending = MentraLivePendingReconnect()
|
|
46
|
+
let first = pending.arm()
|
|
47
|
+
XCTAssertTrue(pending.owns(first))
|
|
48
|
+
|
|
49
|
+
// didFailToConnect or a fallback connectToDevice clears tracking.
|
|
50
|
+
pending.clear()
|
|
51
|
+
XCTAssertFalse(pending.owns(first))
|
|
52
|
+
|
|
53
|
+
// A later re-arm does not revive the earlier attempt's timer.
|
|
54
|
+
let second = pending.arm()
|
|
55
|
+
XCTAssertFalse(pending.owns(first))
|
|
56
|
+
XCTAssertTrue(pending.owns(second))
|
|
57
|
+
}
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/bluetooth-sdk",
|
|
3
|
-
"version": "3.3.0-dev.
|
|
3
|
+
"version": "3.3.0-dev.553",
|
|
4
4
|
"description": "SDK for communicating with smart glasses",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"react-native": "src/index.ts",
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"registry": "https://registry.npmjs.org/"
|
|
115
115
|
},
|
|
116
116
|
"dependencies": {
|
|
117
|
-
"@mentra/cloud-protocol": "3.3.0-dev.
|
|
117
|
+
"@mentra/cloud-protocol": "3.3.0-dev.553"
|
|
118
118
|
},
|
|
119
119
|
"devDependencies": {
|
|
120
120
|
"@types/node": "^25.9.3",
|
|
@@ -2,5 +2,7 @@ import { IOSConfig, type ConfigPlugin } from "expo/config-plugins";
|
|
|
2
2
|
import { type BluetoothSdkPluginProps } from "./index";
|
|
3
3
|
export declare const INFO_ANALYTICS_ENVIRONMENT = "MentraBluetoothSdkAnalyticsEnvironment";
|
|
4
4
|
export declare const INFO_SDK_VERSION = "MentraBluetoothSdkVersion";
|
|
5
|
+
/** Lets iOS complete the SDK's pending reconnect, and deliver BLE events, while the app is suspended. */
|
|
6
|
+
export declare const IOS_BLUETOOTH_BACKGROUND_MODE = "bluetooth-central";
|
|
5
7
|
export declare function applyBluetoothSdkInfoPlist(infoPlist: IOSConfig.InfoPlist, props: BluetoothSdkPluginProps | undefined): IOSConfig.InfoPlist;
|
|
6
8
|
export declare const withIosConfiguration: ConfigPlugin<BluetoothSdkPluginProps>;
|
package/plugin/build/withIos.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.withIosConfiguration = exports.INFO_SDK_VERSION = exports.INFO_ANALYTICS_ENVIRONMENT = void 0;
|
|
6
|
+
exports.withIosConfiguration = exports.IOS_BLUETOOTH_BACKGROUND_MODE = exports.INFO_SDK_VERSION = exports.INFO_ANALYTICS_ENVIRONMENT = void 0;
|
|
7
7
|
exports.applyBluetoothSdkInfoPlist = applyBluetoothSdkInfoPlist;
|
|
8
8
|
const child_process_1 = require("child_process");
|
|
9
9
|
const fs_1 = __importDefault(require("fs"));
|
|
@@ -17,6 +17,8 @@ exports.INFO_ANALYTICS_ENVIRONMENT = "MentraBluetoothSdkAnalyticsEnvironment";
|
|
|
17
17
|
exports.INFO_SDK_VERSION = "MentraBluetoothSdkVersion";
|
|
18
18
|
const STALE_INFO_POSTHOG_API_KEY = "MentraBluetoothSdkPostHogApiKey";
|
|
19
19
|
const STALE_INFO_POSTHOG_HOST = "MentraBluetoothSdkPostHogHost";
|
|
20
|
+
/** Lets iOS complete the SDK's pending reconnect, and deliver BLE events, while the app is suspended. */
|
|
21
|
+
exports.IOS_BLUETOOTH_BACKGROUND_MODE = "bluetooth-central";
|
|
20
22
|
const ensureBluetoothSdkExpoAdapterPodEnv = (podfile) => {
|
|
21
23
|
if (podfile.includes(BLUETOOTH_SDK_EXPO_ADAPTER_ENV)) {
|
|
22
24
|
return podfile;
|
|
@@ -70,6 +72,15 @@ function applyBluetoothSdkInfoPlist(infoPlist, props) {
|
|
|
70
72
|
delete infoPlist[STALE_INFO_POSTHOG_API_KEY];
|
|
71
73
|
delete infoPlist[STALE_INFO_POSTHOG_HOST];
|
|
72
74
|
infoPlist[exports.INFO_SDK_VERSION] = sdkVersion;
|
|
75
|
+
// Merge, never replace: apps may also declare audio, location or other modes.
|
|
76
|
+
const declaredModes = infoPlist.UIBackgroundModes;
|
|
77
|
+
if (declaredModes !== undefined && !Array.isArray(declaredModes)) {
|
|
78
|
+
throw new Error("ios.infoPlist.UIBackgroundModes must be an array of background mode strings");
|
|
79
|
+
}
|
|
80
|
+
const backgroundModes = declaredModes ?? [];
|
|
81
|
+
if (!backgroundModes.includes(exports.IOS_BLUETOOTH_BACKGROUND_MODE)) {
|
|
82
|
+
infoPlist.UIBackgroundModes = [...backgroundModes, exports.IOS_BLUETOOTH_BACKGROUND_MODE];
|
|
83
|
+
}
|
|
73
84
|
if (analytics.disabled !== undefined) {
|
|
74
85
|
infoPlist[INFO_ANALYTICS_DISABLED] = analytics.disabled;
|
|
75
86
|
}
|
|
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
|
|
|
12
12
|
export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
|
|
13
13
|
"schemaVersion": 1,
|
|
14
14
|
"familyBaseVersion": "3.3.0",
|
|
15
|
-
"releaseIdentity": "3.3.0-dev.
|
|
16
|
-
"releaseSetId": "mentra-3.3.0-dev.
|
|
17
|
-
"sourceCommit": "
|
|
18
|
-
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.
|
|
19
|
-
"otaManifestSha256": "
|
|
15
|
+
"releaseIdentity": "3.3.0-dev.553",
|
|
16
|
+
"releaseSetId": "mentra-3.3.0-dev.553",
|
|
17
|
+
"sourceCommit": "afdba2145753b018cd3942282d93cf21b8bd1675",
|
|
18
|
+
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.553.json",
|
|
19
|
+
"otaManifestSha256": "899e2022f1b22cfe4bd24763e5ddd641178ad782ae9f9c9d5edf43ea5e269f00"
|
|
20
20
|
})
|