@mentra/bluetooth-sdk 3.1.0-dev.98 → 3.1.1-beta.230
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 +46 -13
- 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/BluetoothSdkDefaults.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +6 -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/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +8 -0
- package/build/generated/changelogCatalog.d.ts +4 -1
- package/build/generated/changelogCatalog.d.ts.map +1 -1
- package/build/generated/changelogCatalog.js +5 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +6 -6
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +2 -2
- package/ios/Source/GeneratedChangelogCatalog.swift +2 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
- package/ios/Source/OtaManifest.swift +6 -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 +375 -360
- 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/ios/Tests/OtaManifestDowngradeTests.swift +20 -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 +5 -1
- package/src/generated/releaseMetadata.ts +6 -6
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
enum SendOutcome {
|
|
4
|
+
case delivered
|
|
5
|
+
case retry
|
|
6
|
+
case discard
|
|
7
|
+
|
|
8
|
+
/// 2xx is delivered. A 4xx other than 408/429 means PostHog rejected this payload
|
|
9
|
+
/// for good (bad key, malformed body) and retrying it would only block the queue.
|
|
10
|
+
/// Everything else (5xx, 408, 429) is worth retrying later.
|
|
11
|
+
static func fromHTTPStatus(_ code: Int) -> SendOutcome {
|
|
12
|
+
switch code {
|
|
13
|
+
case 200 ..< 300: return .delivered
|
|
14
|
+
case 408, 429: return .retry
|
|
15
|
+
case 400 ..< 500: return .discard
|
|
16
|
+
default: return .retry
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Bounded on-disk retry queue for analytics payloads whose upload failed.
|
|
22
|
+
/// One JSON object per line. Oldest entries are dropped past `maxEntries`; entries
|
|
23
|
+
/// older than `maxAge` are discarded on drain. Every payload carries its own
|
|
24
|
+
/// `uuid` and `timestamp`, so a retried event neither double counts nor moves in time.
|
|
25
|
+
///
|
|
26
|
+
/// Not thread-safe by itself: callers serialize access on the analytics transport queue.
|
|
27
|
+
final class BluetoothSdkAnalyticsQueue {
|
|
28
|
+
static let defaultMaxEntries = 100
|
|
29
|
+
static let defaultMaxAge: TimeInterval = 7 * 24 * 60 * 60
|
|
30
|
+
static let fileName = "mentra_bluetooth_sdk_analytics_queue.jsonl"
|
|
31
|
+
|
|
32
|
+
static func defaultFileURL(fileManager: FileManager = .default) -> URL? {
|
|
33
|
+
guard let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil }
|
|
34
|
+
return base.appendingPathComponent("MentraBluetoothSdk", isDirectory: true).appendingPathComponent(fileName)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private let fileURL: URL
|
|
38
|
+
private let maxEntries: Int
|
|
39
|
+
private let maxAge: TimeInterval
|
|
40
|
+
|
|
41
|
+
init(fileURL: URL, maxEntries: Int = BluetoothSdkAnalyticsQueue.defaultMaxEntries, maxAge: TimeInterval = BluetoothSdkAnalyticsQueue.defaultMaxAge) {
|
|
42
|
+
self.fileURL = fileURL
|
|
43
|
+
self.maxEntries = maxEntries
|
|
44
|
+
self.maxAge = maxAge
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func enqueue(_ payload: [String: Any], now: Date) {
|
|
48
|
+
var entries = read()
|
|
49
|
+
entries.append(["enqueued_at": now.timeIntervalSince1970, "payload": payload])
|
|
50
|
+
while entries.count > maxEntries {
|
|
51
|
+
entries.removeFirst()
|
|
52
|
+
}
|
|
53
|
+
write(entries)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
var count: Int {
|
|
57
|
+
read().count
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Sends queued payloads oldest-first through `send`. Delivered and permanently
|
|
61
|
+
/// rejected entries are dropped; the first retryable failure stops the drain so
|
|
62
|
+
/// ordering is preserved and a dead network does not burn through every entry.
|
|
63
|
+
func drain(now: Date, send: ([String: Any]) -> SendOutcome) {
|
|
64
|
+
let entries = read()
|
|
65
|
+
guard !entries.isEmpty else { return }
|
|
66
|
+
var remaining: [[String: Any]] = []
|
|
67
|
+
var blocked = false
|
|
68
|
+
for entry in entries {
|
|
69
|
+
let enqueuedAt = (entry["enqueued_at"] as? TimeInterval) ?? now.timeIntervalSince1970
|
|
70
|
+
if now.timeIntervalSince1970 - enqueuedAt > maxAge { continue }
|
|
71
|
+
guard let payload = entry["payload"] as? [String: Any] else { continue }
|
|
72
|
+
if blocked {
|
|
73
|
+
remaining.append(entry)
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if send(payload) == .retry {
|
|
77
|
+
blocked = true
|
|
78
|
+
remaining.append(entry)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
write(remaining)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private func read() -> [[String: Any]] {
|
|
85
|
+
guard let data = try? Data(contentsOf: fileURL), let text = String(data: data, encoding: .utf8) else { return [] }
|
|
86
|
+
return text.split(separator: "\n").compactMap { line in
|
|
87
|
+
guard !line.trimmingCharacters(in: .whitespaces).isEmpty,
|
|
88
|
+
let lineData = line.data(using: .utf8),
|
|
89
|
+
let object = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any]
|
|
90
|
+
else { return nil }
|
|
91
|
+
return object
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private func write(_ entries: [[String: Any]]) {
|
|
96
|
+
let fileManager = FileManager.default
|
|
97
|
+
if entries.isEmpty {
|
|
98
|
+
try? fileManager.removeItem(at: fileURL)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
do {
|
|
102
|
+
try fileManager.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
103
|
+
var lines: [String] = []
|
|
104
|
+
for entry in entries {
|
|
105
|
+
guard JSONSerialization.isValidJSONObject(entry),
|
|
106
|
+
let data = try? JSONSerialization.data(withJSONObject: entry),
|
|
107
|
+
let line = String(data: data, encoding: .utf8)
|
|
108
|
+
else { continue }
|
|
109
|
+
lines.append(line)
|
|
110
|
+
}
|
|
111
|
+
let text = lines.joined(separator: "\n") + "\n"
|
|
112
|
+
try text.data(using: .utf8)?.write(to: fileURL, options: .atomic)
|
|
113
|
+
} catch {
|
|
114
|
+
// A queue write failure only loses retries, never live behavior.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The subset of glasses status the analytics decision logic needs.
|
|
4
|
+
struct AnalyticsGlassesSnapshot {
|
|
5
|
+
var connected: Bool
|
|
6
|
+
var fullyBooted: Bool
|
|
7
|
+
var model: String
|
|
8
|
+
var serialNumber: String
|
|
9
|
+
var firmwareVersion = ""
|
|
10
|
+
var besFirmwareVersion = ""
|
|
11
|
+
var mtkFirmwareVersion = ""
|
|
12
|
+
var androidVersion = ""
|
|
13
|
+
var appVersion = ""
|
|
14
|
+
var buildNumber = ""
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
struct AnalyticsEvent {
|
|
18
|
+
let name: String
|
|
19
|
+
let properties: [String: Any]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Pure decision logic for the SDK usage events. It owns no threads and no I/O so
|
|
23
|
+
/// the connection/identification/heartbeat rules can be unit-tested directly.
|
|
24
|
+
///
|
|
25
|
+
/// Rules:
|
|
26
|
+
/// - `bluetooth_sdk_glasses_connected` once per connection. If the model is not
|
|
27
|
+
/// known yet at connect time the event waits for it, and is emitted without a
|
|
28
|
+
/// model only if the connection ends first.
|
|
29
|
+
/// - `bluetooth_sdk_glasses_identified` once per connection per serial, plus a
|
|
30
|
+
/// `glasses_heartbeat` re-emission on the first status update of each new
|
|
31
|
+
/// reporting day while still connected, so a connection spanning a week boundary
|
|
32
|
+
/// is visible in both weeks. Reporting days follow `America/Los_Angeles`, the
|
|
33
|
+
/// calendar the WAU weeks are cut on; a UTC day would miss the Sunday-evening to
|
|
34
|
+
/// Monday-morning Pacific boundary, which is one UTC day.
|
|
35
|
+
struct BluetoothSdkAnalyticsTracker {
|
|
36
|
+
static let millisPerDay: Int64 = 86_400_000
|
|
37
|
+
static let reportingZone = TimeZone(identifier: "America/Los_Angeles")!
|
|
38
|
+
|
|
39
|
+
/// Calendar day in the reporting zone, as days since the epoch of that zone's midnight.
|
|
40
|
+
static func reportingDay(epochMillis: Int64) -> Int64 {
|
|
41
|
+
let offsetMillis = Int64(reportingZone.secondsFromGMT(for: Date(timeIntervalSince1970: Double(epochMillis) / 1000))) * 1000
|
|
42
|
+
let local = epochMillis + offsetMillis
|
|
43
|
+
let day = local / millisPerDay
|
|
44
|
+
return local < 0 && local % millisPerDay != 0 ? day - 1 : day
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static func reportingDay(date: Date = Date()) -> Int64 {
|
|
48
|
+
reportingDay(epochMillis: Int64((date.timeIntervalSince1970 * 1000).rounded(.down)))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private let simulatedModel: String
|
|
52
|
+
private var lastConnected = false
|
|
53
|
+
private var connectedPendingModel = false
|
|
54
|
+
private var identifiedSerial: String?
|
|
55
|
+
private var identifiedReportingDay: Int64?
|
|
56
|
+
|
|
57
|
+
init(simulatedModel: String) {
|
|
58
|
+
self.simulatedModel = simulatedModel
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
mutating func initialize(_ snapshot: AnalyticsGlassesSnapshot, reportingDay: Int64) {
|
|
62
|
+
lastConnected = snapshot.connected
|
|
63
|
+
connectedPendingModel = false
|
|
64
|
+
// Only treat identification as already captured when a valid serial is present
|
|
65
|
+
// at init. If the glasses are connected but the serial has not arrived yet
|
|
66
|
+
// (Mentra Live fills it via version_info after connect), leave this nil so the
|
|
67
|
+
// identify event still fires once the serial arrives.
|
|
68
|
+
identifiedSerial = snapshot.connected ? snapshot.serialNumber.validManufacturingSerial : nil
|
|
69
|
+
identifiedReportingDay = identifiedSerial == nil ? nil : reportingDay
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
mutating func observe(_ snapshot: AnalyticsGlassesSnapshot, reportingDay: Int64) -> [AnalyticsEvent] {
|
|
73
|
+
var events: [AnalyticsEvent] = []
|
|
74
|
+
let wasConnected = lastConnected
|
|
75
|
+
lastConnected = snapshot.connected
|
|
76
|
+
|
|
77
|
+
guard snapshot.connected else {
|
|
78
|
+
if connectedPendingModel {
|
|
79
|
+
connectedPendingModel = false
|
|
80
|
+
events.append(connectedEvent(snapshot, modelUnresolved: true))
|
|
81
|
+
}
|
|
82
|
+
identifiedSerial = nil
|
|
83
|
+
identifiedReportingDay = nil
|
|
84
|
+
return events
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if !wasConnected {
|
|
88
|
+
identifiedSerial = nil
|
|
89
|
+
identifiedReportingDay = nil
|
|
90
|
+
if snapshot.model.isBlank {
|
|
91
|
+
connectedPendingModel = true
|
|
92
|
+
} else {
|
|
93
|
+
events.append(connectedEvent(snapshot, modelUnresolved: false))
|
|
94
|
+
}
|
|
95
|
+
} else if connectedPendingModel, !snapshot.model.isBlank {
|
|
96
|
+
connectedPendingModel = false
|
|
97
|
+
events.append(connectedEvent(snapshot, modelUnresolved: false))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
guard let serial = snapshot.serialNumber.validManufacturingSerial else { return events }
|
|
101
|
+
if identifiedSerial != serial {
|
|
102
|
+
identifiedSerial = serial
|
|
103
|
+
identifiedReportingDay = reportingDay
|
|
104
|
+
events.append(identifiedEvent(snapshot, serial: serial, kind: "glasses_identified"))
|
|
105
|
+
} else if identifiedReportingDay != reportingDay {
|
|
106
|
+
identifiedReportingDay = reportingDay
|
|
107
|
+
events.append(identifiedEvent(snapshot, serial: serial, kind: "glasses_heartbeat"))
|
|
108
|
+
}
|
|
109
|
+
return events
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private func connectedEvent(_ snapshot: AnalyticsGlassesSnapshot, modelUnresolved: Bool) -> AnalyticsEvent {
|
|
113
|
+
var properties: [String: Any] = [
|
|
114
|
+
"event_kind": "glasses_connected",
|
|
115
|
+
"fully_booted": snapshot.fullyBooted,
|
|
116
|
+
"glasses_is_simulated": snapshot.model == simulatedModel,
|
|
117
|
+
]
|
|
118
|
+
if !snapshot.model.isBlank { properties["glasses_model"] = snapshot.model }
|
|
119
|
+
if modelUnresolved { properties["glasses_model_unresolved"] = true }
|
|
120
|
+
return AnalyticsEvent(name: "bluetooth_sdk_glasses_connected", properties: properties)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private func identifiedEvent(_ snapshot: AnalyticsGlassesSnapshot, serial: String, kind: String) -> AnalyticsEvent {
|
|
124
|
+
var properties: [String: Any] = [
|
|
125
|
+
"event_kind": kind,
|
|
126
|
+
"fully_booted": snapshot.fullyBooted,
|
|
127
|
+
"glasses_device_id": serial,
|
|
128
|
+
"glasses_device_id_type": "manufacturing_serial",
|
|
129
|
+
"glasses_is_simulated": snapshot.model == simulatedModel,
|
|
130
|
+
]
|
|
131
|
+
if !snapshot.model.isBlank { properties["glasses_model"] = snapshot.model }
|
|
132
|
+
let software: [(String, String)] = [
|
|
133
|
+
("glasses_firmware_version", snapshot.firmwareVersion),
|
|
134
|
+
("glasses_bes_firmware_version", snapshot.besFirmwareVersion),
|
|
135
|
+
("glasses_mtk_firmware_version", snapshot.mtkFirmwareVersion),
|
|
136
|
+
("glasses_android_version", snapshot.androidVersion),
|
|
137
|
+
("glasses_app_version", snapshot.appVersion),
|
|
138
|
+
("glasses_build_number", snapshot.buildNumber),
|
|
139
|
+
]
|
|
140
|
+
for (key, value) in software where !value.isBlank {
|
|
141
|
+
properties[key] = value
|
|
142
|
+
}
|
|
143
|
+
return AnalyticsEvent(name: "bluetooth_sdk_glasses_identified", properties: properties)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
extension String {
|
|
148
|
+
var isBlank: Bool {
|
|
149
|
+
trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
var validManufacturingSerial: String? {
|
|
153
|
+
let normalized = trimmingCharacters(in: .whitespacesAndNewlines)
|
|
154
|
+
guard !normalized.isEmpty, normalized.contains(where: { $0 != "0" }) else { return nil }
|
|
155
|
+
return normalized
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Process-wide owner of analytics delivery: one serial queue and one retry file
|
|
4
|
+
/// for the whole process. Connection tracking stays per SDK instance, but two
|
|
5
|
+
/// instances can overlap briefly when the SDK is recreated, and if each owned its
|
|
6
|
+
/// own queue a drain started by the old instance could rewrite the retry file over
|
|
7
|
+
/// an event the new instance had just persisted. A single serial queue serializes
|
|
8
|
+
/// every read-modify-write of that file.
|
|
9
|
+
enum BluetoothSdkAnalyticsTransport {
|
|
10
|
+
static let queue = DispatchQueue(label: "com.mentra.bluetoothsdk.analytics.transport")
|
|
11
|
+
static let retryQueue: BluetoothSdkAnalyticsQueue? =
|
|
12
|
+
BluetoothSdkAnalyticsQueue.defaultFileURL().map { BluetoothSdkAnalyticsQueue(fileURL: $0) }
|
|
13
|
+
}
|