@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
package/README.md CHANGED
@@ -217,9 +217,9 @@ reordering rows just because RSSI metadata arrives later.
217
217
 
218
218
  ## Mentra SDK Usage Analytics
219
219
 
220
- The SDK sends three usage events to Mentra's PostHog project by default so
221
- Mentra can understand SDK adoption, successful glasses connections, and
222
- enterprise device deployments:
220
+ The SDK sends usage events to Mentra's PostHog project by default so Mentra
221
+ can understand SDK adoption, glasses connections, enterprise device
222
+ deployments, and how Mentra Live features are used:
223
223
 
224
224
  - `bluetooth_sdk_started`: sent once per app runtime after the native SDK starts.
225
225
  - `bluetooth_sdk_glasses_connected`: sent when SDK status transitions from not connected to connected.
@@ -229,6 +229,45 @@ The connected event waits for the glasses model when the model is not known at
229
229
  the moment the connection flag flips; if the connection ends first it is sent
230
230
  without a model and with `glasses_model_unresolved=true`.
231
231
 
232
+ Each usage occurrence below is one event, sent when it happens:
233
+
234
+ - `bluetooth_sdk_glasses_disconnected`: a connection reported as connected
235
+ ended. Carries `session_duration_ms`, `disconnect_reason` (`user_disconnect`,
236
+ `forget`, `cancel`, `bluetooth_off`, `link_lost`, or `unknown`), and the
237
+ battery levels seen during the connection. A connection already up when the
238
+ SDK started is not reported.
239
+ - `bluetooth_sdk_photo_taken`: a `requestPhoto` call settled, or a photo taken
240
+ with the glasses button failed (successful button photos never reach the
241
+ phone). Carries `trigger` (`api` / `button`), `success`, `error_code`,
242
+ `latency_ms`, the requested size, mode, compression, gallery save and
243
+ transfer method, and `file_size_bytes` when the response carries it.
244
+ - `bluetooth_sdk_video_recorded`: an app- or button-started recording stopped
245
+ or failed. Carries `trigger`, `success`, `error_code`, `duration_ms` (phone
246
+ clock, start to stop status), the requested resolution and frame rate,
247
+ gallery save, maximum duration, and `stopped_by_app`.
248
+ - `bluetooth_sdk_stream_started` / `bluetooth_sdk_stream_ended`: a
249
+ `startStream` call settled, and that stream later ended. `protocol` comes
250
+ from the URL scheme only (`rtmp`, `srt`, `whip`, or `unknown`). The start
251
+ carries the requested video settings, `capture_audio`, `glasses_network`, and
252
+ `latency_ms`; the end carries `duration_ms`, `end_state`, `stopped_by_app`,
253
+ `reconnect_attempts`, `error_code`, and the last bitrate, frame rate, dropped
254
+ frames, and temperature when the stream reported statistics.
255
+ - `bluetooth_sdk_mic_session`: the active microphone turned off or changed.
256
+ Carries `duration_ms`, `mic_source`, and `local_transcription`.
257
+ - `bluetooth_sdk_wifi_setup` / `bluetooth_sdk_hotspot_started`:
258
+ `sendWifiCredentials` or `setHotspotState(true)` settled, with `success`,
259
+ `error_code`, and `latency_ms`.
260
+ - `bluetooth_sdk_ota_finished`: a glasses software update completed or failed,
261
+ with the failed step and error, `total_steps`, `duration_ms`, and the
262
+ glasses versions it started from.
263
+ - `bluetooth_sdk_request_failed`: a glasses command timed out
264
+ (`error_code=request_timeout`), named by `operation`. At most 100 per SDK
265
+ start.
266
+
267
+ These usage events also carry the connected glasses' `glasses_model`,
268
+ `glasses_device_id` and `glasses_build_number` when known. A recording or
269
+ stream whose end never reaches the running app produces only its start.
270
+
232
271
  Analytics delivery never blocks Bluetooth SDK behavior: events are submitted
233
272
  asynchronously off the caller thread. An upload that fails (no network, non-2xx)
234
273
  is kept in a small on-device queue (at most 100 events, 7 days) and retried on
@@ -281,10 +320,30 @@ write token, not a private PostHog personal API key. Apps do not configure the
281
320
  analytics destination; these SDK usage events are always sent to Mentra's
282
321
  PostHog project unless analytics are disabled.
283
322
 
