@mentra/bluetooth-sdk 3.3.0-dev.822 → 3.3.0-dev.826
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 +68 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +223 -36
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
- package/build/BluetoothSdk.types.d.ts +3 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +2 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/nativeLogConsole.d.ts +19 -0
- package/build/_private/nativeLogConsole.d.ts.map +1 -1
- package/build/_private/nativeLogConsole.js +12 -0
- package/build/_private/nativeLogConsole.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +1 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +247 -48
- package/ios/Source/internal/BluetoothAvailability.swift +6 -0
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
- package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
- package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
- package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
- package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
- package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
- package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
- package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +1 -0
- package/src/_private/BluetoothSdkModule.ts +2 -1
- package/src/_private/nativeLogConsole.ts +19 -0
- 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
|
-
|
|
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
|
|
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":
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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(
|
|
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
|
+
}
|