@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.
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
@@ -1 +1 @@
1
- {"version":3,"file":"changelogCatalog.js","sourceRoot":"","sources":["../../src/generated/changelogCatalog.ts"],"names":[],"mappings":"AAAA,wDAAwD;AACxD,MAAM,CAAC,MAAM,4BAA4B,GAAG,MAAM,CAAC,MAAM,CACzD;IACE;QACE,SAAS,EAAE,OAAO;QAClB,UAAU,EAAE,maAAma;KAChb;IACD;QACE,SAAS,EAAE,OAAO;QAClB,UAAU,EAAE,+cAA+c;KAC5d;CACO,CACT,CAAA","sourcesContent":["/** Generated from /changelogs. Do not edit directly. */\nexport const GENERATED_RELEASE_CHANGELOGS = Object.freeze(\n[\n {\n \"version\": \"3.2.0\",\n \"markdown\": \"This release is under active development. User-facing changes will be documented as they land.\\n\\n- Breaking Bluetooth SDK change: `forgetWifiNetwork(ssid)` now returns `WifiForgetResult` instead of `WifiStatusChangeEvent`. Migrate status-only consumers to the semantic `outcome`; the requested network remains `ssid`, while optional post-command connectivity is reported by `connected`, `currentSsid`, and `localIp`.\"\n },\n {\n \"version\": \"3.1.0\",\n \"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 }\n] as const,\n)\n"]}
1
+ {"version":3,"file":"changelogCatalog.js","sourceRoot":"","sources":["../../src/generated/changelogCatalog.ts"],"names":[],"mappings":"AAAA,wDAAwD;AACxD,MAAM,CAAC,MAAM,4BAA4B,GAAG,MAAM,CAAC,MAAM,CACzD;IACE;QACE,SAAS,EAAE,OAAO;QAClB,UAAU,EAAE,maAAma;KAChb;IACD;QACE,SAAS,EAAE,OAAO;QAClB,UAAU,EAAE,u4BAAu4B;KACp5B;CACO,CACT,CAAA","sourcesContent":["/** Generated from /changelogs. Do not edit directly. */\nexport const GENERATED_RELEASE_CHANGELOGS = Object.freeze(\n[\n {\n \"version\": \"3.2.0\",\n \"markdown\": \"This release is under active development. User-facing changes will be documented as they land.\\n\\n- Breaking Bluetooth SDK change: `forgetWifiNetwork(ssid)` now returns `WifiForgetResult` instead of `WifiStatusChangeEvent`. Migrate status-only consumers to the semantic `outcome`; the requested network remains `ssid`, while optional post-command connectivity is reported by `connected`, `currentSsid`, and `localIp`.\"\n },\n {\n \"version\": \"3.1.0\",\n \"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.\"\n }\n] as const,\n)\n"]}
@@ -1,10 +1,10 @@
1
1
  export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
2
2
  "schemaVersion": 1,
3
3
  "familyBaseVersion": "3.2.0",
4
- "releaseIdentity": "3.2.0-dev.209",
5
- "releaseSetId": "mentra-3.2.0-dev.209",
6
- "sourceCommit": "b1606374e1508f0993d1a7e391fddeb4f8fac5bd",
7
- "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.209.json",
8
- "otaManifestSha256": "de5936b765f7deb07a1fd448bb4e9c00f36e1b1313d16c021aff843cf801b489"
4
+ "releaseIdentity": "3.2.0-dev.210",
5
+ "releaseSetId": "mentra-3.2.0-dev.210",
6
+ "sourceCommit": "259fb93fd2ecae4ed7bf280fe3b4e2b2cc594611",
7
+ "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.210.json",
8
+ "otaManifestSha256": "7d8fb1f69ca9a0a6b9d00f87a60694e74aa5377776d1818a7b982b09d9bb65dc"
9
9
  });