323
+ ### Update check
324
+
325
+ Separately from analytics, the SDK checks for a newer SDK release every time it
326
+ starts, whether or not analytics are disabled. The check is one HTTPS request
327
+ (4-second timeout, no retry) to
328
+ `https://core.mentraglass.com/api/client/bluetooth-sdk/update-check` carrying
329
+ `install_id` (the same anonymous install id analytics uses), `app_identifier`,
330
+ `app_version`, `app_build`, `app_build_type`, `app_install_source`,
331
+ `app_environment`, `sdk_version`, `sdk_platform`, `sdk_surface`,
332
+ `os_platform`, and `os_version`. Mentra records each check. The SDK compares
333
+ itself with the latest release on its own channel (stable, beta, or dev).
334
+
335
+ When a newer release exists, the SDK prints a banner with the update command to
336
+ the developer console: Logcat (`MentraBluetoothSDK` tag) on Android, the Xcode
337
+ console on iOS, and `console.warn` in React Native, which shows as a LogBox
338
+ warning in debug builds. The banner repeats every 15 minutes while the SDK
339
+ instance lives. Nothing is shown in app UI. A network that blocks
340
+ `core.mentraglass.com` loses only the banner; SDK behavior is unchanged.
341
+
284
342
  Captured properties include `event_source`, `sdk_platform`, `sdk_surface`,
285
343
  `sdk_version`, `app_identifier` (the Android package or iOS bundle identifier),
286
344
  the platform-specific `app_package` or `app_bundle_identifier`, OS
287
- platform/version, and `event_kind`. Every event also carries host build facts:
345
+ platform/version, `phone_manufacturer`, `phone_model`, `locale`, `timezone`,
346
+ and `event_kind`. Every event also carries host build facts:
288
347
  `app_version`, `app_build`, `app_build_type` (`debug` / `release`),
