@mentra/bluetooth-sdk 3.2.0-dev.209 → 3.2.0-dev.213

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 (45) hide show
  1. package/README.md +45 -8
  2. package/android/build.gradle +2 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +89 -90
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsHost.kt +106 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsQueue.kt +111 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +139 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTransport.kt +49 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +11 -1
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2SerialResolution.kt +23 -0
  12. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsHostTest.kt +64 -0
  13. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsQueueTest.kt +86 -0
  14. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +148 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTransportTest.kt +44 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/G2SerialResolutionTest.kt +25 -0
  17. package/build/generated/changelogCatalog.d.ts +1 -1
  18. package/build/generated/changelogCatalog.js +1 -1
  19. package/build/generated/changelogCatalog.js.map +1 -1
  20. package/build/generated/releaseMetadata.js +5 -5
  21. package/build/generated/releaseMetadata.js.map +1 -1
  22. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  23. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  24. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  25. package/ios/Source/internal/BluetoothSdkAnalytics.swift +127 -76
  26. package/ios/Source/internal/BluetoothSdkAnalyticsHost.swift +91 -0
  27. package/ios/Source/internal/BluetoothSdkAnalyticsQueue.swift +117 -0
  28. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +157 -0
  29. package/ios/Source/internal/BluetoothSdkAnalyticsTransport.swift +13 -0
  30. package/ios/Source/sgcs/G2.swift +374 -359
  31. package/ios/Source/sgcs/G2SerialResolution.swift +23 -0
  32. package/ios/Tests/BluetoothSdkAnalyticsHostTests.swift +63 -0
  33. package/ios/Tests/BluetoothSdkAnalyticsQueueTests.swift +97 -0
  34. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +122 -0
  35. package/ios/Tests/BluetoothSdkAnalyticsTransportTests.swift +36 -0
  36. package/ios/Tests/G2SerialResolutionTests.swift +19 -0
  37. package/package.json +1 -1
  38. package/plugin/build/analyticsProps.d.ts +9 -0
  39. package/plugin/build/analyticsProps.js +34 -0
  40. package/plugin/build/index.d.ts +8 -0
  41. package/plugin/build/withAndroid.js +12 -18
  42. package/plugin/build/withIos.d.ts +1 -0
  43. package/plugin/build/withIos.js +8 -18
  44. package/src/generated/changelogCatalog.ts +1 -1
  45. package/src/generated/releaseMetadata.ts +5 -5
