@mentra/bluetooth-sdk 3.2.0-dev.208 → 3.2.0-dev.210
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 +45 -8
- package/android/build.gradle +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +89 -90
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsHost.kt +106 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsQueue.kt +111 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +139 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTransport.kt +49 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +11 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2SerialResolution.kt +23 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsHostTest.kt +64 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsQueueTest.kt +86 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +148 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTransportTest.kt +44 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/G2SerialResolutionTest.kt +25 -0
- package/build/generated/changelogCatalog.d.ts +1 -1
- package/build/generated/changelogCatalog.js +1 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +127 -76
- package/ios/Source/internal/BluetoothSdkAnalyticsHost.swift +91 -0
- package/ios/Source/internal/BluetoothSdkAnalyticsQueue.swift +117 -0
- package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +157 -0
- package/ios/Source/internal/BluetoothSdkAnalyticsTransport.swift +13 -0
- package/ios/Source/sgcs/G2.swift +374 -359
- package/ios/Source/sgcs/G2SerialResolution.swift +23 -0
- package/ios/Tests/BluetoothSdkAnalyticsHostTests.swift +63 -0
- package/ios/Tests/BluetoothSdkAnalyticsQueueTests.swift +97 -0
- package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +122 -0
- package/ios/Tests/BluetoothSdkAnalyticsTransportTests.swift +36 -0
- package/ios/Tests/G2SerialResolutionTests.swift +19 -0
- package/package.json +1 -1
- package/plugin/build/analyticsProps.d.ts +9 -0
- package/plugin/build/analyticsProps.js +34 -0
- package/plugin/build/index.d.ts +8 -0
- package/plugin/build/withAndroid.js +12 -18
- package/plugin/build/withIos.d.ts +1 -0
- package/plugin/build/withIos.js +8 -18
- package/src/generated/changelogCatalog.ts +1 -1
- package/src/generated/releaseMetadata.ts +5 -5
package/README.md
CHANGED
|
@@ -214,10 +214,19 @@ enterprise device deployments:
|
|
|
214
214
|
|
|
215
215
|
- `bluetooth_sdk_started`: sent once per app runtime after the native SDK starts.
|
|
216
216
|
- `bluetooth_sdk_glasses_connected`: sent when SDK status transitions from not connected to connected.
|
|
217
|
-
- `bluetooth_sdk_glasses_identified`: sent once per connection after the SDK receives a valid manufacturing serial from the glasses. This fires for every supported model that reports a serial. G1 and Ar99 decode the serial from the glasses' BLE advertisement. Mentra Live reports the product serial provisioned by its Android firmware through `asg_client`.
|
|
217
|
+
- `bluetooth_sdk_glasses_identified`: sent once per connection after the SDK receives a valid manufacturing serial from the glasses, and again as a `glasses_heartbeat` on the first status update of each new reporting day (`America/Los_Angeles` calendar day, the calendar Mentra's weekly reporting is cut on) while that connection is still up, so a connection that spans a week boundary is visible in both weeks. Heartbeats ride on glasses status updates; a connection whose status never changes for a whole day produces none. This fires for every supported model that reports a serial. G1, G2, and Ar99 decode the serial from the glasses' BLE advertisement. Mentra Live reports the product serial provisioned by its Android firmware through `asg_client`.
|
|
218
218
|
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
The connected event waits for the glasses model when the model is not known at
|
|
220
|
+
the moment the connection flag flips; if the connection ends first it is sent
|
|
221
|
+
without a model and with `glasses_model_unresolved=true`.
|
|
222
|
+
|
|
223
|
+
Analytics delivery never blocks Bluetooth SDK behavior: events are submitted
|
|
224
|
+
asynchronously off the caller thread. An upload that fails (no network, non-2xx)
|
|
225
|
+
is kept in a small on-device queue (at most 100 events, 7 days) and retried on
|
|
226
|
+
the next successful send or the next SDK start; a payload PostHog rejects
|
|
227
|
+
outright (4xx other than 408/429) is dropped rather than retried. Each event
|
|
228
|
+
carries its own `uuid` and capture `timestamp`, so a retry neither double counts
|
|
229
|
+
nor moves the event to a later week.
|
|
221
230
|
|
|
222
231
|
React Native / Expo apps can disable these events before SDK startup through
|
|
223
232
|
the config plugin:
|
|
@@ -243,6 +252,21 @@ Native Android apps can pass `BluetoothSdkAnalyticsConfig.disabled()` in
|
|
|
243
252
|
Native iOS apps can pass `.disabled` in `MentraBluetoothSDKConfiguration` or set
|
|
244
253
|
`MentraBluetoothSdkAnalyticsDisabled` to `true` in `Info.plist`.
|
|
245
254
|
|
|
255
|
+
Hosts that ship the same package id through several lanes (dev, staging, store)
|
|
256
|
+
can label the lane so Mentra can separate them. Pass
|
|
257
|
+
`{"analytics": {"environment": "prod"}}` to the config plugin, or set the
|
|
258
|
+
`com.mentra.bluetoothsdk.analytics.environment` Android metadata /
|
|
259
|
+
`MentraBluetoothSdkAnalyticsEnvironment` `Info.plist` key directly. Values are
|
|
260
|
+
trimmed and lowercased, must start with a letter or digit, may then contain
|
|
261
|
+
`[a-z0-9_-]`, are at most 32 characters, and are reported as `app_environment`.
|
|
262
|
+
|
|
263
|
+
Mentra counts an install as production only when `app_install_source` is
|
|
264
|
+
`app_store` or `play_store`, `app_build_type` is `release`, and, for hosts that
|
|
265
|
+
declare a lane, `app_environment` is `prod`. Play cannot distinguish its testing
|
|
266
|
+
tracks from production (both report `play_store`), so the lane is what separates
|
|
267
|
+
them for the Mentra App; hosts without a lane are reported as an unclassified
|
|
268
|
+
store cohort rather than assumed production.
|
|
269
|
+
|
|
246
270
|
Mentra's PostHog project API key is embedded in the SDK as a public analytics
|
|
247
271
|
write token, not a private PostHog personal API key. Apps do not configure the
|
|
248
272
|
analytics destination; these SDK usage events are always sent to Mentra's
|
|
@@ -251,11 +275,24 @@ PostHog project unless analytics are disabled.
|
|
|
251
275
|
Captured properties include `event_source`, `sdk_platform`, `sdk_surface`,
|
|
252
276
|
`sdk_version`, `app_identifier` (the Android package or iOS bundle identifier),
|
|
253
277
|
the platform-specific `app_package` or `app_bundle_identifier`, OS
|
|
254
|
-
platform/version, and `event_kind`.
|
|
255
|
-
`
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
278
|
+
platform/version, and `event_kind`. Every event also carries host build facts:
|
|
279
|
+
`app_version`, `app_build`, `app_build_type` (`debug` / `release`),
|
|
280
|
+
`app_install_source` (`play_store`, `app_store`, `testflight`, `adhoc_or_dev`,
|
|
281
|
+
`simulator`, `sideload`, a named third-party store, `other_store`, or `unknown`
|
|
282
|
+
when the platform gave no usable evidence), the raw Android
|
|
283
|
+
`app_installer_package` when present, and `app_environment` when the host
|
|
284
|
+
declares one. Connection and identification events include `fully_booted`,
|
|
285
|
+
a glasses model value when known, and `glasses_is_simulated`. The identification
|
|
286
|
+
event intentionally includes the glasses manufacturing serial as
|
|
287
|
+
`glasses_device_id`, with `glasses_device_id_type=manufacturing_serial`, so
|
|
288
|
+
Mentra can correlate fleet deployments across supported models, plus the
|
|
289
|
+
glasses-side software versions the SDK already holds (`glasses_firmware_version`,
|
|
290
|
+
`glasses_bes_firmware_version`, `glasses_mtk_firmware_version`,
|
|
291
|
+
`glasses_android_version`, `glasses_app_version`, `glasses_build_number`) so
|
|
292
|
+
identified glasses can be grouped by firmware. Glasses that never report a
|
|
293
|
+
serial produce no identification event; that coverage gap is measured as
|
|
294
|
+
connections without identification per model and SDK version, not from these
|
|
295
|
+
fields. This serial identifies the glasses
|
|
259
296
|
hardware, not the user or the host phone. Its source depends on the model:
|
|
260
297
|
Mentra Live reports the serial provisioned in BES NV storage, while G1 and Ar99
|
|
261
298
|
decode it from the glasses' BLE advertisement / manufacturer data.
|
package/android/build.gradle
CHANGED
|
@@ -274,6 +274,8 @@ dependencies {
|
|
|
274
274
|
testImplementation 'junit:junit:4.13.2'
|
|
275
275
|
testImplementation 'org.robolectric:robolectric:4.11.1'
|
|
276
276
|
testImplementation 'org.assertj:assertj-core:3.24.2'
|
|
277
|
+
// The Android SDK jar stubs org.json in JVM unit tests; the queue serializes real JSON.
|
|
278
|
+
testImplementation 'org.json:json:20240303'
|
|
277
279
|
testImplementation 'androidx.test:core:1.5.0'
|
|
278
280
|
|
|
279
281
|
// Local photo upload receiver
|
|
@@ -3,13 +3,15 @@ package com.mentra.bluetoothsdk
|
|
|
3
3
|
import android.content.Context
|
|
4
4
|
import android.content.pm.PackageManager
|
|
5
5
|
import android.os.Build
|
|
6
|
+
import com.mentra.bluetoothsdk.utils.DeviceTypes
|
|
6
7
|
import org.json.JSONObject
|
|
7
8
|
import java.net.HttpURLConnection
|
|
8
9
|
import java.net.URL
|
|
10
|
+
import java.text.SimpleDateFormat
|
|
11
|
+
import java.util.Date
|
|
12
|
+
import java.util.Locale
|
|
13
|
+
import java.util.TimeZone
|
|
9
14
|
import java.util.UUID
|
|
10
|
-
import java.util.concurrent.ExecutorService
|
|
11
|
-
import java.util.concurrent.Executors
|
|
12
|
-
import java.util.concurrent.RejectedExecutionException
|
|
13
15
|
|
|
14
16
|
class BluetoothSdkAnalyticsConfig private constructor(
|
|
15
17
|
val enabled: Boolean,
|
|
@@ -40,25 +42,23 @@ internal class BluetoothSdkAnalytics(
|
|
|
40
42
|
initialConfig: BluetoothSdkAnalyticsConfig,
|
|
41
43
|
) {
|
|
42
44
|
private val appContext = context.applicationContext
|
|
43
|
-
private val executor: ExecutorService = Executors.newSingleThreadExecutor { runnable ->
|
|
44
|
-
Thread(runnable, "MentraBluetoothSdkAnalytics").apply { isDaemon = true }
|
|
45
|
-
}
|
|
46
|
-
// startedCaptured/lastConnected are touched from store listeners and SDK
|
|
47
|
-
// entry points, hence @Synchronized on methods that read or write them.
|
|
48
45
|
private val config = initialConfig.toRuntimeConfig().resolvedForApp(appContext)
|
|
46
|
+
// The tracker is touched from store listeners and SDK entry points, hence
|
|
47
|
+
// @Synchronized on the methods that read or write it.
|
|
48
|
+
private val tracker = BluetoothSdkAnalyticsTracker(DeviceTypes.SIMULATED)
|
|
49
49
|
private var startedCaptured = false
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// Resolved once, lazily, on the transport executor: PackageManager lookups
|
|
51
|
+
// must not run on the caller (often main or the Bluetooth status) thread.
|
|
52
|
+
private val hostProperties: Map<String, Any> by lazy { BluetoothSdkAnalyticsHost.resolve(appContext).toMap() }
|
|
53
|
+
// The retry queue and its executor are process-wide (see BluetoothSdkAnalyticsTransport).
|
|
54
|
+
private val queue: BluetoothSdkAnalyticsQueue get() = BluetoothSdkAnalyticsTransport.queue(appContext)
|
|
55
|
+
private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
|
|
56
|
+
timeZone = TimeZone.getTimeZone("UTC")
|
|
57
|
+
}
|
|
52
58
|
|
|
53
59
|
@Synchronized
|
|
54
60
|
fun initializeGlassesStatus(status: GlassesStatus) {
|
|
55
|
-
|
|
56
|
-
// Only treat identification as already captured when a valid serial is
|
|
57
|
-
// present at init. If the glasses are connected but the serial has not
|
|
58
|
-
// arrived yet (Mentra Live fills it via version_info after connect), leave
|
|
59
|
-
// this false so the identify event still fires once the serial arrives.
|
|
60
|
-
identifiedCapturedForConnection =
|
|
61
|
-
status.analyticsConnected && status.serialNumber.validManufacturingSerial() != null
|
|
61
|
+
tracker.initialize(status.toAnalyticsSnapshot(), BluetoothSdkAnalyticsTracker.reportingDay(System.currentTimeMillis()))
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
@Synchronized
|
|
@@ -66,52 +66,23 @@ internal class BluetoothSdkAnalytics(
|
|
|
66
66
|
if (startedCaptured || !config.isReady) return
|
|
67
67
|
startedCaptured = true
|
|
68
68
|
capture("bluetooth_sdk_started", mapOf("event_kind" to "sdk_started"))
|
|
69
|
+
// A fresh runtime is the natural moment to retry what an earlier one could not deliver.
|
|
70
|
+
runOnExecutor { queue.drain(System.currentTimeMillis()) { payload -> send(payload) } }
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
@Synchronized
|
|
72
74
|
fun observeGlassesStatus(status: GlassesStatus) {
|
|
73
|
-
val
|
|
74
|
-
val wasConnected = lastConnected
|
|
75
|
-
lastConnected = isConnected
|
|
75
|
+
val events = tracker.observe(status.toAnalyticsSnapshot(), BluetoothSdkAnalyticsTracker.reportingDay(System.currentTimeMillis()))
|
|
76
76
|
if (!config.isReady) return
|
|
77
|
-
|
|
78
|
-
identifiedCapturedForConnection = false
|
|
79
|
-
return
|
|
80
|
-
}
|
|
81
|
-
if (isConnected && !wasConnected) {
|
|
82
|
-
identifiedCapturedForConnection = false
|
|
83
|
-
capture(
|
|
84
|
-
"bluetooth_sdk_glasses_connected",
|
|
85
|
-
buildMap {
|
|
86
|
-
put("event_kind", "glasses_connected")
|
|
87
|
-
put("fully_booted", status.fullyBooted)
|
|
88
|
-
status.deviceModel.takeIf { it.isNotBlank() }?.let { put("glasses_model", it) }
|
|
89
|
-
},
|
|
90
|
-
)
|
|
91
|
-
// Fall through: a serial already present at connect time (G1/Ar99 report
|
|
92
|
-
// it in the advertisement) should be identified now rather than waiting
|
|
93
|
-
// for some later, unrelated glasses-store update to run.
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
val serialNumber = status.serialNumber.validManufacturingSerial() ?: return
|
|
97
|
-
if (!identifiedCapturedForConnection) {
|
|
98
|
-
identifiedCapturedForConnection = true
|
|
99
|
-
capture(
|
|
100
|
-
"bluetooth_sdk_glasses_identified",
|
|
101
|
-
buildMap {
|
|
102
|
-
put("event_kind", "glasses_identified")
|
|
103
|
-
put("fully_booted", status.fullyBooted)
|
|
104
|
-
put("glasses_device_id", serialNumber)
|
|
105
|
-
put("glasses_device_id_type", "manufacturing_serial")
|
|
106
|
-
status.deviceModel.takeIf { it.isNotBlank() }?.let { put("glasses_model", it) }
|
|
107
|
-
},
|
|
108
|
-
)
|
|
109
|
-
}
|
|
77
|
+
for (event in events) capture(event.name, event.properties)
|
|
110
78
|
}
|
|
111
79
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Delivery is owned by the process-wide transport, so nothing is torn down
|
|
82
|
+
* here: a retry in flight when this instance closes still completes, and a
|
|
83
|
+
* replacement instance cannot race it on the retry file.
|
|
84
|
+
*/
|
|
85
|
+
fun shutdown() {}
|
|
115
86
|
|
|
116
87
|
private fun capture(
|
|
117
88
|
eventName: String,
|
|
@@ -119,40 +90,60 @@ internal class BluetoothSdkAnalytics(
|
|
|
119
90
|
) {
|
|
120
91
|
val activeConfig = config
|
|
121
92
|
if (!activeConfig.isReady) return
|
|
93
|
+
// Identity and time are fixed at capture, not at (re)send, so a retried
|
|
94
|
+
// event neither double counts nor drifts into a later week.
|
|
95
|
+
val uuid = UUID.randomUUID().toString()
|
|
96
|
+
val capturedAt = System.currentTimeMillis()
|
|
97
|
+
runOnExecutor {
|
|
98
|
+
val payload =
|
|
99
|
+
JSONObject(
|
|
100
|
+
mapOf(
|
|
101
|
+
"api_key" to DEFAULT_POSTHOG_API_KEY,
|
|
102
|
+
"uuid" to uuid,
|
|
103
|
+
"event" to eventName,
|
|
104
|
+
"distinct_id" to distinctId(),
|
|
105
|
+
"timestamp" to isoFormat.format(Date(capturedAt)),
|
|
106
|
+
"properties" to baseProperties(activeConfig) + hostProperties + eventProperties,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
when (send(payload)) {
|
|
110
|
+
SendOutcome.DELIVERED -> queue.drain(capturedAt) { queued -> send(queued) }
|
|
111
|
+
SendOutcome.RETRY -> queue.enqueue(payload, capturedAt)
|
|
112
|
+
SendOutcome.DISCARD -> Unit
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private fun runOnExecutor(block: () -> Unit) {
|
|
118
|
+
BluetoothSdkAnalyticsTransport.submit(block)
|
|
119
|
+
}
|
|
122
120
|
|
|
121
|
+
/**
|
|
122
|
+
* 2xx is delivered. A 4xx other than 408/429 means PostHog rejected this payload
|
|
123
|
+
* for good (bad key, malformed body) and retrying it would only block the queue.
|
|
124
|
+
* Everything else (network errors, 5xx, 408, 429) is worth retrying later.
|
|
125
|
+
*/
|
|
126
|
+
private fun send(payload: JSONObject): SendOutcome =
|
|
123
127
|
try {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
"distinct_id" to distinctId(),
|
|
134
|
-
"properties" to baseProperties(activeConfig) + eventProperties,
|
|
135
|
-
)
|
|
136
|
-
)
|
|
137
|
-
val connection = URL(captureUrl()).openConnection() as HttpURLConnection
|
|
138
|
-
connection.requestMethod = "POST"
|
|
139
|
-
connection.connectTimeout = 4_000
|
|
140
|
-
connection.readTimeout = 4_000
|
|
141
|
-
connection.doOutput = true
|
|
142
|
-
connection.setRequestProperty("Content-Type", "application/json")
|
|
143
|
-
connection.outputStream.use { output ->
|
|
144
|
-
output.write(payload.toString().toByteArray(Charsets.UTF_8))
|
|
145
|
-
}
|
|
146
|
-
connection.inputStream.close()
|
|
147
|
-
connection.disconnect()
|
|
148
|
-
} catch (_: Exception) {
|
|
149
|
-
// Analytics must never affect Bluetooth SDK behavior.
|
|
128
|
+
val connection = URL(captureUrl()).openConnection() as HttpURLConnection
|
|
129
|
+
try {
|
|
130
|
+
connection.requestMethod = "POST"
|
|
131
|
+
connection.connectTimeout = 4_000
|
|
132
|
+
connection.readTimeout = 4_000
|
|
133
|
+
connection.doOutput = true
|
|
134
|
+
connection.setRequestProperty("Content-Type", "application/json")
|
|
135
|
+
connection.outputStream.use { output ->
|
|
136
|
+
output.write(payload.toString().toByteArray(Charsets.UTF_8))
|
|
150
137
|
}
|
|
138
|
+
val code = connection.responseCode
|
|
139
|
+
if (code in 200..299) connection.inputStream.close() else connection.errorStream?.close()
|
|
140
|
+
SendOutcome.fromHttpStatus(code)
|
|
141
|
+
} finally {
|
|
142
|
+
connection.disconnect()
|
|
151
143
|
}
|
|
152
|
-
} catch (_:
|
|
153
|
-
|
|
144
|
+
} catch (_: Exception) {
|
|
145
|
+
SendOutcome.RETRY
|
|
154
146
|
}
|
|
155
|
-
}
|
|
156
147
|
|
|
157
148
|
private fun baseProperties(activeConfig: BluetoothSdkAnalyticsRuntimeConfig): Map<String, Any> =
|
|
158
149
|
buildMap {
|
|
@@ -209,8 +200,16 @@ private fun BluetoothSdkAnalyticsRuntimeConfig.resolvedForApp(context: Context):
|
|
|
209
200
|
)
|
|
210
201
|
}
|
|
211
202
|
|
|
212
|
-
private
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
203
|
+
private fun GlassesStatus.toAnalyticsSnapshot(): AnalyticsGlassesSnapshot =
|
|
204
|
+
AnalyticsGlassesSnapshot(
|
|
205
|
+
connected = connectionState.isConnected || connected || fullyBooted,
|
|
206
|
+
fullyBooted = fullyBooted,
|
|
207
|
+
model = deviceModel,
|
|
208
|
+
serialNumber = serialNumber,
|
|
209
|
+
firmwareVersion = firmwareVersion,
|
|
210
|
+
besFirmwareVersion = besFirmwareVersion,
|
|
211
|
+
mtkFirmwareVersion = mtkFirmwareVersion,
|
|
212
|
+
androidVersion = androidVersion,
|
|
213
|
+
appVersion = appVersion,
|
|
214
|
+
buildNumber = buildNumber,
|
|
215
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.pm.ApplicationInfo
|
|
5
|
+
import android.content.pm.PackageManager
|
|
6
|
+
import android.os.Build
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Host-app facts attached to every analytics event so Mentra can separate store
|
|
10
|
+
* installs from sideloaded, debuggable, and internal-lane builds.
|
|
11
|
+
*/
|
|
12
|
+
internal data class BluetoothSdkAnalyticsHostProperties(
|
|
13
|
+
val appVersion: String?,
|
|
14
|
+
val appBuild: String?,
|
|
15
|
+
val buildType: String,
|
|
16
|
+
val installSource: String,
|
|
17
|
+
val installerPackage: String?,
|
|
18
|
+
val environment: String?,
|
|
19
|
+
) {
|
|
20
|
+
fun toMap(): Map<String, Any> =
|
|
21
|
+
buildMap {
|
|
22
|
+
appVersion?.let { put("app_version", it) }
|
|
23
|
+
appBuild?.let { put("app_build", it) }
|
|
24
|
+
put("app_build_type", buildType)
|
|
25
|
+
put("app_install_source", installSource)
|
|
26
|
+
installerPackage?.let { put("app_installer_package", it) }
|
|
27
|
+
environment?.let { put("app_environment", it) }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
internal object BluetoothSdkAnalyticsHost {
|
|
32
|
+
/**
|
|
33
|
+
* Optional host-declared lane (`dev`, `staging`, `prod`, ...). Stamped by the Expo
|
|
34
|
+
* config plugin's `analytics.environment` option; native apps may set it directly.
|
|
35
|
+
*/
|
|
36
|
+
const val META_ENVIRONMENT = "com.mentra.bluetoothsdk.analytics.environment"
|
|
37
|
+
|
|
38
|
+
private val environmentPattern = Regex("^[a-z0-9][a-z0-9_-]{0,31}$")
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Android cannot tell a Play production install from a Play testing track: both
|
|
42
|
+
* arrive through `com.android.vending`. `app_environment` is the host's way to
|
|
43
|
+
* add that distinction. A missing installer is documented to mean adb, a file
|
|
44
|
+
* manager, or an IDE put the APK on the device; a failed lookup is reported as
|
|
45
|
+
* `unknown` rather than guessed, because this value decides whether an install
|
|
46
|
+
* counts as production.
|
|
47
|
+
*/
|
|
48
|
+
fun installSourceFor(installerPackage: String?, lookupFailed: Boolean = false): String =
|
|
49
|
+
when {
|
|
50
|
+
lookupFailed -> "unknown"
|
|
51
|
+
else -> installerPackage?.trim()?.takeIf { it.isNotEmpty() }.let { installer ->
|
|
52
|
+
when (installer) {
|
|
53
|
+
null -> "sideload"
|
|
54
|
+
"com.android.vending" -> "play_store"
|
|
55
|
+
"com.amazon.venezia" -> "amazon_appstore"
|
|
56
|
+
"com.sec.android.app.samsungapps" -> "galaxy_store"
|
|
57
|
+
"com.huawei.appmarket" -> "huawei_appgallery"
|
|
58
|
+
"com.xiaomi.market" -> "xiaomi_getapps"
|
|
59
|
+
else -> "other_store"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fun normalizedEnvironment(raw: String?): String? =
|
|
65
|
+
raw?.trim()?.lowercase()?.takeIf { environmentPattern.matches(it) }
|
|
66
|
+
|
|
67
|
+
fun resolve(context: Context): BluetoothSdkAnalyticsHostProperties {
|
|
68
|
+
val packageManager = context.packageManager
|
|
69
|
+
val packageName = context.packageName
|
|
70
|
+
val packageInfo =
|
|
71
|
+
try {
|
|
72
|
+
packageManager.getPackageInfo(packageName, 0)
|
|
73
|
+
} catch (_: Exception) {
|
|
74
|
+
null
|
|
75
|
+
}
|
|
76
|
+
val applicationInfo =
|
|
77
|
+
try {
|
|
78
|
+
packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
|
|
79
|
+
} catch (_: Exception) {
|
|
80
|
+
null
|
|
81
|
+
}
|
|
82
|
+
var installerLookupFailed = false
|
|
83
|
+
val installer =
|
|
84
|
+
try {
|
|
85
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
86
|
+
packageManager.getInstallSourceInfo(packageName).installingPackageName
|
|
87
|
+
} else {
|
|
88
|
+
@Suppress("DEPRECATION")
|
|
89
|
+
packageManager.getInstallerPackageName(packageName)
|
|
90
|
+
}
|
|
91
|
+
} catch (_: Exception) {
|
|
92
|
+
installerLookupFailed = true
|
|
93
|
+
null
|
|
94
|
+
}
|
|
95
|
+
val debuggable = ((applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
|
96
|
+
|
|
97
|
+
return BluetoothSdkAnalyticsHostProperties(
|
|
98
|
+
appVersion = packageInfo?.versionName?.trim()?.takeIf { it.isNotEmpty() },
|
|
99
|
+
appBuild = packageInfo?.longVersionCode?.toString(),
|
|
100
|
+
buildType = if (debuggable) "debug" else "release",
|
|
101
|
+
installSource = installSourceFor(installer, installerLookupFailed),
|
|
102
|
+
installerPackage = installer?.trim()?.takeIf { it.isNotEmpty() },
|
|
103
|
+
environment = normalizedEnvironment(applicationInfo?.metaData?.getString(META_ENVIRONMENT)),
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
import org.json.JSONObject
|
|
4
|
+
import java.io.File
|
|
5
|
+
|
|
6
|
+
internal enum class SendOutcome {
|
|
7
|
+
DELIVERED,
|
|
8
|
+
RETRY,
|
|
9
|
+
DISCARD,
|
|
10
|
+
;
|
|
11
|
+
|
|
12
|
+
companion object {
|
|
13
|
+
fun fromHttpStatus(code: Int): SendOutcome =
|
|
14
|
+
when {
|
|
15
|
+
code in 200..299 -> DELIVERED
|
|
16
|
+
code == 408 || code == 429 -> RETRY
|
|
17
|
+
code in 400..499 -> DISCARD
|
|
18
|
+
else -> RETRY
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Bounded on-disk retry queue for analytics payloads whose upload failed.
|
|
25
|
+
* One JSON object per line. Oldest entries are dropped past [maxEntries]; entries
|
|
26
|
+
* older than [maxAgeMillis] are discarded on drain. Every payload carries its own
|
|
27
|
+
* `uuid` and `timestamp`, so a retried event neither double counts nor moves in time.
|
|
28
|
+
*
|
|
29
|
+
* Not thread-safe by itself: callers serialize access on the analytics executor.
|
|
30
|
+
*/
|
|
31
|
+
internal class BluetoothSdkAnalyticsQueue(
|
|
32
|
+
private val file: File,
|
|
33
|
+
private val maxEntries: Int = DEFAULT_MAX_ENTRIES,
|
|
34
|
+
private val maxAgeMillis: Long = DEFAULT_MAX_AGE_MILLIS,
|
|
35
|
+
) {
|
|
36
|
+
fun enqueue(payload: JSONObject, nowMillis: Long) {
|
|
37
|
+
val entries = read().toMutableList()
|
|
38
|
+
entries += JSONObject().put("enqueued_at", nowMillis).put("payload", payload)
|
|
39
|
+
while (entries.size > maxEntries) entries.removeAt(0)
|
|
40
|
+
write(entries)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fun size(): Int = read().size
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sends queued payloads oldest-first through [send]. Delivered and permanently
|
|
47
|
+
* rejected entries are dropped; the first retryable failure stops the drain so
|
|
48
|
+
* ordering is preserved and a dead network does not burn through every entry.
|
|
49
|
+
*/
|
|
50
|
+
fun drain(nowMillis: Long, send: (JSONObject) -> SendOutcome) {
|
|
51
|
+
val entries = read()
|
|
52
|
+
if (entries.isEmpty()) return
|
|
53
|
+
val remaining = mutableListOf<JSONObject>()
|
|
54
|
+
var blocked = false
|
|
55
|
+
for (entry in entries) {
|
|
56
|
+
val enqueuedAt = entry.optLong("enqueued_at", nowMillis)
|
|
57
|
+
if (nowMillis - enqueuedAt > maxAgeMillis) continue
|
|
58
|
+
val payload = entry.optJSONObject("payload") ?: continue
|
|
59
|
+
if (blocked) {
|
|
60
|
+
remaining += entry
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
val outcome =
|
|
64
|
+
try {
|
|
65
|
+
send(payload)
|
|
66
|
+
} catch (_: Exception) {
|
|
67
|
+
SendOutcome.RETRY
|
|
68
|
+
}
|
|
69
|
+
if (outcome == SendOutcome.RETRY) {
|
|
70
|
+
blocked = true
|
|
71
|
+
remaining += entry
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
write(remaining)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private fun read(): List<JSONObject> {
|
|
78
|
+
if (!file.exists()) return emptyList()
|
|
79
|
+
return try {
|
|
80
|
+
file.readLines().mapNotNull { line ->
|
|
81
|
+
line.takeIf { it.isNotBlank() }?.let { runCatching { JSONObject(it) }.getOrNull() }
|
|
82
|
+
}
|
|
83
|
+
} catch (_: Exception) {
|
|
84
|
+
emptyList()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private fun write(entries: List<JSONObject>) {
|
|
89
|
+
try {
|
|
90
|
+
if (entries.isEmpty()) {
|
|
91
|
+
file.delete()
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
file.parentFile?.mkdirs()
|
|
95
|
+
val tmp = File(file.parentFile, file.name + ".tmp")
|
|
96
|
+
tmp.writeText(entries.joinToString("\n") { it.toString() } + "\n")
|
|
97
|
+
if (!tmp.renameTo(file)) {
|
|
98
|
+
file.writeText(entries.joinToString("\n") { it.toString() } + "\n")
|
|
99
|
+
tmp.delete()
|
|
100
|
+
}
|
|
101
|
+
} catch (_: Exception) {
|
|
102
|
+
// A queue write failure only loses retries, never live behavior.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
companion object {
|
|
107
|
+
const val DEFAULT_MAX_ENTRIES = 100
|
|
108
|
+
const val DEFAULT_MAX_AGE_MILLIS = 7L * 24 * 60 * 60 * 1000
|
|
109
|
+
const val FILE_NAME = "mentra_bluetooth_sdk_analytics_queue.jsonl"
|
|
110
|
+
}
|
|
111
|
+
}
|