@mentra/bluetooth-sdk 3.3.0-dev.822 → 3.3.0-dev.825

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 (42) hide show
  1. package/README.md +68 -6
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +1 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  9. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +223 -36
  10. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  11. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  12. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  13. package/build/BluetoothSdk.types.d.ts +3 -0
  14. package/build/BluetoothSdk.types.d.ts.map +1 -1
  15. package/build/BluetoothSdk.types.js.map +1 -1
  16. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  17. package/build/_private/BluetoothSdkModule.js +2 -1
  18. package/build/_private/BluetoothSdkModule.js.map +1 -1
  19. package/build/_private/nativeLogConsole.d.ts +19 -0
  20. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  21. package/build/_private/nativeLogConsole.js +12 -0
  22. package/build/_private/nativeLogConsole.js.map +1 -1
  23. package/build/generated/releaseMetadata.js +5 -5
  24. package/build/generated/releaseMetadata.js.map +1 -1
  25. package/ios/BluetoothSdkModule.swift +1 -0
  26. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  27. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  28. package/ios/Source/MentraBluetoothSDK.swift +247 -48
  29. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  30. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  31. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  32. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  33. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  34. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  35. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  36. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  37. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  38. package/package.json +2 -2
  39. package/src/BluetoothSdk.types.ts +1 -0
  40. package/src/_private/BluetoothSdkModule.ts +2 -1
  41. package/src/_private/nativeLogConsole.ts +19 -0
  42. package/src/generated/releaseMetadata.ts +5 -5