@@ -0,0 +1,23 @@
1
+ import Foundation
2
+
3
+ /// Which manufacturing serial to publish for a G2 connection.
4
+ ///
5
+ /// A fresh pairing decodes the serial from the advertisement during the scan. A
6
+ /// cached reconnect (UUID based, after a process restart) skips the scan, so the
7
+ /// only serial the SDK still holds is the one it persisted as the device name at
8
+ /// the previous auth completion. That persisted value is reused only when it is
9
+ /// exactly the id this connection was asked for: the search id can be a partial
10
+ /// string typed by a host, and a partial must never be reported as a serial.
11
+ enum G2SerialResolution {
12
+ private static let unset = "NOT_SET"
13
+
14
+ static func resolve(scannedSerial: String?, requestedId: String, persistedDeviceName: String) -> String? {
15
+ if let scanned = scannedSerial?.trimmingCharacters(in: .whitespacesAndNewlines), !scanned.isEmpty {
16
+ return scanned
17
+ }
18
+ let persisted = persistedDeviceName.trimmingCharacters(in: .whitespacesAndNewlines)
19
+ let requested = requestedId.trimmingCharacters(in: .whitespacesAndNewlines)
20
+ guard !persisted.isEmpty, persisted != unset, persisted == requested else { return nil }
21
+ return persisted
22
+ }
23
+ }
@@ -0,0 +1,63 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class BluetoothSdkAnalyticsHostTests: XCTestCase {
5
+ func testSimulatorWinsOverEverything() {
6
+ XCTAssertEqual(
7
+ BluetoothSdkAnalyticsHost.installSource(isSimulator: true, hasEmbeddedProvisioningProfile: true, receiptFileName: "sandboxReceipt"),
8
+ "simulator"
9
+ )
10
+ }
11
+
12
+ func testEmbeddedProfileMeansAdHocOrDevelopmentEvenWithSandboxReceipt() {
13
+ XCTAssertEqual(
14
+ BluetoothSdkAnalyticsHost.installSource(isSimulator: false, hasEmbeddedProvisioningProfile: true, receiptFileName: "sandboxReceipt"),
15
+ "adhoc_or_dev"
16
+ )
17
+ }
18
+
19
+ func testSandboxReceiptWithoutProfileIsTestFlight() {
20
+ XCTAssertEqual(
21
+ BluetoothSdkAnalyticsHost.installSource(isSimulator: false, hasEmbeddedProvisioningProfile: false, receiptFileName: "sandboxReceipt"),
22
+ "testflight"
23
+ )
24
+ }
25
+
26
+ func testNoProfileAndProductionReceiptIsAppStore() {
27
+ XCTAssertEqual(
28
+ BluetoothSdkAnalyticsHost.installSource(isSimulator: false, hasEmbeddedProvisioningProfile: false, receiptFileName: "receipt"),
29
+ "app_store"
30
+ )
31
+ }
32
+
33
+ func testMissingOrUnrecognizedReceiptIsUnknownNotAppStore() {
34
+ XCTAssertEqual(
35
+ BluetoothSdkAnalyticsHost.installSource(isSimulator: false, hasEmbeddedProvisioningProfile: false, receiptFileName: nil),
36
+ "unknown"
37
+ )
38
+ XCTAssertEqual(
39
+ BluetoothSdkAnalyticsHost.installSource(isSimulator: false, hasEmbeddedProvisioningProfile: false, receiptFileName: "something-else"),
40
+ "unknown"
41
+ )
42
+ }
43
+
44
+ func testEnvironmentIsNormalizedAndValidated() {
45
+ XCTAssertEqual(BluetoothSdkAnalyticsHost.normalizedEnvironment(" Prod "), "prod")
46
+ XCTAssertEqual(BluetoothSdkAnalyticsHost.normalizedEnvironment("staging-eu_1"), "staging-eu_1")
47
+ XCTAssertNil(BluetoothSdkAnalyticsHost.normalizedEnvironment(nil))
48
+ XCTAssertNil(BluetoothSdkAnalyticsHost.normalizedEnvironment(""))
49
+ XCTAssertNil(BluetoothSdkAnalyticsHost.normalizedEnvironment("-leading"))
50
+ XCTAssertNil(BluetoothSdkAnalyticsHost.normalizedEnvironment("has space"))
51
+ XCTAssertNil(BluetoothSdkAnalyticsHost.normalizedEnvironment(String(repeating: "a", count: 33)))
52
+ }
53
+
54
+ func testResolveReadsTheBundleItIsGiven() {
55
+ let bundle = Bundle(for: BluetoothSdkAnalyticsHostTests.self)
56
+ let host = BluetoothSdkAnalyticsHost.resolve(bundle: bundle)
57
+ // The test bundle declares no lane and no analytics keys of its own.
58
+ XCTAssertNil(host.environment)
59
+ XCTAssertEqual(host.appVersion, bundle.infoDictionary?["CFBundleShortVersionString"] as? String)
60
+ XCTAssertEqual(host.appBuild, bundle.infoDictionary?["CFBundleVersion"] as? String)
61
+ XCTAssertEqual(host.properties["app_install_source"] as? String, host.installSource)
62
+ }
63
+ }
@@ -0,0 +1,97 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class BluetoothSdkAnalyticsQueueTests: XCTestCase {
5
+ private var fileURL: URL!
6
+
7
+ override func setUpWithError() throws {
8
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
9
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
10
+ fileURL = directory.appendingPathComponent("queue.jsonl")
11
+ }
12
+
13
+ override func tearDownWithError() throws {
14
+ try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent())
15
+ }
16
+
17
+ private func payload(_ id: String) -> [String: Any] {
18
+ ["uuid": id, "event": "bluetooth_sdk_started"]
19
+ }
20
+
21
+ func testDrainsOldestFirstAndKeepsFailuresInOrder() {
22
+ let queue = BluetoothSdkAnalyticsQueue(fileURL: fileURL)
23
+ queue.enqueue(payload("a"), now: Date(timeIntervalSince1970: 1))
24
+ queue.enqueue(payload("b"), now: Date(timeIntervalSince1970: 2))
25
+ queue.enqueue(payload("c"), now: Date(timeIntervalSince1970: 3))
26
+
27
+ var sent: [String] = []
28
+ queue.drain(now: Date(timeIntervalSince1970: 4)) { p in
29
+ let id = p["uuid"] as? String ?? ""
30
+ if id == "b" { return .retry }
31
+ sent.append(id)
32
+ return .delivered
33
+ }
34
+ XCTAssertEqual(sent, ["a"])
35
+ XCTAssertEqual(queue.count, 2)
36
+
37
+ var second: [String] = []
38
+ queue.drain(now: Date(timeIntervalSince1970: 5)) { p in
39
+ second.append(p["uuid"] as? String ?? "")
40
+ return .delivered
41
+ }
42
+ XCTAssertEqual(second, ["b", "c"])
43
+ XCTAssertEqual(queue.count, 0)
44
+ XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
45
+ }
46
+
47
+ func testDropsPastCapAndExpiredEntries() {
48
+ let queue = BluetoothSdkAnalyticsQueue(fileURL: fileURL, maxEntries: 2, maxAge: 10)
49
+ queue.enqueue(payload("old"), now: Date(timeIntervalSince1970: 0))
50
+ queue.enqueue(payload("mid"), now: Date(timeIntervalSince1970: 5))
51
+ queue.enqueue(payload("new"), now: Date(timeIntervalSince1970: 6))
52
+ XCTAssertEqual(queue.count, 2)
53
+
54
+ var sent: [String] = []
55
+ queue.drain(now: Date(timeIntervalSince1970: 16)) { p in
56
+ sent.append(p["uuid"] as? String ?? "")
57
+ return .delivered
58
+ }
59
+ XCTAssertEqual(sent, ["new"])
60
+ }
61
+
62
+ func testSurvivesACorruptLine() throws {
63
+ let queue = BluetoothSdkAnalyticsQueue(fileURL: fileURL)
64
+ queue.enqueue(payload("a"), now: Date(timeIntervalSince1970: 1))
65
+ let handle = try FileHandle(forWritingTo: fileURL)
66
+ handle.seekToEndOfFile()
67
+ try handle.write(XCTUnwrap("not json\n".data(using: .utf8)))
68
+ try handle.close()
69
+ queue.enqueue(payload("b"), now: Date(timeIntervalSince1970: 2))
70
+ XCTAssertEqual(queue.count, 2)
71
+ }
72
+
73
+ func testPermanentlyRejectedPayloadIsDroppedWithoutBlockingTheRest() {
74
+ let queue = BluetoothSdkAnalyticsQueue(fileURL: fileURL)
75
+ queue.enqueue(payload("bad"), now: Date(timeIntervalSince1970: 1))
76
+ queue.enqueue(payload("good"), now: Date(timeIntervalSince1970: 2))
77
+
78
+ var sent: [String] = []
79
+ queue.drain(now: Date(timeIntervalSince1970: 3)) { p in
80
+ let id = p["uuid"] as? String ?? ""
81
+ if id == "bad" { return .discard }
82
+ sent.append(id)
83
+ return .delivered
84
+ }
85
+ XCTAssertEqual(sent, ["good"])
86
+ XCTAssertEqual(queue.count, 0)
87
+ }
88
+
89
+ func testHttpStatusMapsToOutcome() {
90
+ XCTAssertEqual(SendOutcome.fromHTTPStatus(200), .delivered)
91
+ XCTAssertEqual(SendOutcome.fromHTTPStatus(400), .discard)
92
+ XCTAssertEqual(SendOutcome.fromHTTPStatus(401), .discard)
93
+ XCTAssertEqual(SendOutcome.fromHTTPStatus(408), .retry)
94
+ XCTAssertEqual(SendOutcome.fromHTTPStatus(429), .retry)
95
+ XCTAssertEqual(SendOutcome.fromHTTPStatus(503), .retry)
96
+ }
97
+ }
@@ -0,0 +1,122 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class BluetoothSdkAnalyticsTrackerTests: XCTestCase {
5
+ private var tracker = BluetoothSdkAnalyticsTracker(simulatedModel: "Simulated Glasses")
6
+
7
+ private func snapshot(connected: Bool, model: String = "Mentra Live", serial: String = "") -> AnalyticsGlassesSnapshot {
8
+ AnalyticsGlassesSnapshot(connected: connected, fullyBooted: connected, model: model, serialNumber: serial)
9
+ }
10
+
11
+ func testConnectThenSerialEmitsEachOncePerConnection() {
12
+ tracker.initialize(snapshot(connected: false), reportingDay: 100)
13
+ XCTAssertEqual(tracker.observe(snapshot(connected: true), reportingDay: 100).map(\.name), ["bluetooth_sdk_glasses_connected"])
14
+
15
+ let identified = tracker.observe(snapshot(connected: true, serial: "MLAB0001"), reportingDay: 100)
16
+ XCTAssertEqual(identified.map(\.name), ["bluetooth_sdk_glasses_identified"])
17
+ XCTAssertEqual(identified[0].properties["event_kind"] as? String, "glasses_identified")
18
+ XCTAssertEqual(identified[0].properties["glasses_device_id"] as? String, "MLAB0001")
19
+ XCTAssertEqual(identified[0].properties["glasses_is_simulated"] as? Bool, false)
20
+
21
+ XCTAssertTrue(tracker.observe(snapshot(connected: true, serial: "MLAB0001"), reportingDay: 100).isEmpty)
22
+
23
+ _ = tracker.observe(snapshot(connected: false), reportingDay: 100)
24
+ XCTAssertEqual(
25
+ tracker.observe(snapshot(connected: true, serial: "MLAB0001"), reportingDay: 100).map(\.name),
26
+ ["bluetooth_sdk_glasses_connected", "bluetooth_sdk_glasses_identified"]
27
+ )
28
+ }
29
+
30
+ func testConnectedWaitsForTheModel() {
31
+ tracker.initialize(snapshot(connected: false), reportingDay: 100)
32
+ XCTAssertTrue(tracker.observe(snapshot(connected: true, model: ""), reportingDay: 100).isEmpty)
33
+ let withModel = tracker.observe(snapshot(connected: true, model: "Even Realities G2"), reportingDay: 100)
34
+ XCTAssertEqual(withModel.map(\.name), ["bluetooth_sdk_glasses_connected"])
35
+ XCTAssertEqual(withModel[0].properties["glasses_model"] as? String, "Even Realities G2")
36
+ XCTAssertNil(withModel[0].properties["glasses_model_unresolved"])
37
+ }
38
+
39
+ func testConnectedWithoutModelIsCountedWhenTheConnectionEndsFirst() {
40
+ tracker.initialize(snapshot(connected: false), reportingDay: 100)
41
+ _ = tracker.observe(snapshot(connected: true, model: ""), reportingDay: 100)
42
+ let ended = tracker.observe(snapshot(connected: false, model: ""), reportingDay: 100)
43
+ XCTAssertEqual(ended.map(\.name), ["bluetooth_sdk_glasses_connected"])
44
+ XCTAssertEqual(ended[0].properties["glasses_model_unresolved"] as? Bool, true)
45
+ XCTAssertNil(ended[0].properties["glasses_model"])
46
+ }
47
+
48
+ func testHeartbeatOncePerUtcDayWhileConnected() {
49
+ tracker.initialize(snapshot(connected: false), reportingDay: 100)
50
+ _ = tracker.observe(snapshot(connected: true, serial: "MLAB0001"), reportingDay: 100)
51
+ XCTAssertTrue(tracker.observe(snapshot(connected: true, serial: "MLAB0001"), reportingDay: 100).isEmpty)
52
+ let nextDay = tracker.observe(snapshot(connected: true, serial: "MLAB0001"), reportingDay: 101)
53
+ XCTAssertEqual(nextDay.map(\.name), ["bluetooth_sdk_glasses_identified"])
54
+ XCTAssertEqual(nextDay[0].properties["event_kind"] as? String, "glasses_heartbeat")
55
+ XCTAssertTrue(tracker.observe(snapshot(connected: true, serial: "MLAB0001"), reportingDay: 101).isEmpty)
56
+ }
57
+
58
+ func testInitializingWhileConnectedSuppressesDuplicateIdentificationButNotHeartbeats() {
59
+ tracker.initialize(snapshot(connected: true, serial: "MLAB0001"), reportingDay: 100)
60
+ XCTAssertTrue(tracker.observe(snapshot(connected: true, serial: "MLAB0001"), reportingDay: 100).isEmpty)
61
+ XCTAssertEqual(tracker.observe(snapshot(connected: true, serial: "MLAB0001"), reportingDay: 101)[0].properties["event_kind"] as? String, "glasses_heartbeat")
62
+
63
+ var late = BluetoothSdkAnalyticsTracker(simulatedModel: "Simulated Glasses")
64
+ late.initialize(snapshot(connected: true, serial: ""), reportingDay: 100)
65
+ XCTAssertEqual(late.observe(snapshot(connected: true, serial: "MLAB0001"), reportingDay: 100)[0].properties["event_kind"] as? String, "glasses_identified")
66
+ }
67
+
68
+ func testPlaceholderSerialsIgnoredAndSimulatedFlagged() {
69
+ tracker.initialize(snapshot(connected: false), reportingDay: 100)
70
+ let events = tracker.observe(snapshot(connected: true, model: "Simulated Glasses", serial: "0000"), reportingDay: 100)
71
+ XCTAssertEqual(events.map(\.name), ["bluetooth_sdk_glasses_connected"])
72
+ XCTAssertEqual(events[0].properties["glasses_is_simulated"] as? Bool, true)
73
+ }
74
+
75
+ func testIdentificationCarriesKnownGlassesSoftwareVersions() {
76
+ tracker.initialize(snapshot(connected: false), reportingDay: 100)
77
+ var full = snapshot(connected: true, serial: "MLAB0001")
78
+ full.firmwareVersion = "26.9.3.0"
79
+ full.mtkFirmwareVersion = "20260709"
80
+ full.appVersion = "5.2.1"
81
+ let identified = tracker.observe(full, reportingDay: 100).first { $0.name == "bluetooth_sdk_glasses_identified" }
82
+ XCTAssertEqual(identified?.properties["glasses_firmware_version"] as? String, "26.9.3.0")
83
+ XCTAssertEqual(identified?.properties["glasses_mtk_firmware_version"] as? String, "20260709")
84
+ XCTAssertEqual(identified?.properties["glasses_app_version"] as? String, "5.2.1")
85
+ XCTAssertNil(identified?.properties["glasses_bes_firmware_version"])
86
+ XCTAssertNil(identified?.properties["glasses_build_number"])
87
+ }
88
+
89
+ private func millis(_ iso: String) -> Int64 {
90
+ let formatter = ISO8601DateFormatter()
91
+ return Int64(formatter.date(from: iso)!.timeIntervalSince1970 * 1000)
92
+ }
93
+
94
+ func testReportingDayFollowsTheLosAngelesCalendarNotUtc() {
95
+ let sundayEveningPacific = millis("2026-09-14T00:30:00Z") // Sun 2026-09-13 17:30 PDT
96
+ let mondayEarlyPacific = millis("2026-09-14T08:00:00Z") // Mon 2026-09-14 01:00 PDT
97
+ XCTAssertEqual(sundayEveningPacific / 86_400_000, mondayEarlyPacific / 86_400_000)
98
+ XCTAssertEqual(
99
+ BluetoothSdkAnalyticsTracker.reportingDay(epochMillis: sundayEveningPacific),
100
+ BluetoothSdkAnalyticsTracker.reportingDay(epochMillis: mondayEarlyPacific) - 1
101
+ )
102
+ // Spring-forward day: 01:30 PST and 03:30 PDT are the same Pacific day.
103
+ XCTAssertEqual(
104
+ BluetoothSdkAnalyticsTracker.reportingDay(epochMillis: millis("2026-03-08T09:30:00Z")),
105
+ BluetoothSdkAnalyticsTracker.reportingDay(epochMillis: millis("2026-03-08T10:30:00Z"))
106
+ )
107
+ // Pacific midnight is the boundary.
108
+ XCTAssertEqual(
109
+ BluetoothSdkAnalyticsTracker.reportingDay(epochMillis: millis("2026-09-14T06:59:59Z")) + 1,
110
+ BluetoothSdkAnalyticsTracker.reportingDay(epochMillis: millis("2026-09-14T07:00:00Z"))
111
+ )
112
+ }
113
+
114
+ func testConnectionAcrossPacificSundayMondayBoundaryHeartbeatsOnMonday() {
115
+ tracker.initialize(snapshot(connected: false), reportingDay: 0)
116
+ let sunday = BluetoothSdkAnalyticsTracker.reportingDay(epochMillis: millis("2026-09-14T00:30:00Z"))
117
+ let monday = BluetoothSdkAnalyticsTracker.reportingDay(epochMillis: millis("2026-09-14T08:00:00Z"))
118
+ _ = tracker.observe(snapshot(connected: true, serial: "MLAB0001"), reportingDay: sunday)
119
+ let onMonday = tracker.observe(snapshot(connected: true, serial: "MLAB0001"), reportingDay: monday)
120
+ XCTAssertEqual(onMonday.first?.properties["event_kind"] as? String, "glasses_heartbeat")
121
+ }
122
+ }
@@ -0,0 +1,36 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class BluetoothSdkAnalyticsTransportTests: XCTestCase {
5
+ func testDrainStartedByAnOldInstanceCannotOverwriteAnEventANewInstancePersists() throws {
6
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
7
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
8
+ let queue = BluetoothSdkAnalyticsQueue(fileURL: dir.appendingPathComponent("queue.jsonl"))
9
+ queue.enqueue(["uuid": "old"], now: Date(timeIntervalSince1970: 1))
10
+ let drainStarted = DispatchSemaphore(value: 0)
11
+ let newInstanceReady = DispatchSemaphore(value: 0)
12
+
13
+ // "Old instance": a slow drain that snapshots the file, then rewrites it.
14
+ BluetoothSdkAnalyticsTransport.queue.async {
15
+ queue.drain(now: Date(timeIntervalSince1970: 2)) { _ in
16
+ drainStarted.signal()
17
+ _ = newInstanceReady.wait(timeout: .now() + 2)
18
+ return .delivered
19
+ }
20
+ }
21
+ // "New instance": persists a failed event while that drain is in progress.
22
+ // Through the shared serial queue it runs after the drain instead of racing its rewrite.
23
+ XCTAssertEqual(drainStarted.wait(timeout: .now() + 2), .success)
24
+ BluetoothSdkAnalyticsTransport.queue.async { queue.enqueue(["uuid": "new"], now: Date(timeIntervalSince1970: 3)) }
25
+ newInstanceReady.signal()
26
+ BluetoothSdkAnalyticsTransport.queue.sync {}
27
+
28
+ var remaining: [String] = []
29
+ queue.drain(now: Date(timeIntervalSince1970: 4)) { p in
30
+ remaining.append(p["uuid"] as? String ?? "")
31
+ return .retry
32
+ }
33
+ XCTAssertEqual(remaining, ["new"])
34
+ try? FileManager.default.removeItem(at: dir)
35
+ }
36
+ }
@@ -0,0 +1,19 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class G2SerialResolutionTests: XCTestCase {
5
+ func testScannedSerialAlwaysWins() {
6
+ XCTAssertEqual(G2SerialResolution.resolve(scannedSerial: "S2ABCD12345678", requestedId: "OTHER", persistedDeviceName: "S2OLD000000000"), "S2ABCD12345678")
7
+ }
8
+
9
+ func testCachedReconnectReusesPersistedSerialOnlyWhenItIsTheRequestedId() {
10
+ XCTAssertEqual(G2SerialResolution.resolve(scannedSerial: nil, requestedId: "S2ABCD12345678", persistedDeviceName: "S2ABCD12345678"), "S2ABCD12345678")
11
+ XCTAssertNil(G2SerialResolution.resolve(scannedSerial: nil, requestedId: "12345678", persistedDeviceName: "S2ABCD12345678"))
12
+ XCTAssertNil(G2SerialResolution.resolve(scannedSerial: nil, requestedId: "S2ABCD12345678", persistedDeviceName: ""))
13
+ XCTAssertNil(G2SerialResolution.resolve(scannedSerial: nil, requestedId: "NOT_SET", persistedDeviceName: "NOT_SET"))
14
+ }
15
+
16
+ func testSwitchingPairsDoesNotLeakThePreviousSerial() {
17
+ XCTAssertNil(G2SerialResolution.resolve(scannedSerial: nil, requestedId: "S2NEW000000001", persistedDeviceName: "S2OLD000000000"))
18
+ }
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.2.0-dev.209",
3
+ "version": "3.2.0-dev.213",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -0,0 +1,9 @@
1
+ import { type BluetoothSdkPluginProps } from "./index";
2
+ export interface ResolvedAnalyticsProps {
3
+ /** `undefined` leaves the native default (enabled) untouched. */
4
+ disabled?: boolean;
5
+ /** Normalized host lane such as `dev`, `staging`, or `prod`. */
6
+ environment?: string;
7
+ }
8
+ export declare function normalizeAnalyticsEnvironment(raw: unknown): string | undefined;
9
+ export declare function resolveAnalyticsProps(props: BluetoothSdkPluginProps | undefined): ResolvedAnalyticsProps;
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeAnalyticsEnvironment = normalizeAnalyticsEnvironment;
4
+ exports.resolveAnalyticsProps = resolveAnalyticsProps;
5
+ const ENVIRONMENT_PATTERN = /^[a-z0-9][a-z0-9_-]{0,31}$/;
6
+ function normalizeAnalyticsEnvironment(raw) {
7
+ if (raw === undefined || raw === null || raw === "")
8
+ return undefined;
9
+ if (typeof raw !== "string") {
10
+ throw new Error(`@mentra/bluetooth-sdk: analytics.environment must be a string, received ${typeof raw}`);
11
+ }
12
+ const value = raw.trim().toLowerCase();
13
+ if (!ENVIRONMENT_PATTERN.test(value)) {
14
+ throw new Error(`@mentra/bluetooth-sdk: analytics.environment "${raw}" must match ${ENVIRONMENT_PATTERN} (for example "dev", "staging", "prod")`);
15
+ }
16
+ return value;
17
+ }
18
+ function resolveAnalyticsProps(props) {
19
+ const analytics = props?.analytics;
20
+ let disabled;
21
+ let environment;
22
+ if (analytics === false) {
23
+ disabled = true;
24
+ }
25
+ else if (analytics === true) {
26
+ disabled = false;
27
+ }
28
+ else if (typeof analytics === "object" && analytics !== null) {
29
+ if (analytics.enabled !== undefined)
30
+ disabled = !analytics.enabled;
31
+ environment = normalizeAnalyticsEnvironment(analytics.environment);
32
+ }
33
+ return { disabled, environment };
34
+ }
@@ -5,6 +5,14 @@ export interface BluetoothSdkPluginProps {
5
5
  }
6
6
  export interface BluetoothSdkAnalyticsPluginProps {
7
7
  enabled?: boolean;
8
+ /**
9
+ * Host-declared build lane (`dev`, `staging`, `prod`, ...), reported as
10
+ * `app_environment` on every SDK analytics event. Trimmed and lowercased;
11
+ * must then start with a letter or digit, followed by letters, digits, `_`
12
+ * or `-`, at most 32 characters. Store/TestFlight/sideload detection is
13
+ * automatic; this only adds the lane the host itself knows about.
14
+ */
15
+ environment?: string;
8
16
  }
9
17
  declare const withBluetoothSdk: ConfigPlugin<BluetoothSdkPluginProps>;
10
18
  export default withBluetoothSdk;
@@ -7,10 +7,17 @@ exports.withAndroidConfiguration = void 0;
7
7
  const child_process_1 = require("child_process");
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const config_plugins_1 = require("expo/config-plugins");
10
+ const analyticsProps_1 = require("./analyticsProps");
10
11
  const META_ANALYTICS_DISABLED = "com.mentra.bluetoothsdk.analytics.disabled";
12
+ const META_ANALYTICS_ENVIRONMENT = "com.mentra.bluetoothsdk.analytics.environment";
11
13
  const STALE_META_POSTHOG_API_KEY = "com.mentra.bluetoothsdk.analytics.posthog_api_key";
12
14
  const STALE_META_POSTHOG_HOST = "com.mentra.bluetoothsdk.analytics.posthog_host";
13
- const ANALYTICS_META_NAMES = [META_ANALYTICS_DISABLED, STALE_META_POSTHOG_API_KEY, STALE_META_POSTHOG_HOST];
15
+ const ANALYTICS_META_NAMES = [
16
+ META_ANALYTICS_DISABLED,
17
+ META_ANALYTICS_ENVIRONMENT,
18
+ STALE_META_POSTHOG_API_KEY,
19
+ STALE_META_POSTHOG_HOST,
20
+ ];
14
21
  function getBluetoothSdkRoot() {
15
22
  return path_1.default.dirname(require.resolve("../../package.json"));
16
23
  }
@@ -137,22 +144,6 @@ function withSherpaOnnxLocalMavenRepo(config) {
137
144
  return config;
138
145
  });
