@mentra/bluetooth-sdk 3.2.0-dev.209 → 3.2.0-dev.210
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 +45 -8
- package/android/build.gradle +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +89 -90
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsHost.kt +106 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsQueue.kt +111 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +139 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTransport.kt +49 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +11 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2SerialResolution.kt +23 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsHostTest.kt +64 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsQueueTest.kt +86 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +148 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTransportTest.kt +44 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/G2SerialResolutionTest.kt +25 -0
- package/build/generated/changelogCatalog.d.ts +1 -1
- package/build/generated/changelogCatalog.js +1 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +127 -76
- package/ios/Source/internal/BluetoothSdkAnalyticsHost.swift +91 -0
- package/ios/Source/internal/BluetoothSdkAnalyticsQueue.swift +117 -0
- package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +157 -0
- package/ios/Source/internal/BluetoothSdkAnalyticsTransport.swift +13 -0
- package/ios/Source/sgcs/G2.swift +374 -359
- package/ios/Source/sgcs/G2SerialResolution.swift +23 -0
- package/ios/Tests/BluetoothSdkAnalyticsHostTests.swift +63 -0
- package/ios/Tests/BluetoothSdkAnalyticsQueueTests.swift +97 -0
- package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +122 -0
- package/ios/Tests/BluetoothSdkAnalyticsTransportTests.swift +36 -0
- package/ios/Tests/G2SerialResolutionTests.swift +19 -0
- package/package.json +1 -1
- package/plugin/build/analyticsProps.d.ts +9 -0
- package/plugin/build/analyticsProps.js +34 -0
- package/plugin/build/index.d.ts +8 -0
- package/plugin/build/withAndroid.js +12 -18
- package/plugin/build/withIos.d.ts +1 -0
- package/plugin/build/withIos.js +8 -18
- package/src/generated/changelogCatalog.ts +1 -1
- 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
|
@@ -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
|
+
}
|
package/plugin/build/index.d.ts
CHANGED
|
@@ -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 = [
|
|
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>;
|
package/plugin/build/withIos.js
CHANGED
|
@@ -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.
|
|
16
|
-
"releaseSetId": "mentra-3.2.0-dev.
|
|
17
|
-
"sourceCommit": "
|
|
18
|
-
"otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.
|
|
19
|
-
"otaManifestSha256": "
|
|
15
|
+
"releaseIdentity": "3.2.0-dev.210",
|
|
16
|
+
"releaseSetId": "mentra-3.2.0-dev.210",
|
|
17
|
+
"sourceCommit": "259fb93fd2ecae4ed7bf280fe3b4e2b2cc594611",
|
|
18
|
+
"otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.210.json",
|
|
19
|
+
"otaManifestSha256": "7d8fb1f69ca9a0a6b9d00f87a60694e74aa5377776d1818a7b982b09d9bb65dc"
|
|
20
20
|
})
|