@@ -42,8 +42,15 @@ final class BluetoothSdkAnalytics {
42
42
  // Delivery is process-wide (see BluetoothSdkAnalyticsTransport); this instance only tracks its connection.
43
43
  private let transportQueue = BluetoothSdkAnalyticsTransport.queue
44
44
  private let configuration: BluetoothSdkAnalyticsConfiguration
45
+ // Tracker state is touched from the MainActor facade and store listeners;
46
+ // stateQueue serializes every read and write of it.
45
47
  private var tracker = BluetoothSdkAnalyticsTracker(simulatedModel: DeviceTypes.SIMULATED)
48
+ private var usage = BluetoothSdkUsageTracker()
46
49
  private var startedCaptured = false
50
+ private var lastGlasses: AnalyticsGlassesSnapshot?
51
+ /// Set by an SDK disconnect/forget/cancel call and consumed by the next
52
+ /// connected -> disconnected transition, so that transition can say why.
53
+ private var pendingDisconnectReason: String?
47
54
  // Touched only on transportQueue, which serializes access.
48
55
  private var resolvedHostProperties: [String: Any]?
49
56
  private var retryQueue: BluetoothSdkAnalyticsQueue? {
@@ -60,9 +67,16 @@ final class BluetoothSdkAnalytics {
60
67
  self.configuration = configuration.resolvedForApp()
61
68
  }
62
69
 
70
+ /// False when the host disabled analytics; callers skip building usage facts entirely.
71
+ var isEnabled: Bool {
72
+ configuration.isReady
73
+ }
74
+
63
75
  func initializeGlassesStatus(_ status: GlassesStatus) {
64
76
  stateQueue.sync {
65
- tracker.initialize(status.analyticsSnapshot, reportingDay: BluetoothSdkAnalyticsTracker.reportingDay())
77
+ let snapshot = status.analyticsSnapshot
78
+ lastGlasses = snapshot
79
+ tracker.initialize(snapshot, reportingDay: BluetoothSdkAnalyticsTracker.reportingDay())
66
80
  }
67
81
  }
68
82
 
@@ -76,9 +90,24 @@ final class BluetoothSdkAnalytics {
76
90
  }
77
91
  }
78
92
 
79
- func observeGlassesStatus(_ status: GlassesStatus) {
93
+ func observeGlassesStatus(_ status: GlassesStatus, phoneBluetoothOff: () -> Bool) {
80
94
  stateQueue.sync {
81
- let events = tracker.observe(status.analyticsSnapshot, reportingDay: BluetoothSdkAnalyticsTracker.reportingDay())
95
+ let snapshot = status.analyticsSnapshot
96
+ lastGlasses = snapshot
97
+ let now = Date()
98
+ let events = tracker.observe(
99
+ snapshot,
100
+ reportingDay: BluetoothSdkAnalyticsTracker.reportingDay(date: now),
101
+ nowMs: Self.epochMillis(now)
102
+ ) {
103
+ pendingDisconnectReason
104
+ ?? (phoneBluetoothOff()
105
+ ? BluetoothSdkAnalyticsTracker.disconnectBluetoothOff
106
+ : BluetoothSdkAnalyticsTracker.disconnectLinkLost)
107
+ }
108
+ if !snapshot.connected || events.contains(where: { $0.name == "bluetooth_sdk_glasses_connected" }) {
109
+ pendingDisconnectReason = nil
110
+ }
82
111
  guard configuration.isReady else { return }
83
112
  for event in events {
84
113
  capture(event: event.name, properties: event.properties, configuration: configuration)
@@ -86,6 +115,45 @@ final class BluetoothSdkAnalytics {
86
115
  }
87
116
  }
88
117
 
118
+ func noteDisconnectRequested(_ reason: String) {
119
+ stateQueue.sync {
120
+ if lastGlasses?.connected == true { pendingDisconnectReason = reason }
121
+ }
122
+ }
123
+
124
+ func recordUsage(_ build: (inout BluetoothSdkUsageTracker) -> AnalyticsEvent?) {
125
+ stateQueue.sync {
126
+ guard configuration.isReady, let event = build(&usage) else { return }
127
+ capture(
128
+ event: event.name,
129
+ properties: glassesContext().merging(event.properties) { _, new in new },
130
+ configuration: configuration
131
+ )
132
+ }
133
+ }
134
+
135
+ /// Usage-tracker state changes that emit nothing themselves (e.g. a stop request).
136
+ func updateUsage(_ update: (inout BluetoothSdkUsageTracker) -> Void) {
137
+ stateQueue.sync {
138
+ guard configuration.isReady else { return }
139
+ update(&usage)
140
+ }
141
+ }
142
+
143
+ static func epochMillis(_ date: Date = Date()) -> Int64 {
144
+ Int64((date.timeIntervalSince1970 * 1000).rounded(.down))
145
+ }
146
+
147
+ /// Only ever called on `stateQueue`.
148
+ private func glassesContext() -> [String: Any] {
149
+ guard let glasses = lastGlasses, glasses.connected else { return [:] }
150
+ var context: [String: Any] = [:]
151
+ if !glasses.model.isBlank { context["glasses_model"] = glasses.model }
152
+ if let serial = glasses.serialNumber.validManufacturingSerial { context["glasses_device_id"] = serial }
153
+ if !glasses.buildNumber.isBlank { context["glasses_build_number"] = glasses.buildNumber }
154
+ return context
155
+ }
156
+
89
157
  private func capture(
90
158
  event: String,
91
159
  properties: [String: Any],
@@ -105,7 +173,7 @@ final class BluetoothSdkAnalytics {
105
173
  "api_key": Self.defaultPostHogApiKey,
106
174
  "uuid": uuid,
107
175
  "event": event,
108
- "distinct_id": self.distinctId(),
176
+ "distinct_id": BluetoothSdkInstallId.get(),
109
177
  "timestamp": self.isoFormatter.string(from: capturedAt),
110
178
  "properties": self.baseProperties(configuration: activeConfiguration)
111
179
  .merging(host) { _, new in new }
@@ -173,7 +241,11 @@ final class BluetoothSdkAnalytics {
173
241
  "app_bundle_identifier": Bundle.main.bundleIdentifier ?? "",
174
242
  "os_platform": platform,
175
243
  "os_version": ProcessInfo.processInfo.operatingSystemVersionString,
244
+ "phone_manufacturer": "Apple",
245
+ "locale": Locale.current.identifier.replacingOccurrences(of: "_", with: "-"),
246
+ "timezone": TimeZone.current.identifier,
176
247
  ]
248
+ if let phoneModel = Self.phoneModel { properties["phone_model"] = phoneModel }
177
249
  // Always present so version cohorts never collapse into a null bucket. The
178
250
  // placeholder shows up only for unstamped source builds (plain SwiftPM checkout).
179
251
  properties["sdk_version"] = BluetoothSdkDefaults.sdkVersion ?? "unknown"
@@ -188,15 +260,15 @@ final class BluetoothSdkAnalytics {
188
260
  return resolved
189
261
  }
190
262
 
191
- private func distinctId() -> String {
192
- let key = "mentra_bluetooth_sdk_analytics_distinct_id"
193
- if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
194
- return existing
263
+ /// The hardware identifier (e.g. `iPhone15,2`), not the user-assigned device name.
264
+ private static let phoneModel: String? = {
265
+ var systemInfo = utsname()
266
+ uname(&systemInfo)
267
+ let machine = withUnsafeBytes(of: &systemInfo.machine) { buffer in
268
+ String(decoding: buffer.prefix { $0 != 0 }, as: UTF8.self)
195
269
  }
196
- let generated = "mentra-bt-sdk-\(UUID().uuidString)"
197
- UserDefaults.standard.set(generated, forKey: key)
198
- return generated
199
- }
270
+ return machine.isEmpty ? nil : machine
271
+ }()
200
272
 
201
273
  private func captureURL() -> URL? {
202
274
  let normalized = Self.defaultPostHogHost.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
@@ -242,7 +314,9 @@ private extension GlassesStatus {
242
314
  mtkFirmwareVersion: mtkFirmwareVersion,
243
315
  androidVersion: androidVersion,
244
316
  appVersion: appVersion,
245
- buildNumber: buildNumber
317
+ buildNumber: buildNumber,
318
+ batteryLevel: batteryLevel,
319
+ charging: charging
246
320
  )
247
321
  }
248
322
  }
@@ -12,6 +12,9 @@ struct AnalyticsGlassesSnapshot {
12
12
  var androidVersion = ""
13
13
  var appVersion = ""
14
14
  var buildNumber = ""
15
+ /// -1 while the glasses have not reported a level.
16
+ var batteryLevel = -1
17
+ var charging = false
15
18
  }
16
19
 
17
20
  struct AnalyticsEvent {
@@ -32,7 +35,17 @@ struct AnalyticsEvent {
32
35
  /// is visible in both weeks. Reporting days follow `America/Los_Angeles`, the
33
36
  /// calendar the WAU weeks are cut on; a UTC day would miss the Sunday-evening to
34
37
  /// Monday-morning Pacific boundary, which is one UTC day.
38
+ /// - `bluetooth_sdk_glasses_disconnected` when a counted connection ends, with its
39
+ /// length, why it ended, and the battery levels seen during it. The reason is
40
+ /// supplied by the caller: an SDK disconnect/forget/cancel call, the phone's
41
+ /// Bluetooth turning off, or otherwise a lost link.
35
42
  struct BluetoothSdkAnalyticsTracker {
43
+ static let disconnectUser = "user_disconnect"
44
+ static let disconnectForget = "forget"
45
+ static let disconnectCancel = "cancel"
46
+ static let disconnectBluetoothOff = "bluetooth_off"
47
+ static let disconnectLinkLost = "link_lost"
48
+ static let disconnectUnknown = "unknown"
36
49
  static let millisPerDay: Int64 = 86_400_000
37
50
  static let reportingZone = TimeZone(identifier: "America/Los_Angeles")!
38
51
 
@@ -53,6 +66,11 @@ struct BluetoothSdkAnalyticsTracker {
53
66
  private var connectedPendingModel = false
54
67
  private var identifiedSerial: String?
55
68
  private var identifiedReportingDay: Int64?
69
+ private var connectedAtMs: Int64?
70
+ private var batteryAtConnect = -1
71
+ private var lastBattery = -1
72
+ private var lastCharging: Bool?
73
+ private var lastModel = ""
56
74
 
57
75
  init(simulatedModel: String) {
58
76
  self.simulatedModel = simulatedModel
@@ -69,7 +87,12 @@ struct BluetoothSdkAnalyticsTracker {
69
87
  identifiedReportingDay = identifiedSerial == nil ? nil : reportingDay
70
88
  }
71
89
 
72
- mutating func observe(_ snapshot: AnalyticsGlassesSnapshot, reportingDay: Int64) -> [AnalyticsEvent] {
90
+ mutating func observe(
91
+ _ snapshot: AnalyticsGlassesSnapshot,
92
+ reportingDay: Int64,
93
+ nowMs: Int64 = 0,
94
+ disconnectReason: () -> String = { BluetoothSdkAnalyticsTracker.disconnectUnknown }
95
+ ) -> [AnalyticsEvent] {
73
96
  var events: [AnalyticsEvent] = []
74
97
  let wasConnected = lastConnected
75
98
  lastConnected = snapshot.connected
@@ -79,14 +102,30 @@ struct BluetoothSdkAnalyticsTracker {
79
102
  connectedPendingModel = false
80
103
  events.append(connectedEvent(snapshot, modelUnresolved: true))
81
104
  }
105
+ if let connectedAtMs {
106
+ events.append(disconnectedEvent(durationMs: nowMs - connectedAtMs, reason: disconnectReason()))
107
+ }
108
+ connectedAtMs = nil
82
109
  identifiedSerial = nil
83
110
  identifiedReportingDay = nil
84
111
  return events
85
112
  }
86
113
 
114
+ if !snapshot.model.isBlank { lastModel = snapshot.model }
115
+ if snapshot.batteryLevel >= 0 {
116
+ if batteryAtConnect < 0 { batteryAtConnect = snapshot.batteryLevel }
117
+ lastBattery = snapshot.batteryLevel
118
+ lastCharging = snapshot.charging
119
+ }
120
+
87
121
  if !wasConnected {
88
122
  identifiedSerial = nil
89
123
  identifiedReportingDay = nil
124
+ connectedAtMs = nowMs
125
+ lastModel = snapshot.model
126
+ batteryAtConnect = snapshot.batteryLevel
127
+ lastBattery = snapshot.batteryLevel
128
+ lastCharging = snapshot.batteryLevel >= 0 ? snapshot.charging : nil
90
129
  if snapshot.model.isBlank {
91
130
  connectedPendingModel = true
92
131
  } else {
@@ -120,6 +159,20 @@ struct BluetoothSdkAnalyticsTracker {
120
159
  return AnalyticsEvent(name: "bluetooth_sdk_glasses_connected", properties: properties)
121
160
  }
122
161
 
162
+ private func disconnectedEvent(durationMs: Int64, reason: String) -> AnalyticsEvent {
163
+ var properties: [String: Any] = [
164
+ "event_kind": "glasses_disconnected",
165
+ "session_duration_ms": max(durationMs, 0),
166
+ "disconnect_reason": reason,
167
+ "glasses_is_simulated": lastModel == simulatedModel,
168
+ ]
169
+ if !lastModel.isBlank { properties["glasses_model"] = lastModel }
170
+ if batteryAtConnect >= 0 { properties["battery_level_at_connect"] = batteryAtConnect }
171
+ if lastBattery >= 0 { properties["battery_level_at_disconnect"] = lastBattery }
172
+ if let lastCharging { properties["charging"] = lastCharging }
173
+ return AnalyticsEvent(name: "bluetooth_sdk_glasses_disconnected", properties: properties)
174
+ }
175
+
123
176
  private func identifiedEvent(_ snapshot: AnalyticsGlassesSnapshot, serial: String, kind: String) -> AnalyticsEvent {
124
177
  var properties: [String: Any] = [
125
178
  "event_kind": kind,
@@ -0,0 +1,21 @@
1
+ import Foundation
2
+
3
+ /// One random id per app install, shared by usage analytics (as the PostHog
4
+ /// `distinct_id`) and the startup update check, so both describe the same install.
5
+ /// It is created on first use whether or not analytics is enabled.
6
+ enum BluetoothSdkInstallId {
7
+ /// The key analytics has always stored its distinct id under; existing installs keep their id.
8
+ static let defaultsKey = "mentra_bluetooth_sdk_analytics_distinct_id"
9
+ private static let lock = NSLock()
10
+
11
+ static func get(defaults: UserDefaults = .standard) -> String {
12
+ lock.lock()
13
+ defer { lock.unlock() }
14
+ if let existing = defaults.string(forKey: defaultsKey), !existing.isEmpty {
15
+ return existing
16
+ }
17
+ let generated = "mentra-bt-sdk-\(UUID().uuidString)"
18
+ defaults.set(generated, forKey: defaultsKey)
19
+ return generated
20
+ }
21
+ }
@@ -0,0 +1,223 @@
1
+ import Foundation
2
+
3
+ struct BluetoothSdkUpdateCheckResult: Equatable {
4
+ let status: String
5
+ let channel: String
6
+ let latestVersion: String?
7
+ let minimumSupportedVersion: String?
8
+
9
+ static func parse(_ body: Data) -> BluetoothSdkUpdateCheckResult? {
10
+ guard let json = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] else { return nil }
11
+ return BluetoothSdkUpdateCheckResult(
12
+ status: json["status"] as? String ?? "",
13
+ channel: json["channel"] as? String ?? "",
14
+ latestVersion: nonBlankString(json["latestVersion"]),
15
+ minimumSupportedVersion: nonBlankString(json["minimumSupportedVersion"])
16
+ )
17
+ }
18
+
19
+ static func parse(_ body: String) -> BluetoothSdkUpdateCheckResult? {
20
+ parse(Data(body.utf8))
21
+ }
22
+
23
+ private static func nonBlankString(_ value: Any?) -> String? {
24
+ guard let value = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
25
+ return nil
26
+ }
27
+ return value
28
+ }
29
+ }
30
+
31
+ /// The developer-facing console banner for an out-of-date SDK. Never shown in app UI.
32
+ enum BluetoothSdkUpdateNotice {
33
+ static let statusUpdateAvailable = "update_available"
34
+ static let statusUnsupported = "unsupported"
35
+ private static let rule = "=================================================================="
36
+
37
+ /// Nil when the installed SDK is current or the server could not tell.
38
+ static func banner(installedVersion: String, result: BluetoothSdkUpdateCheckResult, surface: String) -> String? {
39
+ let latest = result.latestVersion
40
+ let heading: String
41
+ switch result.status {
42
+ case statusUnsupported:
43
+ heading = "MENTRA BLUETOOTH SDK \(installedVersion) IS NO LONGER SUPPORTED"
44
+ case statusUpdateAvailable:
45
+ guard latest != nil else { return nil }
46
+ heading = "MENTRA BLUETOOTH SDK UPDATE AVAILABLE"
47
+ default:
48
+ return nil
49
+ }
50
+ var lines = [
51
+ rule,
52
+ " \(heading)",
53
+ " Installed: \(installedVersion) Latest: \(latest ?? "unknown")",
54
+ " Update: \(updateLine(surface: surface, version: latest))",
55
+ ]
56
+ if result.status == statusUnsupported {
57
+ lines.append(" This version has known issues that are fixed in newer releases.")
58
+ }
59
+ lines.append(" Running an old SDK is the most common cause of glasses issues.")
60
+ lines.append(rule)
61
+ return lines.joined(separator: "\n")
62
+ }
63
+
64
+ private static func updateLine(surface: String, version: String?) -> String {
65
+ switch surface {
66
+ case "react_native": return "bun add @mentra/bluetooth-sdk@\(version ?? "latest")"
67
+ case "ios": return "the MentraBluetoothSDK Swift package at \(version ?? "its latest release")"
68
+ default: return "com.mentraglass:bluetooth-sdk:\(version ?? "<latest release>")"
69
+ }
70
+ }
71
+ }
72
+
73
+ /// Checks Mentra Core for a newer SDK on every SDK start and, when this SDK is
74
+ /// behind, prints a console banner now and every `reprintInterval` while the SDK
75
+ /// instance lives. The check is one request with a short timeout and no retry;
76
+ /// any failure is silent and changes no SDK behavior. It runs regardless of the
77
+ /// analytics setting.
78
+ final class BluetoothSdkUpdateCheck {
79
+ /// Posts the JSON body and completes with the 200 response body, or nil on any failure.
80
+ typealias Transport = @Sendable (_ body: Data, _ completion: @escaping @Sendable (Data?) -> Void) -> Void
81
+ typealias NoticeHandler = @Sendable (_ banner: String, _ result: BluetoothSdkUpdateCheckResult) -> Void
82
+
83
+ static let updateCheckUrl = URL(string: "https://core.mentraglass.com/api/client/bluetooth-sdk/update-check")!
84
+ static let reprintInterval: TimeInterval = 15 * 60
85
+ private static let timeout: TimeInterval = 4
86
+ private static let queue = DispatchQueue(label: "com.mentra.bluetoothsdk.update-check", qos: .utility)
87
+ private static let session: URLSession = {
88
+ let configuration = URLSessionConfiguration.ephemeral
89
+ configuration.timeoutIntervalForRequest = timeout
90
+ configuration.timeoutIntervalForResource = timeout
91
+ configuration.waitsForConnectivity = false
92
+ return URLSession(configuration: configuration)
93
+ }()
94
+
95
+ private let surface: String
96
+ private let sdkVersion: String?
97
+ private let bundle: Bundle
98
+ private let transport: Transport
99
+ private let onNotice: NoticeHandler
100
+ // Recursive so a notice handler that stops the check cannot deadlock it.
101
+ private let lock = NSRecursiveLock()
102
+ private var stopped = false
103
+ private var reprintTimer: DispatchSourceTimer?
104
+
105
+ init(
106
+ surface: String,
107
+ sdkVersion: String? = BluetoothSdkDefaults.sdkVersion,
108
+ bundle: Bundle = .main,
109
+ transport: @escaping Transport = { body, completion in BluetoothSdkUpdateCheck.post(body, completion: completion) },
110
+ onNotice: @escaping NoticeHandler
111
+ ) {
112
+ self.surface = surface
113
+ self.sdkVersion = sdkVersion
114
+ self.bundle = bundle
115
+ self.transport = transport
116
+ self.onNotice = onNotice
117
+ }
118
+
119
+ deinit {
120
+ reprintTimer?.cancel()
121
+ }
122
+
123
+ /// The update line follows the integration surface: React Native, or the Swift package.
124
+ static func surface(forAnalyticsSurface analyticsSurface: String) -> String {
125
+ analyticsSurface == "react_native" ? "react_native" : "ios"
126
+ }
127
+
128
+ static func isCheckable(_ version: String?) -> Bool {
129
+ guard let version else { return false }
130
+ let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
131
+ return !trimmed.isEmpty && trimmed != "unknown"
132
+ }
133
+
134
+ func start() {
135
+ guard Self.isCheckable(sdkVersion) else { return }
136
+ // Bundle and UserDefaults lookups stay off the caller (often main) thread.
137
+ Self.queue.async { [weak self] in
138
+ guard let self else { return }
139
+ let requestBody = self.requestBody()
140
+ guard JSONSerialization.isValidJSONObject(requestBody),
141
+ let body = try? JSONSerialization.data(withJSONObject: requestBody)
142
+ else { return }
143
+ self.transport(body) { [weak self] response in
144
+ self?.handle(response)
145
+ }
146
+ }
147
+ }
148
+
149
+ func stop() {
150
+ lock.lock()
151
+ defer { lock.unlock() }
152
+ stopped = true
153
+ reprintTimer?.cancel()
154
+ reprintTimer = nil
155
+ }
156
+
157
+ private func handle(_ response: Data?) {
158
+ guard let response,
159
+ let sdkVersion,
160
+ let result = BluetoothSdkUpdateCheckResult.parse(response),
161
+ let banner = BluetoothSdkUpdateNotice.banner(installedVersion: sdkVersion, result: result, surface: surface)
162
+ else { return }
163
+ lock.lock()
164
+ defer { lock.unlock() }
165
+ guard !stopped, reprintTimer == nil else { return }
166
+ onNotice(banner, result)
167
+ let timer = DispatchSource.makeTimerSource(queue: Self.queue)
168
+ timer.schedule(deadline: .now() + Self.reprintInterval, repeating: Self.reprintInterval)
169
+ timer.setEventHandler { [weak self] in
170
+ guard let self else { return }
171
+ self.lock.lock()
172
+ defer { self.lock.unlock() }
173
+ if !self.stopped { self.onNotice(banner, result) }
174
+ }
175
+ reprintTimer = timer
176
+ timer.resume()
177
+ }
178
+
179
+ func requestBody() -> [String: Any] {
180
+ let host = BluetoothSdkAnalyticsHost.resolve(bundle: bundle)
181
+ #if os(macOS)
182
+ let osPlatform = "macos"
183
+ #else
184
+ let osPlatform = "ios"
185
+ #endif
186
+ var body: [String: Any] = [
187
+ "install_id": BluetoothSdkInstallId.get(),
188
+ "app_identifier": bundle.bundleIdentifier ?? "",
189
+ "app_build_type": host.buildType,
190
+ "app_install_source": host.installSource,
191
+ "sdk_version": sdkVersion ?? "unknown",
192
+ // Core accepts `android` or `ios`; the macOS build ships in the same Swift package.
193
+ "sdk_platform": "ios",
194
+ "sdk_surface": surface,
195
+ "os_platform": osPlatform,
196
+ "os_version": ProcessInfo.processInfo.operatingSystemVersionString,
197
+ ]
198
+ if let appVersion = host.appVersion { body["app_version"] = appVersion }
199
+ if let appBuild = host.appBuild { body["app_build"] = appBuild }
200
+ if let environment = host.environment { body["app_environment"] = environment }
201
+ return body
202
+ }
203
+
204
+ static func post(_ body: Data, completion: @escaping @Sendable (Data?) -> Void) {
205
+ var request = URLRequest(url: updateCheckUrl)
206
+ request.httpMethod = "POST"
207
+ request.timeoutInterval = timeout
208
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
209
+ request.httpBody = body
210
+ session.dataTask(with: request) { data, response, error in
211
+ guard error == nil, (response as? HTTPURLResponse)?.statusCode == 200 else {
212
+ completion(nil)
213
+ return
214
+ }
215
+ completion(data)
216
+ }.resume()
217
+ }
218
+
219
+ /// Test hook: waits until every task submitted to the check queue so far has run.
220
+ static func awaitIdle() {
221
+ queue.sync {}
222
+ }
223
+ }