139
146
  }
140
- function resolveAnalyticsProps(props) {
141
- const analytics = props?.analytics;
142
- let disabled;
143
- if (analytics === false) {
144
- disabled = true;
145
- }
146
- else if (analytics === true) {
147
- disabled = false;
148
- }
149
- else if (typeof analytics === "object" && analytics.enabled !== undefined) {
150
- disabled = !analytics.enabled;
151
- }
152
- return {
153
- disabled,
154
- };
155
- }
156
147
  function upsertMetaData(application, name, value) {
157
148
  application["meta-data"] ??= [];
158
149
  const existing = application["meta-data"].find((item) => item.$?.["android:name"] === name);
@@ -167,7 +158,7 @@ function removeMetaData(application, name) {
167
158
  }
168
159
  function withAnalyticsManifestMetadata(config, props) {
169
160
  return (0, config_plugins_1.withAndroidManifest)(config, (config) => {
170
- const analytics = resolveAnalyticsProps(props);
161
+ const analytics = (0, analyticsProps_1.resolveAnalyticsProps)(props);
171
162
  const application = config_plugins_1.AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
172
163
  for (const name of ANALYTICS_META_NAMES) {
173
164
  removeMetaData(application, name);
@@ -175,6 +166,9 @@ function withAnalyticsManifestMetadata(config, props) {
175
166
  if (analytics.disabled !== undefined) {
176
167
  upsertMetaData(application, META_ANALYTICS_DISABLED, analytics.disabled ? "true" : "false");
177
168
  }
169
+ if (analytics.environment !== undefined) {
170
+ upsertMetaData(application, META_ANALYTICS_ENVIRONMENT, analytics.environment);
171
+ }
178
172
  return config;
179
173
  });
180
174
  }
@@ -1,5 +1,6 @@
1
1
  import { IOSConfig, type ConfigPlugin } from "expo/config-plugins";
2
2
  import { type BluetoothSdkPluginProps } from "./index";
3
+ export declare const INFO_ANALYTICS_ENVIRONMENT = "MentraBluetoothSdkAnalyticsEnvironment";
3
4
  export declare const INFO_SDK_VERSION = "MentraBluetoothSdkVersion";
4
5
  export declare function applyBluetoothSdkInfoPlist(infoPlist: IOSConfig.InfoPlist, props: BluetoothSdkPluginProps | undefined): IOSConfig.InfoPlist;
5
6
  export declare const withIosConfiguration: ConfigPlugin<BluetoothSdkPluginProps>;
@@ -3,15 +3,17 @@ 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 = void 0;
6
+ exports.withIosConfiguration = 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"));
10
10
  const path_1 = __importDefault(require("path"));
11
11
  const config_plugins_1 = require("expo/config-plugins");
12
+ const analyticsProps_1 = require("./analyticsProps");
12
13
  const BLUETOOTH_SDK_EXPO_ADAPTER_ENV = "MENTRA_BLUETOOTH_SDK_INCLUDE_EXPO_ADAPTER";
13
14
  const BLUETOOTH_SDK_EXPO_ADAPTER_LINE = `ENV['${BLUETOOTH_SDK_EXPO_ADAPTER_ENV}'] ||= '1'`;
14
15
  const INFO_ANALYTICS_DISABLED = "MentraBluetoothSdkAnalyticsDisabled";
16
+ exports.INFO_ANALYTICS_ENVIRONMENT = "MentraBluetoothSdkAnalyticsEnvironment";
15
17
  exports.INFO_SDK_VERSION = "MentraBluetoothSdkVersion";
16
18
  const STALE_INFO_POSTHOG_API_KEY = "MentraBluetoothSdkPostHogApiKey";
17
19
  const STALE_INFO_POSTHOG_HOST = "MentraBluetoothSdkPostHogHost";
@@ -56,36 +58,24 @@ const withXcodeEnvLocal = (config) => {
56
58
  },
57
59
  ]);
58
60
  };