289
348
  `app_install_source` (`play_store`, `app_store`, `testflight`, `adhoc_or_dev`,
290
349
  `simulator`, `sideload`, a named third-party store, `other_store`, or `unknown`
@@ -308,8 +367,11 @@ decode it from the glasses' BLE advertisement / manufacturer data.
308
367
 
309
368
  Apart from that glasses serial, the SDK does not upload BLE MAC addresses,
310
369
  CoreBluetooth identifiers, the host phone's Android device serial, Bluetooth
311
- device names, user ids, tokens, Wi-Fi credentials, microphone data, photos, or
312
- transcripts. PostHog receives a locally generated anonymous SDK install id as
370
+ device names, user ids, tokens, Wi-Fi names or credentials, stream, webhook or
371
+ upload URLs, microphone data, photos, videos, or transcripts, and it never reads
372
+ device location. Error details have URLs removed before they are sent. PostHog
373
+ derives an approximate location (country, region, city) from the IP address
374
+ each event is sent from. PostHog receives a locally generated anonymous SDK install id as
313
375
  `distinct_id`, and events include `$process_person_profile: false`.
314
376
 
315
377
  ## React Hooks
@@ -46,7 +46,12 @@ internal class BluetoothSdkAnalytics(
46
46
  // The tracker is touched from store listeners and SDK entry points, hence
47
47
  // @Synchronized on the methods that read or write it.
48
48
  private val tracker = BluetoothSdkAnalyticsTracker(DeviceTypes.SIMULATED)
49
+ private val usage = BluetoothSdkUsageTracker()
49
50
  private var startedCaptured = false
51
+ private var lastGlasses: AnalyticsGlassesSnapshot? = null
52
+ // Set by an SDK disconnect/forget/cancel call and consumed by the next
53
+ // connected -> disconnected transition, so that transition can say why.
54
+ private var pendingDisconnectReason: String? = null
50
55
  // Resolved once, lazily, on the transport executor: PackageManager lookups
51
56
  // must not run on the caller (often main or the Bluetooth status) thread.
52
57
  private val hostProperties: Map<String, Any> by lazy { BluetoothSdkAnalyticsHost.resolve(appContext).toMap() }
@@ -56,9 +61,15 @@ internal class BluetoothSdkAnalytics(
56
61
  timeZone = TimeZone.getTimeZone("UTC")
57
62
  }
58
63
 
64
+ /** False when the host disabled analytics; callers skip building usage facts entirely. */
65
+ val isEnabled: Boolean
66
+ get() = config.isReady
67
+
59
68
  @Synchronized
60
69
  fun initializeGlassesStatus(status: GlassesStatus) {
61
- tracker.initialize(status.toAnalyticsSnapshot(), BluetoothSdkAnalyticsTracker.reportingDay(System.currentTimeMillis()))
70
+ val snapshot = status.toAnalyticsSnapshot()
71
+ lastGlasses = snapshot
72
+ tracker.initialize(snapshot, BluetoothSdkAnalyticsTracker.reportingDay(System.currentTimeMillis()))
62
73
  }
63
74
 
64
75
  @Synchronized
@@ -71,12 +82,54 @@ internal class BluetoothSdkAnalytics(
71
82
  }
72
83
 
73
84
  @Synchronized
74
- fun observeGlassesStatus(status: GlassesStatus) {
75
- val events = tracker.observe(status.toAnalyticsSnapshot(), BluetoothSdkAnalyticsTracker.reportingDay(System.currentTimeMillis()))
85
+ fun observeGlassesStatus(status: GlassesStatus, phoneBluetoothOff: () -> Boolean) {
86
+ val snapshot = status.toAnalyticsSnapshot()
87
+ lastGlasses = snapshot
88
+ val now = System.currentTimeMillis()
89
+ val events =
90
+ tracker.observe(snapshot, BluetoothSdkAnalyticsTracker.reportingDay(now), now) {
91
+ pendingDisconnectReason
92
+ ?: if (phoneBluetoothOff()) {
93
+ BluetoothSdkAnalyticsTracker.DISCONNECT_BLUETOOTH_OFF
94
+ } else {
95
+ BluetoothSdkAnalyticsTracker.DISCONNECT_LINK_LOST
96
+ }
97
+ }
98
+ if (!snapshot.connected || events.any { it.name == "bluetooth_sdk_glasses_connected" }) {
99
+ pendingDisconnectReason = null
100
+ }
76
101
  if (!config.isReady) return
77
102
  for (event in events) capture(event.name, event.properties)
78
103
  }
79
104
 
105
+ @Synchronized
106
+ fun noteDisconnectRequested(reason: String) {
107
+ if (lastGlasses?.connected == true) pendingDisconnectReason = reason
108
+ }
109
+
110
+ @Synchronized
111
+ fun recordUsage(build: BluetoothSdkUsageTracker.() -> AnalyticsEvent?) {
112
+ if (!config.isReady) return
113
+ val event = usage.build() ?: return
114
+ capture(event.name, glassesContext() + event.properties)
115
+ }
116
+
117
+ /** Usage-tracker state changes that emit nothing themselves (e.g. a stop request). */
118
+ @Synchronized
119
+ fun updateUsage(update: BluetoothSdkUsageTracker.() -> Unit) {
120
+ if (!config.isReady) return
121
+ usage.update()
122
+ }
123
+
124
+ private fun glassesContext(): Map<String, Any> {
125
+ val glasses = lastGlasses?.takeIf { it.connected } ?: return emptyMap()
126
+ return buildMap {
127
+ glasses.model.takeIf { it.isNotBlank() }?.let { put("glasses_model", it) }
128
+ glasses.serialNumber.validManufacturingSerial()?.let { put("glasses_device_id", it) }
129
+ glasses.buildNumber.takeIf { it.isNotBlank() }?.let { put("glasses_build_number", it) }
130
+ }
131
+ }
132
+
80
133
  /**
81
134
  * Delivery is owned by the process-wide transport, so nothing is torn down
82
135
  * here: a retry in flight when this instance closes still completes, and a
@@ -101,7 +154,7 @@ internal class BluetoothSdkAnalytics(
101
154
  "api_key" to DEFAULT_POSTHOG_API_KEY,
102
155
  "uuid" to uuid,
103
156
  "event" to eventName,
104
- "distinct_id" to distinctId(),
157
+ "distinct_id" to BluetoothSdkInstallId.get(appContext),
105
158
  "timestamp" to isoFormat.format(Date(capturedAt)),
106
159
  "properties" to baseProperties(activeConfig) + hostProperties + eventProperties,
107
160
  )
@@ -156,16 +209,12 @@ internal class BluetoothSdkAnalytics(
156
209
  put("app_package", appContext.packageName)
157
210
  put("os_platform", "android")
158
211
  put("os_version", Build.VERSION.SDK_INT)
212
+ Build.MANUFACTURER?.takeIf { it.isNotBlank() }?.let { put("phone_manufacturer", it) }
213
+ Build.MODEL?.takeIf { it.isNotBlank() }?.let { put("phone_model", it) }
214
+ put("locale", Locale.getDefault().toLanguageTag())
215
+ put("timezone", TimeZone.getDefault().id)
159
216
  }
160
217
 
161
- private fun distinctId(): String {
162
- val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
163
- prefs.getString(PREFS_DISTINCT_ID, null)?.let { return it }
164
- val generated = "mentra-bt-sdk-${UUID.randomUUID()}"
165
- prefs.edit().putString(PREFS_DISTINCT_ID, generated).apply()
166
- return generated
167
- }
168
-
169
218
  private fun captureUrl(): String {
170
219
  val normalized = DEFAULT_POSTHOG_HOST.trim().trimEnd('/')
171
220
  return "$normalized/i/v0/e/"
@@ -173,8 +222,6 @@ internal class BluetoothSdkAnalytics(
173
222
 
174
223
  companion object {
175
224
  internal const val META_ANALYTICS_DISABLED = "com.mentra.bluetoothsdk.analytics.disabled"
176
- private const val PREFS_NAME = "mentra_bluetooth_sdk_analytics"
177
- private const val PREFS_DISTINCT_ID = "distinct_id"
178
225
  private const val DEFAULT_POSTHOG_API_KEY = "phc_FCweXVAxVgU7wZK4Fk3okOx4RmyNqVHJf62YpZSfJt5"
179
226
  private const val DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"
180
227
  }
@@ -212,4 +259,6 @@ private fun GlassesStatus.toAnalyticsSnapshot(): AnalyticsGlassesSnapshot =
212
259
  androidVersion = androidVersion,
213
260
  appVersion = appVersion,
214
261
  buildNumber = buildNumber,
262
+ batteryLevel = batteryLevel,
263
+ charging = charging,
215
264
  )
@@ -14,6 +14,9 @@ internal data class AnalyticsGlassesSnapshot(
14
14
  val androidVersion: String = "",
15
15
  val appVersion: String = "",
16
16
  val buildNumber: String = "",
17
+ /** -1 while the glasses have not reported a level. */
18
+ val batteryLevel: Int = -1,
19
+ val charging: Boolean = false,
17
20
  )
18
21
 
19
22
  internal data class AnalyticsEvent(
@@ -36,12 +39,21 @@ internal data class AnalyticsEvent(
36
39
  * is visible in both weeks. Reporting days follow `America/Los_Angeles`, the
37
40
  * calendar the WAU weeks are cut on; a UTC day would miss the Sunday-evening to
38
41
  * Monday-morning Pacific boundary, which is one UTC day.
42
+ * - `bluetooth_sdk_glasses_disconnected` when a counted connection ends, with its
43
+ * length, why it ended, and the battery levels seen during it. The reason is
44
+ * supplied by the caller: an SDK disconnect/forget/cancel call, the phone's
45
+ * Bluetooth turning off, or otherwise a lost link.
39
46
  */
40
47
  internal class BluetoothSdkAnalyticsTracker(private val simulatedModel: String) {
41
48
  private var lastConnected = false
42
49
  private var connectedPendingModel = false
43
50
  private var identifiedSerial: String? = null
44
51
  private var identifiedReportingDay: Long? = null
52
+ private var connectedAtMs: Long? = null
53
+ private var batteryAtConnect = -1
54
+ private var lastBattery = -1
55
+ private var lastCharging: Boolean? = null
56
+ private var lastModel = ""
45
57
 
46
58
  fun initialize(snapshot: AnalyticsGlassesSnapshot, reportingDay: Long) {
47
59
  lastConnected = snapshot.connected
@@ -54,7 +66,12 @@ internal class BluetoothSdkAnalyticsTracker(private val simulatedModel: String)
54
66
  identifiedReportingDay = identifiedSerial?.let { reportingDay }
55
67
  }
56
68
 
57
- fun observe(snapshot: AnalyticsGlassesSnapshot, reportingDay: Long): List<AnalyticsEvent> {
69
+ fun observe(
70
+ snapshot: AnalyticsGlassesSnapshot,
71
+ reportingDay: Long,
72
+ nowMs: Long = 0L,
73
+ disconnectReason: () -> String = { DISCONNECT_UNKNOWN },
74
+ ): List<AnalyticsEvent> {
58
75
  val events = mutableListOf<AnalyticsEvent>()
59
76
  val wasConnected = lastConnected
60
77
  lastConnected = snapshot.connected
@@ -64,14 +81,30 @@ internal class BluetoothSdkAnalyticsTracker(private val simulatedModel: String)
64
81
  connectedPendingModel = false
65
82
  events += connectedEvent(snapshot, modelUnresolved = true)
66
83
  }
84
+ connectedAtMs?.let { startedAt ->
85
+ events += disconnectedEvent(nowMs - startedAt, disconnectReason())
86
+ }
87
+ connectedAtMs = null
67
88
  identifiedSerial = null
68
89
  identifiedReportingDay = null
69
90
  return events
70
91
  }
71
92
 
93
+ if (snapshot.model.isNotBlank()) lastModel = snapshot.model
94
+ if (snapshot.batteryLevel >= 0) {
95
+ if (batteryAtConnect < 0) batteryAtConnect = snapshot.batteryLevel
96
+ lastBattery = snapshot.batteryLevel
97
+ lastCharging = snapshot.charging
98
+ }
99
+
72
100
  if (!wasConnected) {
73
101
  identifiedSerial = null
74
102
  identifiedReportingDay = null
103
+ connectedAtMs = nowMs
104
+ lastModel = snapshot.model
105
+ batteryAtConnect = snapshot.batteryLevel
106
+ lastBattery = snapshot.batteryLevel
107
+ lastCharging = snapshot.charging.takeIf { snapshot.batteryLevel >= 0 }
75
108
  if (snapshot.model.isBlank()) {
76
109
  connectedPendingModel = true
77
110
  } else {
@@ -106,6 +139,21 @@ internal class BluetoothSdkAnalyticsTracker(private val simulatedModel: String)
106
139
  },
107
140
  )
108
141
 
142
+ private fun disconnectedEvent(durationMs: Long, reason: String) =
143
+ AnalyticsEvent(
144
+ "bluetooth_sdk_glasses_disconnected",
145
+ buildMap {
146
+ put("event_kind", "glasses_disconnected")
147
+ put("session_duration_ms", durationMs.coerceAtLeast(0))
148
+ put("disconnect_reason", reason)
149
+ lastModel.takeIf { it.isNotBlank() }?.let { put("glasses_model", it) }
150
+ put("glasses_is_simulated", lastModel == simulatedModel)
151
+ batteryAtConnect.takeIf { it >= 0 }?.let { put("battery_level_at_connect", it) }
152
+ lastBattery.takeIf { it >= 0 }?.let { put("battery_level_at_disconnect", it) }
153
+ lastCharging?.let { put("charging", it) }
154
+ },
155
+ )
156
+
109
157
  private fun identifiedEvent(snapshot: AnalyticsGlassesSnapshot, serial: String, kind: String) =
110
158
  AnalyticsEvent(
111
159
  "bluetooth_sdk_glasses_identified",
@@ -126,6 +174,12 @@ internal class BluetoothSdkAnalyticsTracker(private val simulatedModel: String)
126
174
  )
127
175
 
128
176
  companion object {
177
+ const val DISCONNECT_USER = "user_disconnect"
178
+ const val DISCONNECT_FORGET = "forget"
179
+ const val DISCONNECT_CANCEL = "cancel"
180
+ const val DISCONNECT_BLUETOOTH_OFF = "bluetooth_off"
181
+ const val DISCONNECT_LINK_LOST = "link_lost"
182
+ const val DISCONNECT_UNKNOWN = "unknown"
129
183
  const val MILLIS_PER_DAY = 86_400_000L
130
184
  val REPORTING_ZONE: TimeZone = TimeZone.getTimeZone("America/Los_Angeles")
131
185
 
@@ -0,0 +1,23 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import android.content.Context
4
+ import java.util.UUID
5
+
6
+ /**
7
+ * One random id per app install, shared by usage analytics (as the PostHog
8
+ * `distinct_id`) and the startup update check, so both describe the same install.
9
+ * It is created on first use whether or not analytics is enabled.
10
+ */
11
+ internal object BluetoothSdkInstallId {
12
+ private const val PREFS_NAME = "mentra_bluetooth_sdk_analytics"
13
+ private const val PREFS_DISTINCT_ID = "distinct_id"
14
+
15
+ @Synchronized
16
+ fun get(context: Context): String {
17
+ val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
18
+ prefs.getString(PREFS_DISTINCT_ID, null)?.let { return it }
19
+ val generated = "mentra-bt-sdk-${UUID.randomUUID()}"
20
+ prefs.edit().putString(PREFS_DISTINCT_ID, generated).apply()
21
+ return generated
22
+ }
23
+ }
@@ -334,6 +334,7 @@ class BluetoothSdkModule : Module() {
334
334
  "glasses_status",
335
335
  "bluetooth_status",
336
336
  "log",
337
+ "sdk_update_notice",
337
338
  "device_discovered",
338
339
  "default_device_changed",
339
340
  // Individual event handlers
@@ -0,0 +1,184 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import android.content.Context
4
+ import android.os.Build
5
+ import org.json.JSONObject
6
+ import java.net.HttpURLConnection
7
+ import java.net.URL
8
+ import java.util.concurrent.Executors
9
+ import java.util.concurrent.RejectedExecutionException
10
+ import java.util.concurrent.ScheduledExecutorService
11
+ import java.util.concurrent.ScheduledFuture
12
+ import java.util.concurrent.TimeUnit
13
+
14
+ internal data class BluetoothSdkUpdateCheckResult(
15
+ val status: String,
16
+ val channel: String,
17
+ val latestVersion: String?,
18
+ val minimumSupportedVersion: String?,
19
+ ) {
20
+ companion object {
21
+ fun parse(body: String): BluetoothSdkUpdateCheckResult? =
22
+ try {
23
+ val json = JSONObject(body)
24
+ BluetoothSdkUpdateCheckResult(
25
+ status = json.optString("status"),
26
+ channel = json.optString("channel"),
27
+ latestVersion = json.optNonBlankString("latestVersion"),
28
+ minimumSupportedVersion = json.optNonBlankString("minimumSupportedVersion"),
29
+ )
30
+ } catch (_: Exception) {
31
+ null
32
+ }
33
+
34
+ private fun JSONObject.optNonBlankString(key: String): String? =
35
+ if (isNull(key)) null else optString(key).trim().takeIf { it.isNotEmpty() }
36
+ }
37
+ }
38
+
39
+ /** The developer-facing console banner for an out-of-date SDK. Never shown in app UI. */
40
+ internal object BluetoothSdkUpdateNotice {
41
+ const val STATUS_UPDATE_AVAILABLE = "update_available"
42
+ const val STATUS_UNSUPPORTED = "unsupported"
43
+ private const val RULE = "=================================================================="
44
+
45
+ /** Null when the installed SDK is current or the server could not tell. */
46
+ fun banner(installedVersion: String, result: BluetoothSdkUpdateCheckResult, surface: String): String? {
47
+ val latest = result.latestVersion
48
+ val heading =
49
+ when (result.status) {
50
+ STATUS_UNSUPPORTED -> "MENTRA BLUETOOTH SDK $installedVersion IS NO LONGER SUPPORTED"
51
+ STATUS_UPDATE_AVAILABLE -> if (latest != null) "MENTRA BLUETOOTH SDK UPDATE AVAILABLE" else return null
52
+ else -> return null
53
+ }
54
+ return buildString {
55
+ appendLine(RULE)
56
+ appendLine(" $heading")
57
+ appendLine(" Installed: $installedVersion Latest: ${latest ?: "unknown"}")
58
+ appendLine(" Update: ${updateLine(surface, latest)}")
59
+ if (result.status == STATUS_UNSUPPORTED) {
60
+ appendLine(" This version has known issues that are fixed in newer releases.")
61
+ }
62
+ appendLine(" Running an old SDK is the most common cause of glasses issues.")
63
+ append(RULE)
64
+ }
65
+ }
66
+
67
+ private fun updateLine(surface: String, version: String?): String =
68
+ when (surface) {
69
+ "react_native" -> "bun add @mentra/bluetooth-sdk@${version ?: "latest"}"
70
+ "ios" -> "the MentraBluetoothSDK Swift package at ${version ?: "its latest release"}"
71
+ else -> "com.mentraglass:bluetooth-sdk:${version ?: "<latest release>"}"
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Checks Mentra Core for a newer SDK on every SDK start and, when this SDK is
77
+ * behind, prints a console banner now and every [REPRINT_INTERVAL_MINUTES] while
78
+ * the SDK instance lives. The check is one request with a short timeout and no
79
+ * retry; any failure is silent and changes no SDK behavior. It runs regardless
80
+ * of the analytics setting.
81
+ */
82
+ internal class BluetoothSdkUpdateCheck(
83
+ context: Context,
84
+ private val surface: String,
85
+ private val sdkVersion: String = BuildConfig.SDK_VERSION,
86
+ private val transport: (String) -> String? = ::post,
87
+ private val onNotice: (banner: String, result: BluetoothSdkUpdateCheckResult) -> Unit,
88
+ ) {
89
+ private val appContext = context.applicationContext
90
+ private val lock = Any()
91
+ private var stopped = false
92
+ private var reprint: ScheduledFuture<*>? = null
93
+
94
+ fun start() {
95
+ if (!isCheckable(sdkVersion)) return
96
+ try {
97
+ executor.execute { runCatching { check() } }
98
+ } catch (_: RejectedExecutionException) {
99
+ }
100
+ }
101
+
102
+ fun stop() {
103
+ synchronized(lock) {
104
+ stopped = true
105
+ reprint?.cancel(false)
106
+ reprint = null
107
+ }
108
+ }
109
+
110
+ private fun check() {
111
+ val response = transport(requestBody().toString()) ?: return
112
+ val result = BluetoothSdkUpdateCheckResult.parse(response) ?: return
113
+ val banner = BluetoothSdkUpdateNotice.banner(sdkVersion, result, surface) ?: return
114
+ synchronized(lock) {
115
+ if (stopped) return
116
+ onNotice(banner, result)
117
+ reprint =
118
+ executor.scheduleAtFixedRate(
119
+ { synchronized(lock) { if (!stopped) onNotice(banner, result) } },
120
+ REPRINT_INTERVAL_MINUTES,
121
+ REPRINT_INTERVAL_MINUTES,
122
+ TimeUnit.MINUTES,
123
+ )
124
+ }
125
+ }
126
+
127
+ internal fun requestBody(): JSONObject {
128
+ val host = BluetoothSdkAnalyticsHost.resolve(appContext)
129
+ return JSONObject().apply {
130
+ put("install_id", BluetoothSdkInstallId.get(appContext))
131
+ put("app_identifier", appContext.packageName)
132
+ host.appVersion?.let { put("app_version", it) }
133
+ host.appBuild?.let { put("app_build", it) }
134
+ put("app_build_type", host.buildType)
135
+ put("app_install_source", host.installSource)
136
+ host.environment?.let { put("app_environment", it) }
137
+ put("sdk_version", sdkVersion)
138
+ put("sdk_platform", "android")
139
+ put("sdk_surface", surface)
140
+ put("os_platform", "android")
141
+ put("os_version", Build.VERSION.SDK_INT.toString())
142
+ }
143
+ }
144
+
145
+ companion object {
146
+ const val UPDATE_CHECK_URL = "https://core.mentraglass.com/api/client/bluetooth-sdk/update-check"
147
+ const val REPRINT_INTERVAL_MINUTES = 15L
148
+ private const val TIMEOUT_MS = 4_000
149
+
150
+ private val executor: ScheduledExecutorService =
151
+ Executors.newSingleThreadScheduledExecutor { runnable ->
152
+ Thread(runnable, "MentraBluetoothSdkUpdateCheck").apply { isDaemon = true }
153
+ }
154
+
155
+ fun isCheckable(version: String): Boolean = version.isNotBlank() && version != "unknown"
156
+
157
+ private fun post(body: String): String? {
158
+ val connection = URL(UPDATE_CHECK_URL).openConnection() as HttpURLConnection
159
+ return try {
160
+ connection.requestMethod = "POST"
161
+ connection.connectTimeout = TIMEOUT_MS
162
+ connection.readTimeout = TIMEOUT_MS
163
+ connection.doOutput = true
164
+ connection.setRequestProperty("Content-Type", "application/json")
165
+ connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
166
+ if (connection.responseCode != 200) {
167
+ connection.errorStream?.close()
168
+ null
169
+ } else {
170
+ connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
171
+ }
172
+ } catch (_: Exception) {
173
+ null
174
+ } finally {
175
+ connection.disconnect()
176
+ }
177
+ }
178
+
179
+ /** Test hook: waits until every task submitted so far has run. */
180
+ internal fun awaitIdle() {
181
+ executor.submit {}.get()
182
+ }
183
+ }
184
+ }