@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.
Files changed (50) hide show
  1. package/README.md +46 -13
  2. package/android/build.gradle +2 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +89 -90
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsHost.kt +106 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsQueue.kt +111 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +139 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTransport.kt +49 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  11. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +6 -5
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +11 -1
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2SerialResolution.kt +23 -0
  14. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsHostTest.kt +64 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsQueueTest.kt +86 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +148 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTransportTest.kt +44 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/G2SerialResolutionTest.kt +25 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +8 -0
  20. package/build/generated/changelogCatalog.d.ts +1 -1
  21. package/build/generated/changelogCatalog.js +1 -1
  22. package/build/generated/changelogCatalog.js.map +1 -1
  23. package/build/generated/releaseMetadata.js +5 -5
  24. package/build/generated/releaseMetadata.js.map +1 -1
  25. package/ios/Source/BluetoothSdkDefaults.swift +2 -2
  26. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  27. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  28. package/ios/Source/OtaManifest.swift +6 -5
  29. package/ios/Source/internal/BluetoothSdkAnalytics.swift +127 -76
  30. package/ios/Source/internal/BluetoothSdkAnalyticsHost.swift +91 -0
  31. package/ios/Source/internal/BluetoothSdkAnalyticsQueue.swift +117 -0
  32. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +157 -0
  33. package/ios/Source/internal/BluetoothSdkAnalyticsTransport.swift +13 -0
  34. package/ios/Source/sgcs/G2.swift +375 -360
  35. package/ios/Source/sgcs/G2SerialResolution.swift +23 -0
  36. package/ios/Tests/BluetoothSdkAnalyticsHostTests.swift +63 -0
  37. package/ios/Tests/BluetoothSdkAnalyticsQueueTests.swift +97 -0
  38. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +122 -0
  39. package/ios/Tests/BluetoothSdkAnalyticsTransportTests.swift +36 -0
  40. package/ios/Tests/G2SerialResolutionTests.swift +19 -0
  41. package/ios/Tests/OtaManifestDowngradeTests.swift +20 -0
  42. package/package.json +1 -1
  43. package/plugin/build/analyticsProps.d.ts +9 -0
  44. package/plugin/build/analyticsProps.js +34 -0
  45. package/plugin/build/index.d.ts +8 -0
  46. package/plugin/build/withAndroid.js +12 -18
  47. package/plugin/build/withIos.d.ts +1 -0
  48. package/plugin/build/withIos.js +8 -18
  49. package/src/generated/changelogCatalog.ts +1 -1
  50. 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,+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.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,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-dev.96",
5
- "releaseSetId": "mentra-3.1.0-dev.96",
6
- "sourceCommit": "dc0cedc622a383421f9e5b918b2865c30f8f28dd",
7
- "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.1.0/mentra-live-ota-3.1.0-dev.96.json",
8
- "otaManifestSha256": "205f2dc478e89f36f2c0ebb586b41723d85eeaa62f24ae05754c7c91d7215392"
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,cAAc;IACjC,cAAc,EAAE,qBAAqB;IACrC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,uHAAuH;IACzI,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-dev.96\",\n \"releaseSetId\": \"mentra-3.1.0-dev.96\",\n \"sourceCommit\": \"dc0cedc622a383421f9e5b918b2865c30f8f28dd\",\n \"otaManifestUrl\": \"https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.1.0/mentra-live-ota-3.1.0-dev.96.json\",\n \"otaManifestSha256\": \"205f2dc478e89f36f2c0ebb586b41723d85eeaa62f24ae05754c7c91d7215392\"\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,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 = true
12
+ static let loudnessGateEnabled = false
13
13
  private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
14
- private static let swiftPackageSdkVersion = "3.1.0-dev.96"
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-dev.96"
7
- static let releaseSetId = "mentra-3.1.0-dev.96"
8
- static let sourceCommit = "dc0cedc622a383421f9e5b918b2865c30f8f28dd"
9
- static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.1.0/mentra-live-ota-3.1.0-dev.96.json"
10
- static let otaManifestSha256 = "205f2dc478e89f36f2c0ebb586b41723d85eeaa62f24ae05754c7c91d7215392"
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
- // Downgrade floor: a non-positive value (the default) disables downgrades entirely,
91
- // matching ASG's fail-closed DowngradeGate and the engine. OEM SDK consumers that do not
92
- // set a floor get upgrade-only behavior and are never told a downgrade is available that
93
- // the glasses would refuse.
94
- downgradeFloorVersionCode: Int = 0
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
- private let transportQueue = DispatchQueue(label: "com.mentra.bluetoothsdk.analytics.transport")
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
- private var lastConnected = false
42
- private var identifiedCapturedForConnection = false
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
- lastConnected = status.analyticsConnected
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
- captureStartedLocked()
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 isConnected = status.analyticsConnected
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
- guard isConnected else {
83
- identifiedCapturedForConnection = false
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
- "properties": self.baseProperties(configuration: activeConfiguration).merging(properties) { _, new in new },
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
- guard let body = try? JSONSerialization.data(withJSONObject: payload) else { return }
133
- guard let captureURL = self.captureURL() else { return }
134
- var request = URLRequest(url: captureURL)
135
- request.httpMethod = "POST"
136
- request.timeoutInterval = 4
137
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
138
- request.httpBody = body
139
- URLSession.shared.dataTask(with: request).resume()
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
- if let sdkVersion = BluetoothSdkDefaults.sdkVersion {
155
- properties["sdk_version"] = sdkVersion
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
- private extension GlassesStatus {
177
- var analyticsConnected: Bool {
178
- connectionState.isConnected || connected || fullyBooted
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 String {
183
- var validManufacturingSerial: String? {
184
- let normalized = trimmingCharacters(in: .whitespacesAndNewlines)
185
- guard !normalized.isEmpty, normalized.contains(where: { $0 != "0" }) else { return nil }
186
- return normalized
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
+ }