59
- function resolveAnalyticsProps(props) {
60
- const analytics = props?.analytics;
61
- let disabled;
62
- if (analytics === false) {
63
- disabled = true;
64
- }
65
- else if (analytics === true) {
66
- disabled = false;
67
- }
68
- else if (typeof analytics === "object" && analytics.enabled !== undefined) {
69
- disabled = !analytics.enabled;
70
- }
71
- return {
72
- disabled,
73
- };
74
- }
75
61
  function applyBluetoothSdkInfoPlist(infoPlist, props) {
76
62
  const packageJson = require("../../package.json");
77
63
  const sdkVersion = typeof packageJson.version === "string" ? packageJson.version.trim() : "";
78
64
  if (!sdkVersion) {
79
65
  throw new Error("@mentra/bluetooth-sdk package.json is missing a version");
80
66
  }
81
- const analytics = resolveAnalyticsProps(props);
67
+ const analytics = (0, analyticsProps_1.resolveAnalyticsProps)(props);
82
68
  delete infoPlist[INFO_ANALYTICS_DISABLED];
69
+ delete infoPlist[exports.INFO_ANALYTICS_ENVIRONMENT];
83
70
  delete infoPlist[STALE_INFO_POSTHOG_API_KEY];
84
71
  delete infoPlist[STALE_INFO_POSTHOG_HOST];
85
72
  infoPlist[exports.INFO_SDK_VERSION] = sdkVersion;
86
73
  if (analytics.disabled !== undefined) {
87
74
  infoPlist[INFO_ANALYTICS_DISABLED] = analytics.disabled;
88
75
  }
76
+ if (analytics.environment !== undefined) {
77
+ infoPlist[exports.INFO_ANALYTICS_ENVIRONMENT] = analytics.environment;
78
+ }
89
79
  return infoPlist;
90
80
  }
