@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.
Files changed (51) hide show
  1. package/README.md +46 -13
  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/BluetoothSdkDefaults.kt +1 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  11. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +6 -5
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +11 -1
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2SerialResolution.kt +23 -0
  14. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsHostTest.kt +64 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsQueueTest.kt +86 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +148 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTransportTest.kt +44 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/G2SerialResolutionTest.kt +25 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +8 -0
  20. package/build/generated/changelogCatalog.d.ts +4 -1
  21. package/build/generated/changelogCatalog.d.ts.map +1 -1
  22. package/build/generated/changelogCatalog.js +5 -1
  23. package/build/generated/changelogCatalog.js.map +1 -1
  24. package/build/generated/releaseMetadata.js +6 -6
  25. package/build/generated/releaseMetadata.js.map +1 -1
  26. package/ios/Source/BluetoothSdkDefaults.swift +2 -2
  27. package/ios/Source/GeneratedChangelogCatalog.swift +2 -1
  28. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  29. package/ios/Source/OtaManifest.swift +6 -5
  30. package/ios/Source/internal/BluetoothSdkAnalytics.swift +127 -76
  31. package/ios/Source/internal/BluetoothSdkAnalyticsHost.swift +91 -0
  32. package/ios/Source/internal/BluetoothSdkAnalyticsQueue.swift +117 -0
  33. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +157 -0
  34. package/ios/Source/internal/BluetoothSdkAnalyticsTransport.swift +13 -0
  35. package/ios/Source/sgcs/G2.swift +375 -360
  36. package/ios/Source/sgcs/G2SerialResolution.swift +23 -0
  37. package/ios/Tests/BluetoothSdkAnalyticsHostTests.swift +63 -0
  38. package/ios/Tests/BluetoothSdkAnalyticsQueueTests.swift +97 -0
  39. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +122 -0
  40. package/ios/Tests/BluetoothSdkAnalyticsTransportTests.swift +36 -0
  41. package/ios/Tests/G2SerialResolutionTests.swift +19 -0
  42. package/ios/Tests/OtaManifestDowngradeTests.swift +20 -0
  43. package/package.json +1 -1
  44. package/plugin/build/analyticsProps.d.ts +9 -0
  45. package/plugin/build/analyticsProps.js +34 -0
  46. package/plugin/build/index.d.ts +8 -0
  47. package/plugin/build/withAndroid.js +12 -18
  48. package/plugin/build/withIos.d.ts +1 -0
  49. package/plugin/build/withIos.js +8 -18
  50. package/src/generated/changelogCatalog.ts +5 -1
  51. 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
+ }