@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.
- 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
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
|
|
221
|
-
|
|
222
|
-
|
|
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,
|
|
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
|
|
312
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|