@mentra/bluetooth-sdk 3.1.0-dev.96 → 3.1.0
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 +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- 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 +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 +2 -2
- package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- 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 +1 -1
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
import com.mentra.bluetoothsdk.sgcs.G2SerialResolution
|
|
4
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
5
|
+
import org.junit.Test
|
|
6
|
+
|
|
7
|
+
class G2SerialResolutionTest {
|
|
8
|
+
@Test
|
|
9
|
+
fun `a scanned serial always wins`() {
|
|
10
|
+
assertThat(G2SerialResolution.resolve("S2ABCD12345678", "OTHER", "S2OLD000000000")).isEqualTo("S2ABCD12345678")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@Test
|
|
14
|
+
fun `a cached reconnect reuses the persisted serial only when it is the requested id`() {
|
|
15
|
+
assertThat(G2SerialResolution.resolve(null, "S2ABCD12345678", "S2ABCD12345678")).isEqualTo("S2ABCD12345678")
|
|
16
|
+
assertThat(G2SerialResolution.resolve(null, "12345678", "S2ABCD12345678")).isNull()
|
|
17
|
+
assertThat(G2SerialResolution.resolve(null, "S2ABCD12345678", "")).isNull()
|
|
18
|
+
assertThat(G2SerialResolution.resolve(null, "NOT_SET", "NOT_SET")).isNull()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Test
|
|
22
|
+
fun `switching to a different pair does not leak the previous serial`() {
|
|
23
|
+
assertThat(G2SerialResolution.resolve(null, "S2NEW000000001", "S2OLD000000000")).isNull()
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -31,6 +31,14 @@ class OtaManifestDowngradeTest {
|
|
|
31
31
|
downgradeFloorVersionCode = floor,
|
|
32
32
|
)
|
|
33
33
|
|
|
34
|
+
@Test
|
|
35
|
+
fun shippedFloorAllowsMentra30ButRejectsOlderTargets() {
|
|
36
|
+
for ((target, expected) in listOf(51518113L to false, 51518114L to true, 51518115L to true, 100000095L to false)) {
|
|
37
|
+
assertEquals(expected, OtaManifestChecker.hasUpdate("100000095", "", "", manifest(target)))
|
|
38
|
+
}
|
|
39
|
+
assertFalse(OtaManifestChecker.hasUpdate("100000095", "", "", manifest(51518114L), downgradeFloorVersionCode = 0L))
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
@Test
|
|
35
43
|
fun upgradeIsAlwaysAnUpdate() {
|
|
36
44
|
assertTrue(hasUpdate("49000000", manifest(49076573L)))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Generated from /changelogs. Do not edit directly. */
|
|
2
2
|
export declare const GENERATED_RELEASE_CHANGELOGS: readonly [{
|
|
3
3
|
readonly version: "3.1.0";
|
|
4
|
-
readonly 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.";
|
|
4
|
+
readonly 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.";
|
|
5
5
|
}];
|
|
6
6
|
//# sourceMappingURL=changelogCatalog.d.ts.map
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export const GENERATED_RELEASE_CHANGELOGS = Object.freeze([
|
|
3
3
|
{
|
|
4
4
|
"version": "3.1.0",
|
|
5
|
-
"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."
|
|
5
|
+
"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."
|
|
6
6
|
}
|
|
7
7
|
]);
|
|
8
8
|
//# sourceMappingURL=changelogCatalog.js.map
|
|
@@ -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
|
|
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,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.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.1.0",
|
|
4
|
-
"releaseIdentity": "3.1.0
|
|
5
|
-
"releaseSetId": "mentra-3.1.0
|
|
6
|
-
"sourceCommit": "
|
|
7
|
-
"otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.1.0/mentra-live-ota-3.1.0-
|
|
8
|
-
"otaManifestSha256": "
|
|
4
|
+
"releaseIdentity": "3.1.0",
|
|
5
|
+
"releaseSetId": "mentra-3.1.0",
|
|
6
|
+
"sourceCommit": "d1ead9b78b26bc44c45009a27a211c23fc56c22a",
|
|
7
|
+
"otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.1.0/mentra-live-ota-3.1.0-beta.212.json",
|
|
8
|
+
"otaManifestSha256": "bd76af0eb35f459e702b210036d1d8eb5d0e00a1c100f3873bad391d56abdbfe"
|
|
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,
|
|
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,OAAO;IAC1B,cAAc,EAAE,cAAc;IAC9B,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,yHAAyH;IAC3I,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.1.0\",\n \"releaseIdentity\": \"3.1.0\",\n \"releaseSetId\": \"mentra-3.1.0\",\n \"sourceCommit\": \"d1ead9b78b26bc44c45009a27a211c23fc56c22a\",\n \"otaManifestUrl\": \"https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.1.0/mentra-live-ota-3.1.0-beta.212.json\",\n \"otaManifestSha256\": \"bd76af0eb35f459e702b210036d1d8eb5d0e00a1c100f3873bad391d56abdbfe\"\n})\n"]}
|
|
@@ -9,9 +9,9 @@ enum BluetoothSdkDefaults {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
static let voiceActivityDetectionEnabled = false
|
|
12
|
-
static let loudnessGateEnabled =
|
|
12
|
+
static let loudnessGateEnabled = false
|
|
13
13
|
private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
|
|
14
|
-
private static let swiftPackageSdkVersion = "3.1.0
|
|
14
|
+
private static let swiftPackageSdkVersion = "3.1.0"
|
|
15
15
|
private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
|
|
16
16
|
|
|
17
17
|
private static func normalizedSdkVersion(_ value: String?) -> String? {
|
|
@@ -2,5 +2,5 @@ import Foundation
|
|
|
2
2
|
|
|
3
3
|
/// Generated from /changelogs. Do not edit directly.
|
|
4
4
|
let generatedReleaseChangelogs: [ReleaseChangelog] = [
|
|
5
|
-
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."),
|
|
5
|
+
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."),
|
|
6
6
|
]
|
|
@@ -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.1.0"
|
|
6
|
-
static let releaseIdentity = "3.1.0
|
|
7
|
-
static let releaseSetId = "mentra-3.1.0
|
|
8
|
-
static let sourceCommit = "
|
|
9
|
-
static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.1.0/mentra-live-ota-3.1.0-
|
|
10
|
-
static let otaManifestSha256 = "
|
|
6
|
+
static let releaseIdentity = "3.1.0"
|
|
7
|
+
static let releaseSetId = "mentra-3.1.0"
|
|
8
|
+
static let sourceCommit = "d1ead9b78b26bc44c45009a27a211c23fc56c22a"
|
|
9
|
+
static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.1.0/mentra-live-ota-3.1.0-beta.212.json"
|
|
10
|
+
static let otaManifestSha256 = "bd76af0eb35f459e702b210036d1d8eb5d0e00a1c100f3873bad391d56abdbfe"
|
|
11
11
|
}
|
|
@@ -87,11 +87,12 @@ enum OtaManifestChecker {
|
|
|
87
87
|
currentMtkVersion: String,
|
|
88
88
|
currentBesVersion: String,
|
|
89
89
|
manifest: OtaManifest,
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
90
|
+
// Mentra 3.0 is the oldest downgrade-safe target, matching Engine, ASG, and recovery.
|
|
91
|
+
// Coordinated releases guarantee higher supported ASG builds can downgrade; no source gate is needed.
|
|
92
|
+
// The shipped floor may increase, never decrease, and must stay aligned across all checkers.
|
|
93
|
+
// See asg_client/docs/mentra-live-spec.md#ota-and-updates.
|
|
94
|
+
// An explicit non-positive override still disables downgrades.
|
|
95
|
+
downgradeFloorVersionCode: Int = 51518114
|
|
95
96
|
) throws -> Bool {
|
|
96
97
|
try hasApkUpdate(
|
|
97
98
|
currentBuildNumber: currentBuildNumber,
|
|
@@ -35,11 +35,22 @@ final class BluetoothSdkAnalytics {
|
|
|
35
35
|
private static let defaultPostHogApiKey = "phc_FCweXVAxVgU7wZK4Fk3okOx4RmyNqVHJf62YpZSfJt5"
|
|
36
36
|
private static let defaultPostHogHost = "https://us.i.posthog.com"
|
|
37
37
|
private let stateQueue = DispatchQueue(label: "com.mentra.bluetoothsdk.analytics.state")
|
|
38
|
-
|
|
38
|
+
// Delivery is process-wide (see BluetoothSdkAnalyticsTransport); this instance only tracks its connection.
|
|
39
|
+
private let transportQueue = BluetoothSdkAnalyticsTransport.queue
|
|
39
40
|
private let configuration: BluetoothSdkAnalyticsConfiguration
|
|
41
|
+
private var tracker = BluetoothSdkAnalyticsTracker(simulatedModel: DeviceTypes.SIMULATED)
|
|
40
42
|
private var startedCaptured = false
|
|
41
|
-
|
|
42
|
-
private var
|
|
43
|
+
// Touched only on transportQueue, which serializes access.
|
|
44
|
+
private var resolvedHostProperties: [String: Any]?
|
|
45
|
+
private var retryQueue: BluetoothSdkAnalyticsQueue? {
|
|
46
|
+
BluetoothSdkAnalyticsTransport.retryQueue
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private let isoFormatter: ISO8601DateFormatter = {
|
|
50
|
+
let formatter = ISO8601DateFormatter()
|
|
51
|
+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
52
|
+
return formatter
|
|
53
|
+
}()
|
|
43
54
|
|
|
44
55
|
init(configuration: BluetoothSdkAnalyticsConfiguration) {
|
|
45
56
|
self.configuration = configuration.resolvedForApp()
|
|
@@ -47,71 +58,27 @@ final class BluetoothSdkAnalytics {
|
|
|
47
58
|
|
|
48
59
|
func initializeGlassesStatus(_ status: GlassesStatus) {
|
|
49
60
|
stateQueue.sync {
|
|
50
|
-
|
|
51
|
-
// Only treat identification as already captured when a valid serial is
|
|
52
|
-
// present at init. If the glasses are connected but the serial has not
|
|
53
|
-
// arrived yet (Mentra Live fills it via version_info after connect), leave
|
|
54
|
-
// this false so the identify event still fires once the serial arrives.
|
|
55
|
-
identifiedCapturedForConnection =
|
|
56
|
-
status.analyticsConnected && status.serialNumber.validManufacturingSerial != nil
|
|
61
|
+
tracker.initialize(status.analyticsSnapshot, reportingDay: BluetoothSdkAnalyticsTracker.reportingDay())
|
|
57
62
|
}
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
func captureStarted() {
|
|
61
66
|
stateQueue.sync {
|
|
62
|
-
|
|
67
|
+
guard !startedCaptured, configuration.isReady else { return }
|
|
68
|
+
startedCaptured = true
|
|
69
|
+
capture(event: "bluetooth_sdk_started", properties: ["event_kind": "sdk_started"], configuration: configuration)
|
|
70
|
+
// A fresh runtime is the natural moment to retry what an earlier one could not deliver.
|
|
71
|
+
transportQueue.async { self.drainRetryQueue(now: Date()) }
|
|
63
72
|
}
|
|
64
73
|
}
|
|
65
74
|
|
|
66
|
-
private func captureStartedLocked() {
|
|
67
|
-
guard !startedCaptured, configuration.isReady else { return }
|
|
68
|
-
startedCaptured = true
|
|
69
|
-
capture(
|
|
70
|
-
event: "bluetooth_sdk_started",
|
|
71
|
-
properties: ["event_kind": "sdk_started"],
|
|
72
|
-
configuration: configuration
|
|
73
|
-
)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
75
|
func observeGlassesStatus(_ status: GlassesStatus) {
|
|
77
76
|
stateQueue.sync {
|
|
78
|
-
let
|
|
79
|
-
let wasConnected = lastConnected
|
|
80
|
-
lastConnected = isConnected
|
|
77
|
+
let events = tracker.observe(status.analyticsSnapshot, reportingDay: BluetoothSdkAnalyticsTracker.reportingDay())
|
|
81
78
|
guard configuration.isReady else { return }
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return
|
|
85
|
-
}
|
|
86
|
-
if isConnected, !wasConnected {
|
|
87
|
-
identifiedCapturedForConnection = false
|
|
88
|
-
var properties: [String: Any] = [
|
|
89
|
-
"event_kind": "glasses_connected",
|
|
90
|
-
"fully_booted": status.fullyBooted,
|
|
91
|
-
]
|
|
92
|
-
if !status.deviceModel.isEmpty {
|
|
93
|
-
properties["glasses_model"] = status.deviceModel
|
|
94
|
-
}
|
|
95
|
-
capture(event: "bluetooth_sdk_glasses_connected", properties: properties, configuration: configuration)
|
|
96
|
-
// Fall through: a serial already present at connect time (G1/Ar99
|
|
97
|
-
// report it in the advertisement) should be identified now rather than
|
|
98
|
-
// waiting for some later, unrelated glasses-store update to run.
|
|
79
|
+
for event in events {
|
|
80
|
+
capture(event: event.name, properties: event.properties, configuration: configuration)
|
|
99
81
|
}
|
|
100
|
-
|
|
101
|
-
guard !identifiedCapturedForConnection,
|
|
102
|
-
let serialNumber = status.serialNumber.validManufacturingSerial
|
|
103
|
-
else { return }
|
|
104
|
-
identifiedCapturedForConnection = true
|
|
105
|
-
var properties: [String: Any] = [
|
|
106
|
-
"event_kind": "glasses_identified",
|
|
107
|
-
"fully_booted": status.fullyBooted,
|
|
108
|
-
"glasses_device_id": serialNumber,
|
|
109
|
-
"glasses_device_id_type": "manufacturing_serial",
|
|
110
|
-
]
|
|
111
|
-
if !status.deviceModel.isEmpty {
|
|
112
|
-
properties["glasses_model"] = status.deviceModel
|
|
113
|
-
}
|
|
114
|
-
capture(event: "bluetooth_sdk_glasses_identified", properties: properties, configuration: configuration)
|
|
115
82
|
}
|
|
116
83
|
}
|
|
117
84
|
|
|
@@ -121,23 +88,70 @@ final class BluetoothSdkAnalytics {
|
|
|
121
88
|
configuration activeConfiguration: BluetoothSdkAnalyticsConfiguration
|
|
122
89
|
) {
|
|
123
90
|
guard activeConfiguration.isReady else { return }
|
|
91
|
+
// Identity and time are fixed at capture, not at (re)send, so a retried
|
|
92
|
+
// event neither double counts nor drifts into a later week.
|
|
93
|
+
let uuid = UUID().uuidString.lowercased()
|
|
94
|
+
let capturedAt = Date()
|
|
124
95
|
|
|
125
96
|
transportQueue.async {
|
|
97
|
+
// Host facts are resolved once, on the transport queue: Bundle lookups
|
|
98
|
+
// must not run on the caller (often the Bluetooth status thread).
|
|
99
|
+
let host = self.transportQueueHostProperties()
|
|
126
100
|
let payload: [String: Any] = [
|
|
127
101
|
"api_key": Self.defaultPostHogApiKey,
|
|
102
|
+
"uuid": uuid,
|
|
128
103
|
"event": event,
|
|
129
104
|
"distinct_id": self.distinctId(),
|
|
130
|
-
"
|
|
105
|
+
"timestamp": self.isoFormatter.string(from: capturedAt),
|
|
106
|
+
"properties": self.baseProperties(configuration: activeConfiguration)
|
|
107
|
+
.merging(host) { _, new in new }
|
|
108
|
+
.merging(properties) { _, new in new },
|
|
131
109
|
]
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
110
|
+
switch self.send(payload) {
|
|
111
|
+
case .delivered: self.drainRetryQueue(now: capturedAt)
|
|
112
|
+
case .retry: self.retryQueue?.enqueue(payload, now: capturedAt)
|
|
113
|
+
case .discard: break
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// Only ever called from `transportQueue`.
|
|
119
|
+
private func drainRetryQueue(now: Date) {
|
|
120
|
+
retryQueue?.drain(now: now) { payload in self.send(payload) }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Synchronous on purpose: it runs on the transport queue, and one request in
|
|
124
|
+
/// flight at a time keeps retry ordering trivial. The completion handler owns the
|
|
125
|
+
/// outcome; if it has not fired by the deadline the task is cancelled and the
|
|
126
|
+
/// payload is treated as retryable (its uuid makes a late duplicate harmless).
|
|
127
|
+
private func send(_ payload: [String: Any]) -> SendOutcome {
|
|
128
|
+
guard JSONSerialization.isValidJSONObject(payload),
|
|
129
|
+
let body = try? JSONSerialization.data(withJSONObject: payload),
|
|
130
|
+
let captureURL = captureURL()
|
|
131
|
+
else { return .discard }
|
|
132
|
+
var request = URLRequest(url: captureURL)
|
|
133
|
+
request.httpMethod = "POST"
|
|
134
|
+
request.timeoutInterval = 4
|
|
135
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
136
|
+
request.httpBody = body
|
|
137
|
+
|
|
138
|
+
let result = SendResult()
|
|
139
|
+
let task = URLSession.shared.dataTask(with: request) { _, response, error in
|
|
140
|
+
let outcome: SendOutcome
|
|
141
|
+
if error != nil {
|
|
142
|
+
outcome = .retry
|
|
143
|
+
} else if let http = response as? HTTPURLResponse {
|
|
144
|
+
outcome = SendOutcome.fromHTTPStatus(http.statusCode)
|
|
145
|
+
} else {
|
|
146
|
+
outcome = .retry
|
|
147
|
+
}
|
|
148
|
+
result.complete(outcome)
|
|
149
|
+
}
|
|
150
|
+
task.resume()
|
|
151
|
+
if result.wait(timeout: .now() + 6) == .timedOut {
|
|
152
|
+
task.cancel()
|
|
140
153
|
}
|
|
154
|
+
return result.outcome
|
|
141
155
|
}
|
|
142
156
|
|
|
143
157
|
private func baseProperties(configuration: BluetoothSdkAnalyticsConfiguration) -> [String: Any] {
|
|
@@ -151,12 +165,20 @@ final class BluetoothSdkAnalytics {
|
|
|
151
165
|
"os_platform": "ios",
|
|
152
166
|
"os_version": ProcessInfo.processInfo.operatingSystemVersionString,
|
|
153
167
|
]
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
168
|
+
// Always present so version cohorts never collapse into a null bucket. The
|
|
169
|
+
// placeholder shows up only for unstamped source builds (plain SwiftPM checkout).
|
|
170
|
+
properties["sdk_version"] = BluetoothSdkDefaults.sdkVersion ?? "unknown"
|
|
157
171
|
return properties
|
|
158
172
|
}
|
|
159
173
|
|
|
174
|
+
/// Only ever called from `transportQueue`, which serializes access.
|
|
175
|
+
private func transportQueueHostProperties() -> [String: Any] {
|
|
176
|
+
if let resolvedHostProperties { return resolvedHostProperties }
|
|
177
|
+
let resolved = BluetoothSdkAnalyticsHost.resolve().properties
|
|
178
|
+
resolvedHostProperties = resolved
|
|
179
|
+
return resolved
|
|
180
|
+
}
|
|
181
|
+
|
|
160
182
|
private func distinctId() -> String {
|
|
161
183
|
let key = "mentra_bluetooth_sdk_analytics_distinct_id"
|
|
162
184
|
if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
|
|
@@ -173,17 +195,46 @@ final class BluetoothSdkAnalytics {
|
|
|
173
195
|
}
|
|
174
196
|
}
|
|
175
197
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
198
|
+
/// Completion-owned send result: the outcome is written once, under a lock, and
|
|
199
|
+
/// read only after the semaphore says it is settled (or after a timeout, in which
|
|
200
|
+
/// case it stays `.retry`).
|
|
201
|
+
private final class SendResult {
|
|
202
|
+
private let lock = NSLock()
|
|
203
|
+
private let semaphore = DispatchSemaphore(value: 0)
|
|
204
|
+
private var settled: SendOutcome = .retry
|
|
205
|
+
|
|
206
|
+
var outcome: SendOutcome {
|
|
207
|
+
lock.lock()
|
|
208
|
+
defer { lock.unlock() }
|
|
209
|
+
return settled
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func complete(_ outcome: SendOutcome) {
|
|
213
|
+
lock.lock()
|
|
214
|
+
settled = outcome
|
|
215
|
+
lock.unlock()
|
|
216
|
+
semaphore.signal()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
func wait(timeout: DispatchTime) -> DispatchTimeoutResult {
|
|
220
|
+
semaphore.wait(timeout: timeout)
|
|
179
221
|
}
|
|
180
222
|
}
|
|
181
223
|
|
|
182
|
-
private extension
|
|
183
|
-
var
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
224
|
+
private extension GlassesStatus {
|
|
225
|
+
var analyticsSnapshot: AnalyticsGlassesSnapshot {
|
|
226
|
+
AnalyticsGlassesSnapshot(
|
|
227
|
+
connected: connectionState.isConnected || connected || fullyBooted,
|
|
228
|
+
fullyBooted: fullyBooted,
|
|
229
|
+
model: deviceModel,
|
|
230
|
+
serialNumber: serialNumber,
|
|
231
|
+
firmwareVersion: firmwareVersion,
|
|
232
|
+
besFirmwareVersion: besFirmwareVersion,
|
|
233
|
+
mtkFirmwareVersion: mtkFirmwareVersion,
|
|
234
|
+
androidVersion: androidVersion,
|
|
235
|
+
appVersion: appVersion,
|
|
236
|
+
buildNumber: buildNumber
|
|
237
|
+
)
|
|
187
238
|
}
|
|
188
239
|
}
|
|
189
240
|
|
|
@@ -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
|
+
}
|