10
10
  //# sourceMappingURL=releaseMetadata.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,wHAAwH;IAC1I,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.2.0\",\n \"releaseIdentity\": \"3.2.0-dev.209\",\n \"releaseSetId\": \"mentra-3.2.0-dev.209\",\n \"sourceCommit\": \"b1606374e1508f0993d1a7e391fddeb4f8fac5bd\",\n \"otaManifestUrl\": \"https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.209.json\",\n \"otaManifestSha256\": \"de5936b765f7deb07a1fd448bb4e9c00f36e1b1313d16c021aff843cf801b489\"\n})\n"]}
1
+ {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,wHAAwH;IAC1I,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.2.0\",\n \"releaseIdentity\": \"3.2.0-dev.210\",\n \"releaseSetId\": \"mentra-3.2.0-dev.210\",\n \"sourceCommit\": \"259fb93fd2ecae4ed7bf280fe3b4e2b2cc594611\",\n \"otaManifestUrl\": \"https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.210.json\",\n \"otaManifestSha256\": \"7d8fb1f69ca9a0a6b9d00f87a60694e74aa5377776d1818a7b982b09d9bb65dc\"\n})\n"]}
@@ -11,7 +11,7 @@ enum BluetoothSdkDefaults {
11
11
  static let voiceActivityDetectionEnabled = false
12
12
  static let loudnessGateEnabled = false
13
13
  private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
14
- private static let swiftPackageSdkVersion = "3.2.0-dev.209"
14
+ private static let swiftPackageSdkVersion = "3.2.0-dev.210"
15
15
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
16
16
 
17
17
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -3,5 +3,5 @@ import Foundation
3
3
  /// Generated from /changelogs. Do not edit directly.
4
4
  let generatedReleaseChangelogs: [ReleaseChangelog] = [
5
5
  ReleaseChangelog(version: "3.2.0", markdown: "This release is under active development. User-facing changes will be documented as they land.\n\n- Breaking Bluetooth SDK change: `forgetWifiNetwork(ssid)` now returns `WifiForgetResult` instead of `WifiStatusChangeEvent`. Migrate status-only consumers to the semantic `outcome`; the requested network remains `ssid`, while optional post-command connectivity is reported by `connected`, `currentSsid`, and `localIp`."),
6
- ReleaseChangelog(version: "3.1.0", 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."),
6
+ ReleaseChangelog(version: "3.1.0", 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."),
7
7
  ]
@@ -3,9 +3,9 @@ import Foundation
3
3
  /// Generated by release CI. Do not edit in a release checkout.
4
4
  enum GeneratedReleaseMetadata {
5
5
  static let familyBaseVersion = "3.2.0"
6
- static let releaseIdentity = "3.2.0-dev.209"
7
- static let releaseSetId = "mentra-3.2.0-dev.209"
8
- static let sourceCommit = "b1606374e1508f0993d1a7e391fddeb4f8fac5bd"
9
- static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.209.json"
10
- static let otaManifestSha256 = "de5936b765f7deb07a1fd448bb4e9c00f36e1b1313d16c021aff843cf801b489"
6
+ static let releaseIdentity = "3.2.0-dev.210"
7
+ static let releaseSetId = "mentra-3.2.0-dev.210"
8
+ static let sourceCommit = "259fb93fd2ecae4ed7bf280fe3b4e2b2cc594611"
9
+ static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.210.json"
10
+ static let otaManifestSha256 = "7d8fb1f69ca9a0a6b9d00f87a60694e74aa5377776d1818a7b982b09d9bb65dc"
11
11
  }
@@ -39,11 +39,22 @@ final class BluetoothSdkAnalytics {
39
39
  private static let defaultPostHogApiKey = "phc_FCweXVAxVgU7wZK4Fk3okOx4RmyNqVHJf62YpZSfJt5"
40
40
  private static let defaultPostHogHost = "https://us.i.posthog.com"
41
41
  private let stateQueue = DispatchQueue(label: "com.mentra.bluetoothsdk.analytics.state")
42
- private let transportQueue = DispatchQueue(label: "com.mentra.bluetoothsdk.analytics.transport")
42
+ // Delivery is process-wide (see BluetoothSdkAnalyticsTransport); this instance only tracks its connection.
43
+ private let transportQueue = BluetoothSdkAnalyticsTransport.queue
43
44
  private let configuration: BluetoothSdkAnalyticsConfiguration
45
+ private var tracker = BluetoothSdkAnalyticsTracker(simulatedModel: DeviceTypes.SIMULATED)
44
46
  private var startedCaptured = false
45
- private var lastConnected = false
46
- private var identifiedCapturedForConnection = false
47
+ // Touched only on transportQueue, which serializes access.
48
+ private var resolvedHostProperties: [String: Any]?
49
+ private var retryQueue: BluetoothSdkAnalyticsQueue? {
50
+ BluetoothSdkAnalyticsTransport.retryQueue
51
+ }
52
+
53
+ private let isoFormatter: ISO8601DateFormatter = {
54
+ let formatter = ISO8601DateFormatter()
55
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
56
+ return formatter
57
+ }()
47
58
 
48
59
  init(configuration: BluetoothSdkAnalyticsConfiguration) {
49
60
  self.configuration = configuration.resolvedForApp()
@@ -51,71 +62,27 @@ final class BluetoothSdkAnalytics {
51
62
 
52
63
  func initializeGlassesStatus(_ status: GlassesStatus) {
53
64
  stateQueue.sync {
54
- lastConnected = status.analyticsConnected
55
- // Only treat identification as already captured when a valid serial is
56
- // present at init. If the glasses are connected but the serial has not
57
- // arrived yet (Mentra Live fills it via version_info after connect), leave
58
- // this false so the identify event still fires once the serial arrives.
59
- identifiedCapturedForConnection =
60
- status.analyticsConnected && status.serialNumber.validManufacturingSerial != nil
65
+ tracker.initialize(status.analyticsSnapshot, reportingDay: BluetoothSdkAnalyticsTracker.reportingDay())
61
66
  }
62
67
  }
63
68
 
64
69
  func captureStarted() {
65
70
  stateQueue.sync {
66
- captureStartedLocked()
71
+ guard !startedCaptured, configuration.isReady else { return }
72
+ startedCaptured = true
73
+ capture(event: "bluetooth_sdk_started", properties: ["event_kind": "sdk_started"], configuration: configuration)
74
+ // A fresh runtime is the natural moment to retry what an earlier one could not deliver.
75
+ transportQueue.async { self.drainRetryQueue(now: Date()) }
67
76
  }
68
77
  }
69
78
 
70
- private func captureStartedLocked() {
71
- guard !startedCaptured, configuration.isReady else { return }
72
- startedCaptured = true
73
- capture(
74
- event: "bluetooth_sdk_started",
75
- properties: ["event_kind": "sdk_started"],
76
- configuration: configuration
77
- )
78
- }
79
-
80
79
  func observeGlassesStatus(_ status: GlassesStatus) {
81
80
  stateQueue.sync {
82
- let isConnected = status.analyticsConnected
83
- let wasConnected = lastConnected
84
- lastConnected = isConnected
81
+ let events = tracker.observe(status.analyticsSnapshot, reportingDay: BluetoothSdkAnalyticsTracker.reportingDay())
85
82
  guard configuration.isReady else { return }
86
- guard isConnected else {
87
- identifiedCapturedForConnection = false
88
- return
89
- }
90
- if isConnected, !wasConnected {
91
- identifiedCapturedForConnection = false
92
- var properties: [String: Any] = [
93
- "event_kind": "glasses_connected",
94
- "fully_booted": status.fullyBooted,
95
- ]
96
- if !status.deviceModel.isEmpty {
97
- properties["glasses_model"] = status.deviceModel
98
- }
99
- capture(event: "bluetooth_sdk_glasses_connected", properties: properties, configuration: configuration)
100
- // Fall through: a serial already present at connect time (G1/Ar99
101
- // report it in the advertisement) should be identified now rather than
102
- // waiting for some later, unrelated glasses-store update to run.
83
+ for event in events {
84
+ capture(event: event.name, properties: event.properties, configuration: configuration)
103
85
  }
104
-
105
- guard !identifiedCapturedForConnection,
106
- let serialNumber = status.serialNumber.validManufacturingSerial
107
- else { return }
108
- identifiedCapturedForConnection = true
109
- var properties: [String: Any] = [
110
- "event_kind": "glasses_identified",
111
- "fully_booted": status.fullyBooted,
112
- "glasses_device_id": serialNumber,
113
- "glasses_device_id_type": "manufacturing_serial",
114
- ]
115
- if !status.deviceModel.isEmpty {
116
- properties["glasses_model"] = status.deviceModel
117
- }
118
- capture(event: "bluetooth_sdk_glasses_identified", properties: properties, configuration: configuration)
119
86
  }
120
87
  }
121
88
 
@@ -125,25 +92,72 @@ final class BluetoothSdkAnalytics {
125
92
  configuration activeConfiguration: BluetoothSdkAnalyticsConfiguration
126
93
  ) {
127
94
  guard activeConfiguration.isReady else { return }
95
+ // Identity and time are fixed at capture, not at (re)send, so a retried
96
+ // event neither double counts nor drifts into a later week.
97
+ let uuid = UUID().uuidString.lowercased()
98
+ let capturedAt = Date()
128
99
 
129
100
  transportQueue.async {
101
+ // Host facts are resolved once, on the transport queue: Bundle lookups
102
+ // must not run on the caller (often the Bluetooth status thread).
103
+ let host = self.transportQueueHostProperties()
130
104
  let payload: [String: Any] = [
131
105
  "api_key": Self.defaultPostHogApiKey,
106
+ "uuid": uuid,
132
107
  "event": event,
133
108
  "distinct_id": self.distinctId(),
134
- "properties": self.baseProperties(configuration: activeConfiguration).merging(properties) { _, new in new },
109
+ "timestamp": self.isoFormatter.string(from: capturedAt),
110
+ "properties": self.baseProperties(configuration: activeConfiguration)
111
+ .merging(host) { _, new in new }
112
+ .merging(properties) { _, new in new },
135
113
  ]
136
- guard let body = try? JSONSerialization.data(withJSONObject: payload) else { return }
137
- guard let captureURL = self.captureURL() else { return }
138
- var request = URLRequest(url: captureURL)
139
- request.httpMethod = "POST"
140
- request.timeoutInterval = 4
141
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
142
- request.httpBody = body
143
- URLSession.shared.dataTask(with: request).resume()
114
+ switch self.send(payload) {
115
+ case .delivered: self.drainRetryQueue(now: capturedAt)
116
+ case .retry: self.retryQueue?.enqueue(payload, now: capturedAt)
117
+ case .discard: break
118
+ }
144
119
  }
145
120
  }
146
121
 
122
+ /// Only ever called from `transportQueue`.
123
+ private func drainRetryQueue(now: Date) {
124
+ retryQueue?.drain(now: now) { payload in self.send(payload) }
125
+ }
126
+
127
+ /// Synchronous on purpose: it runs on the transport queue, and one request in
128
+ /// flight at a time keeps retry ordering trivial. The completion handler owns the
129
+ /// outcome; if it has not fired by the deadline the task is cancelled and the
130
+ /// payload is treated as retryable (its uuid makes a late duplicate harmless).
131
+ private func send(_ payload: [String: Any]) -> SendOutcome {
132
+ guard JSONSerialization.isValidJSONObject(payload),
133
+ let body = try? JSONSerialization.data(withJSONObject: payload),
134
+ let captureURL = captureURL()
135
+ else { return .discard }
136
+ var request = URLRequest(url: captureURL)
137
+ request.httpMethod = "POST"
138
+ request.timeoutInterval = 4
139
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
140
+ request.httpBody = body
141
+
142
+ let result = SendResult()
143
+ let task = URLSession.shared.dataTask(with: request) { _, response, error in
144
+ let outcome: SendOutcome
145
+ if error != nil {
146
+ outcome = .retry
147
+ } else if let http = response as? HTTPURLResponse {
148
+ outcome = SendOutcome.fromHTTPStatus(http.statusCode)
149
+ } else {
150
+ outcome = .retry
151
+ }
152
+ result.complete(outcome)
153
+ }
154
+ task.resume()
155
+ if result.wait(timeout: .now() + 6) == .timedOut {
156
+ task.cancel()
157
+ }
158
+ return result.outcome
159
+ }
160
+
147
161
  private func baseProperties(configuration: BluetoothSdkAnalyticsConfiguration) -> [String: Any] {
148
162
  #if os(macOS)
149
163
  let platform = "macos"
@@ -160,12 +174,20 @@ final class BluetoothSdkAnalytics {
160
174
  "os_platform": platform,
161
175
  "os_version": ProcessInfo.processInfo.operatingSystemVersionString,
162
176
  ]
163
- if let sdkVersion = BluetoothSdkDefaults.sdkVersion {
164
- properties["sdk_version"] = sdkVersion
165
- }
177
+ // Always present so version cohorts never collapse into a null bucket. The
178
+ // placeholder shows up only for unstamped source builds (plain SwiftPM checkout).
179
+ properties["sdk_version"] = BluetoothSdkDefaults.sdkVersion ?? "unknown"
166
180
  return properties
167
181
  }
168
182
 
183
+ /// Only ever called from `transportQueue`, which serializes access.
184
+ private func transportQueueHostProperties() -> [String: Any] {
185
+ if let resolvedHostProperties { return resolvedHostProperties }
186
+ let resolved = BluetoothSdkAnalyticsHost.resolve().properties
187
+ resolvedHostProperties = resolved
188
+ return resolved
189
+ }
190
+
169
191
  private func distinctId() -> String {
170
192
  let key = "mentra_bluetooth_sdk_analytics_distinct_id"
171
193
  if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
@@ -182,17 +204,46 @@ final class BluetoothSdkAnalytics {
182
204
  }
183
205
  }
184
206
 
185
- private extension GlassesStatus {
186
- var analyticsConnected: Bool {
187
- connectionState.isConnected || connected || fullyBooted
207
+ /// Completion-owned send result: the outcome is written once, under a lock, and
208
+ /// read only after the semaphore says it is settled (or after a timeout, in which
209
+ /// case it stays `.retry`).
210
+ private final class SendResult {
211
+ private let lock = NSLock()
212
+ private let semaphore = DispatchSemaphore(value: 0)
213
+ private var settled: SendOutcome = .retry
214
+
215
+ var outcome: SendOutcome {
216
+ lock.lock()
217
+ defer { lock.unlock() }
218
+ return settled
219
+ }
220
+
221
+ func complete(_ outcome: SendOutcome) {
222
+ lock.lock()
223
+ settled = outcome
224
+ lock.unlock()
225
+ semaphore.signal()
226
+ }
227
+
228
+ func wait(timeout: DispatchTime) -> DispatchTimeoutResult {
229
+ semaphore.wait(timeout: timeout)
188
230
  }
189
231
  }
190
232
 
191
- private extension String {
192
- var validManufacturingSerial: String? {
193
- let normalized = trimmingCharacters(in: .whitespacesAndNewlines)
194
- guard !normalized.isEmpty, normalized.contains(where: { $0 != "0" }) else { return nil }
195
- return normalized
233
+ private extension GlassesStatus {
234
+ var analyticsSnapshot: AnalyticsGlassesSnapshot {
235
+ AnalyticsGlassesSnapshot(
236
+ connected: connectionState.isConnected || connected || fullyBooted,
237
+ fullyBooted: fullyBooted,
238
+ model: deviceModel,
239
+ serialNumber: serialNumber,
240
+ firmwareVersion: firmwareVersion,
241
+ besFirmwareVersion: besFirmwareVersion,
242
+ mtkFirmwareVersion: mtkFirmwareVersion,
243
+ androidVersion: androidVersion,
244
+ appVersion: appVersion,
245
+ buildNumber: buildNumber
246
+ )
196
247
  }
197
248
  }
198
249
 
@@ -0,0 +1,91 @@
1
+ import Foundation
2
+
3
+ /// Host-app facts attached to every analytics event so Mentra can separate
4
+ /// store installs from TestFlight, ad-hoc, development, and simulator builds.
5
+ struct BluetoothSdkAnalyticsHostProperties {
6
+ let appVersion: String?
7
+ let appBuild: String?
8
+ let buildType: String
9
+ let installSource: String
10
+ let environment: String?
11
+
12
+ var properties: [String: Any] {
13
+ var values: [String: Any] = [
14
+ "app_build_type": buildType,
15
+ "app_install_source": installSource,
16
+ ]
17
+ if let appVersion { values["app_version"] = appVersion }
18
+ if let appBuild { values["app_build"] = appBuild }
19
+ if let environment { values["app_environment"] = environment }
20
+ return values
21
+ }
22
+ }
23
+
24
+ enum BluetoothSdkAnalyticsHost {
25
+ /// Optional host-declared lane (`dev`, `staging`, `prod`, ...). Stamped by the
26
+ /// Expo config plugin's `analytics.environment` option; native apps may set it directly.
27
+ static let infoEnvironmentKey = "MentraBluetoothSdkAnalyticsEnvironment"
28
+
29
+ /// An embedded provisioning profile only exists in development, ad-hoc, and
30
+ /// enterprise builds, so it is checked before the receipt: TestFlight and App
31
+ /// Store builds carry no profile, and the receipt file name then tells them
32
+ /// apart (`sandboxReceipt` vs `receipt`). Missing or unrecognized evidence is
33
+ /// reported as `unknown` rather than guessed, because this value decides
34
+ /// whether an install counts as production.
35
+ static func installSource(
36
+ isSimulator: Bool,
37
+ hasEmbeddedProvisioningProfile: Bool,
38
+ receiptFileName: String?
39
+ ) -> String {
40
+ if isSimulator { return "simulator" }
41
+ if hasEmbeddedProvisioningProfile { return "adhoc_or_dev" }
42
+ switch receiptFileName {
43
+ case "sandboxReceipt": return "testflight"
44
+ case "receipt": return "app_store"
45
+ default: return "unknown"
46
+ }
47
+ }
48
+
49
+ static func normalizedEnvironment(_ raw: String?) -> String? {
50
+ guard let raw else { return nil }
51
+ let value = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
52
+ guard !value.isEmpty, value.count <= 32 else { return nil }
53
+ let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789_-")
54
+ guard value.unicodeScalars.allSatisfy({ allowed.contains($0) }),
55
+ let first = value.unicodeScalars.first,
56
+ first != "_", first != "-"
57
+ else { return nil }
58
+ return value
59
+ }
60
+
61
+ static func resolve(bundle: Bundle = .main) -> BluetoothSdkAnalyticsHostProperties {
62
+ #if targetEnvironment(simulator)
63
+ let isSimulator = true
64
+ #else
65
+ let isSimulator = false
66
+ #endif
67
+ #if DEBUG
68
+ let buildType = "debug"
69
+ #else
70
+ let buildType = "release"
71
+ #endif
72
+ let info = bundle.infoDictionary ?? [:]
73
+ return BluetoothSdkAnalyticsHostProperties(
74
+ appVersion: nonBlank(info["CFBundleShortVersionString"] as? String),
75
+ appBuild: nonBlank(info["CFBundleVersion"] as? String),
76
+ buildType: buildType,
77
+ installSource: installSource(
78
+ isSimulator: isSimulator,
79
+ hasEmbeddedProvisioningProfile: bundle.path(forResource: "embedded", ofType: "mobileprovision") != nil,
80
+ receiptFileName: bundle.appStoreReceiptURL?.lastPathComponent
81
+ ),
82
+ environment: normalizedEnvironment(info[infoEnvironmentKey] as? String)
83
+ )
84
+ }
85
+
86
+ private static func nonBlank(_ value: String?) -> String? {
87
+ guard let value else { return nil }
88
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
89
+ return trimmed.isEmpty ? nil : trimmed
90
+ }
91
+ }
@@ -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
+ }