@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
|
@@ -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
|
|
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.
|
|
5
|
-
"releaseSetId": "mentra-3.2.0-dev.
|
|
6
|
-
"sourceCommit": "
|
|
7
|
-
"otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.
|
|
8
|
-
"otaManifestSha256": "
|
|
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.
|
|
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.
|
|
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.
|
|
7
|
-
static let releaseSetId = "mentra-3.2.0-dev.
|
|
8
|
-
static let sourceCommit = "
|
|
9
|
-
static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.
|
|
10
|
-
static let otaManifestSha256 = "
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
private var
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
192
|
-
var
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
}
|