91
81
  function withBluetoothSdkInfoPlist(config, props) {
@@ -7,7 +7,7 @@ export const GENERATED_RELEASE_CHANGELOGS = Object.freeze(
7
7
  },
8
8
  {
9
9
  "version": "3.1.0",
10
- "markdown": "Software updates are more reliable, with clearer progress and recovery when the glasses restart.\n\n- MentraOS, Mentra Engine, the Bluetooth SDK, and the glasses client now share one coordinated release version.\n- Mentra Live updates can continue across APK, system, and firmware restarts without asking the user to start the same update again.\n- Bluetooth photo capture and transfer diagnostics are more precise and less disruptive to normal glasses traffic."
10
+ "markdown": "Software updates are more reliable, with clearer progress and recovery when the glasses restart.\n\n- MentraOS, Mentra Engine, the Bluetooth SDK, and the glasses client now share one coordinated release version.\n- Mentra Live updates can continue across APK, system, and firmware restarts without asking the user to start the same update again.\n- Bluetooth photo capture and transfer diagnostics are more precise and less disruptive to normal glasses traffic.\n- Bluetooth SDK usage analytics now report the host app's version, build type, and install source (store, TestFlight, sideload, simulator), plus the glasses firmware versions on identification, so store usage can be measured separately from development builds.\n- Bluetooth SDK usage analytics now identify G2 glasses by serial, keep long-lived connections visible across day boundaries, and retry failed uploads instead of dropping them."
11
11
  }
12
12
  ] as const,
13
13
  )
@@ -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.2.0",
15
- "releaseIdentity": "3.2.0-dev.209",
16
- "releaseSetId": "mentra-3.2.0-dev.209",
17
- "sourceCommit": "b1606374e1508f0993d1a7e391fddeb4f8fac5bd",
18
- "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.209.json",
19
- "otaManifestSha256": "de5936b765f7deb07a1fd448bb4e9c00f36e1b1313d16c021aff843cf801b489"
15
+ "releaseIdentity": "3.2.0-dev.213",
16
+ "releaseSetId": "mentra-3.2.0-dev.213",
17
+ "sourceCommit": "5d3db4a0653727a446a93826e3c5d929ee0d735c",
18
+ "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.213.json",
19
+ "otaManifestSha256": "1fd68fbbf9b1351dd710e7597304f691b5a17dbad652a927ae78c9555bd1458f"
20
20
  })