@mentra/bluetooth-sdk 3.3.0-dev.333 → 3.3.0-dev.338
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/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +30 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +61 -12
- package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
- 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/GeneratedReleaseMetadata.swift +5 -5
- package/package.json +2 -2
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -13,6 +13,7 @@ import androidx.core.content.ContextCompat
|
|
|
13
13
|
import com.mentra.bluetoothsdk.controllers.ControllerManager
|
|
14
14
|
import com.mentra.bluetoothsdk.controllers.R1
|
|
15
15
|
import com.mentra.bluetoothsdk.services.ForegroundService
|
|
16
|
+
import com.mentra.bluetoothsdk.services.G2ConnectionRecovery
|
|
16
17
|
import com.mentra.bluetoothsdk.services.PhoneMic
|
|
17
18
|
import com.mentra.bluetoothsdk.sgcs.Ar99
|
|
18
19
|
import com.mentra.bluetoothsdk.sgcs.G1
|
|
@@ -52,6 +53,8 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
52
53
|
@Volatile
|
|
53
54
|
private var _instance: DeviceManager? = null
|
|
54
55
|
|
|
56
|
+
internal fun isInitialized(): Boolean = _instance != null
|
|
57
|
+
|
|
55
58
|
@JvmStatic
|
|
56
59
|
fun getInstance(): DeviceManager {
|
|
57
60
|
return _instance
|
|
@@ -60,6 +63,10 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
// MARK: - Unique (Android)
|
|
66
|
+
private val connectionRecovery = G2ConnectionRecovery(Bridge.getContext())
|
|
67
|
+
private val recoveryLock = Any()
|
|
68
|
+
private var recoverG2Connection = false
|
|
69
|
+
private var recoveryListenerId: String? = null
|
|
63
70
|
private var serviceStarted = false
|
|
64
71
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
65
72
|
private val executor: ExecutorService = Executors.newSingleThreadExecutor()
|
|
@@ -285,6 +292,15 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
285
292
|
|
|
286
293
|
init {
|
|
287
294
|
Bridge.log("DeviceManager: init()")
|
|
295
|
+
recoveryListenerId = DeviceStore.store.addListener { category, changes ->
|
|
296
|
+
if (category == "bluetooth" && changes.keys.any { it in G2ConnectionRecovery.keys }) {
|
|
297
|
+
synchronized(recoveryLock) {
|
|
298
|
+
if (recoverG2Connection) {
|
|
299
|
+
connectionRecovery.save(DeviceStore.store.getCategory("bluetooth"))
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
288
304
|
initializeViewStates()
|
|
289
305
|
if (initializeHardware) initializeHardwareServices()
|
|
290
306
|
}
|
|
@@ -1505,6 +1521,10 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
1505
1521
|
Bridge.log("MAN: handleDeviceReady() ${sgc?.type}")
|
|
1506
1522
|
resetMicHealth()
|
|
1507
1523
|
defaultWearable = sgc?.type ?: ""
|
|
1524
|
+
synchronized(recoveryLock) {
|
|
1525
|
+
recoverG2Connection = defaultWearable == DeviceTypes.G2
|
|
1526
|
+
connectionRecovery.save(DeviceStore.store.getCategory("bluetooth"))
|
|
1527
|
+
}
|
|
1508
1528
|
searching = false
|
|
1509
1529
|
|
|
1510
1530
|
syncSystemTimeOnceForConnection(readyKey)
|
|
@@ -2411,6 +2431,10 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
2411
2431
|
}
|
|
2412
2432
|
|
|
2413
2433
|
fun disconnect(clearPendingIdentity: Boolean = true) {
|
|
2434
|
+
synchronized(recoveryLock) {
|
|
2435
|
+
recoverG2Connection = false
|
|
2436
|
+
connectionRecovery.clear()
|
|
2437
|
+
}
|
|
2414
2438
|
sgc?.clearDisplay()
|
|
2415
2439
|
// NIMO owns a background canvas encoder and this path discards its instance.
|
|
2416
2440
|
// A link-level reconnect keeps the instance (and encoder) through disconnect().
|
|
@@ -2465,6 +2489,10 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
2465
2489
|
}
|
|
2466
2490
|
|
|
2467
2491
|
fun forget() {
|
|
2492
|
+
synchronized(recoveryLock) {
|
|
2493
|
+
recoverG2Connection = false
|
|
2494
|
+
connectionRecovery.clear()
|
|
2495
|
+
}
|
|
2468
2496
|
Bridge.log("MAN: Forgetting smart glasses")
|
|
2469
2497
|
|
|
2470
2498
|
val live = sgc as? MentraLive
|
|
@@ -2579,6 +2607,8 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
2579
2607
|
|
|
2580
2608
|
// MARK: Cleanup
|
|
2581
2609
|
fun cleanup() {
|
|
2610
|
+
recoveryListenerId?.let { DeviceStore.store.removeListener(it) }
|
|
2611
|
+
recoveryListenerId = null
|
|
2582
2612
|
stopBluetoothStateMonitoring()
|
|
2583
2613
|
|
|
2584
2614
|
micReinitRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
|
|
|
3
3
|
/** Generated by release CI. Do not edit in a release checkout. */
|
|
4
4
|
internal object GeneratedReleaseMetadata {
|
|
5
5
|
const val FAMILY_BASE_VERSION: String = "3.3.0"
|
|
6
|
-
const val RELEASE_IDENTITY: String = "3.3.0-dev.
|
|
7
|
-
const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
9
|
-
const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.
|
|
10
|
-
const val OTA_MANIFEST_SHA256: String = "
|
|
6
|
+
const val RELEASE_IDENTITY: String = "3.3.0-dev.338"
|
|
7
|
+
const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.338"
|
|
8
|
+
const val SOURCE_COMMIT: String = "27dcd64ac422939659d3dedd8b4f9041cc5ed77b"
|
|
9
|
+
const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.338.json"
|
|
10
|
+
const val OTA_MANIFEST_SHA256: String = "bdb8f8a677f95e32babb498efcfe08d3022ce8727e16e57e1cedff7ac8542314"
|
|
11
11
|
}
|
|
@@ -13,6 +13,8 @@ import android.os.IBinder
|
|
|
13
13
|
import androidx.core.app.NotificationCompat
|
|
14
14
|
import androidx.core.content.ContextCompat
|
|
15
15
|
import com.mentra.bluetoothsdk.Bridge
|
|
16
|
+
import com.mentra.bluetoothsdk.DeviceManager
|
|
17
|
+
import com.mentra.bluetoothsdk.DeviceStore
|
|
16
18
|
import com.mentra.bluetoothsdk.debug.BleTraceLogger
|
|
17
19
|
|
|
18
20
|
class ForegroundService : Service() {
|
|
@@ -107,9 +109,40 @@ class ForegroundService : Service() {
|
|
|
107
109
|
}
|
|
108
110
|
// Re-check permissions in case they changed
|
|
109
111
|
startForegroundWithAutoDetectedType()
|
|
112
|
+
// A null intent is Android restarting START_STICKY after process death.
|
|
113
|
+
// Do not wait for an Activity/Expo module to restore the native BLE owner.
|
|
114
|
+
if (intent == null && !DeviceManager.isInitialized()) {
|
|
115
|
+
val settings = G2ConnectionRecovery(this).read()
|
|
116
|
+
if (settings != null && !startHostRuntimeRecovery()) {
|
|
117
|
+
Bridge.initialize(applicationContext)
|
|
118
|
+
settings.forEach { (key, value) -> DeviceStore.set("bluetooth", key, value) }
|
|
119
|
+
Bridge.log("ForegroundService: restoring G2 connection after process restart")
|
|
120
|
+
try {
|
|
121
|
+
DeviceManager.getInstance().connectDefault()
|
|
122
|
+
} catch (error: Exception) {
|
|
123
|
+
Bridge.log("ForegroundService: G2 recovery failed: ${error.message}")
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
110
127
|
return START_STICKY
|
|
111
128
|
}
|
|
112
129
|
|
|
130
|
+
private fun startHostRuntimeRecovery(): Boolean {
|
|
131
|
+
// Keep the standalone SDK free of React dependencies. The Mentra App
|
|
132
|
+
// opts in with a private headless service declared by its host module.
|
|
133
|
+
val serviceName = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
|
|
134
|
+
.metaData?.getString("com.mentra.bluetoothsdk.RUNTIME_RECOVERY_SERVICE")
|
|
135
|
+
?: return false
|
|
136
|
+
return try {
|
|
137
|
+
if (startService(Intent().setClassName(packageName, serviceName)) == null) return false
|
|
138
|
+
Bridge.log("ForegroundService: requested host JavaScript runtime recovery")
|
|
139
|
+
true
|
|
140
|
+
} catch (error: Exception) {
|
|
141
|
+
Bridge.log("ForegroundService: host runtime recovery could not start: ${error.message}")
|
|
142
|
+
false
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
113
146
|
private fun startForegroundWithAutoDetectedType() {
|
|
114
147
|
startForegroundWithType(detectServiceType())
|
|
115
148
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.services
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import com.mentra.bluetoothsdk.utils.DeviceTypes
|
|
5
|
+
import org.json.JSONObject
|
|
6
|
+
|
|
7
|
+
/** Native recovery of the last active G2 link when Android restarts our sticky service. */
|
|
8
|
+
internal class G2ConnectionRecovery(context: Context) {
|
|
9
|
+
private val preferences = context.getSharedPreferences("g2_connection_recovery", Context.MODE_PRIVATE)
|
|
10
|
+
|
|
11
|
+
companion object {
|
|
12
|
+
// Deliberately exclude transient connection flags, mic intent, tokens and pending pairing.
|
|
13
|
+
internal val keys = setOf(
|
|
14
|
+
"default_wearable", "device_name", "device_address", "project_name",
|
|
15
|
+
"brightness", "auto_brightness", "dashboard_height", "dashboard_depth",
|
|
16
|
+
"head_up_angle", "use_native_dashboard", "twelve_hour_time", "metric_system",
|
|
17
|
+
"screen_disabled", "power_saving_mode", "lc3_frame_size",
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fun save(settings: Map<String, Any>) {
|
|
22
|
+
if (settings["default_wearable"] != DeviceTypes.G2 ||
|
|
23
|
+
(settings["device_name"] as? String).isNullOrBlank()) {
|
|
24
|
+
clear()
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
val snapshot = settings.filterKeys { it in keys }
|
|
28
|
+
preferences.edit().putString("snapshot", JSONObject(snapshot).toString()).apply()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fun clear() {
|
|
32
|
+
preferences.edit().remove("snapshot").apply()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fun read(): Map<String, Any>? {
|
|
36
|
+
val raw = preferences.getString("snapshot", null) ?: return null
|
|
37
|
+
return try {
|
|
38
|
+
val json = JSONObject(raw)
|
|
39
|
+
if (json.optString("default_wearable") != DeviceTypes.G2 ||
|
|
40
|
+
json.optString("device_name").isBlank()) return null
|
|
41
|
+
keys.filter { json.has(it) && !json.isNull(it) }.associateWith { json.get(it) }
|
|
42
|
+
} catch (_: Exception) {
|
|
43
|
+
clear()
|
|
44
|
+
null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -1665,8 +1665,15 @@ class G2 : SGCManager() {
|
|
|
1665
1665
|
if (char == null || gatt == null) return
|
|
1666
1666
|
char.value = packet
|
|
1667
1667
|
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
|
1668
|
-
|
|
1669
|
-
|
|
1668
|
+
// A rejected write never entered Android's queue. Retry on this dedicated
|
|
1669
|
+
// BLE worker before later commands (especially a page replacement) overtake it.
|
|
1670
|
+
var ok = false
|
|
1671
|
+
for (attempt in 0 until 5) {
|
|
1672
|
+
ok = gatt.writeCharacteristic(char)
|
|
1673
|
+
bgcapNoteWriteResult(ok)
|
|
1674
|
+
if (ok) break
|
|
1675
|
+
if (attempt < 4) Thread.sleep(BLE_PACKET_GAP_MS)
|
|
1676
|
+
}
|
|
1670
1677
|
if (leg != null) {
|
|
1671
1678
|
Bridge.log("G2/FILE: write $leg ${packet.size}B -> ${if (ok) "queued" else "DROPPED (stack busy)"}")
|
|
1672
1679
|
}
|
|
@@ -2258,6 +2265,12 @@ class G2 : SGCManager() {
|
|
|
2258
2265
|
}
|
|
2259
2266
|
|
|
2260
2267
|
override fun clearDisplay() {
|
|
2268
|
+
// Expo invokes this on its worker queue; all container mutations must
|
|
2269
|
+
// share Main with reconcileDisplay and scene updates.
|
|
2270
|
+
if (Looper.myLooper() != Looper.getMainLooper()) {
|
|
2271
|
+
mainHandler.post { clearDisplay() }
|
|
2272
|
+
return
|
|
2273
|
+
}
|
|
2261
2274
|
Bridge.log("G2: clearDisplay()")
|
|
2262
2275
|
// Clear the text in place — do NOT shut down + rebuild the EvenHub page. Tearing the page
|
|
2263
2276
|
// down (rebuildPage) kills audio streaming AND triggers a firmware systemExit /
|
|
@@ -2267,9 +2280,22 @@ class G2 : SGCManager() {
|
|
|
2267
2280
|
// several seconds (incident 8164175a). Just blank the text + clear images; the reconcile
|
|
2268
2281
|
// loop pushes the blanked text on a live page, and a dead page is only resurrected for
|
|
2269
2282
|
// meaningful (non-blank) content, so a clear can't churn it back up.
|
|
2270
|
-
for (
|
|
2271
|
-
|
|
2272
|
-
|
|
2283
|
+
for (container in textContainers) {
|
|
2284
|
+
// Erase the firmware's existing text BEFORE forgetting its container.
|
|
2285
|
+
// A queued reconcile cannot do this after the positioned container is removed.
|
|
2286
|
+
// Cover the previous byte range, rather than replacing only its first character.
|
|
2287
|
+
val blank = " ".repeat(maxOf(1, container.content.toByteArray(Charsets.UTF_8).size))
|
|
2288
|
+
container.content = blank
|
|
2289
|
+
container.pendingSends = 1 + EVEN_HUB_RESEND_COUNT
|
|
2290
|
+
if (pageCreated) {
|
|
2291
|
+
val erase = EvenHubProto.updateTextMessage(
|
|
2292
|
+
containerID = container.id,
|
|
2293
|
+
contentOffset = 0,
|
|
2294
|
+
contentLength = blank.length,
|
|
2295
|
+
content = blank
|
|
2296
|
+
)
|
|
2297
|
+
repeat(1 + EVEN_HUB_RESEND_COUNT) { sendEvenHubCommand(erase) }
|
|
2298
|
+
}
|
|
2273
2299
|
}
|
|
2274
2300
|
for (i in imageContainers.indices) {
|
|
2275
2301
|
// The firmware still shows this container's image; emptying bmpData locally never reaches
|
|
@@ -2297,8 +2323,13 @@ class G2 : SGCManager() {
|
|
|
2297
2323
|
textContainers.removeAll { it.id in huskIds }
|
|
2298
2324
|
sceneTextByElement.entries.removeAll { it.value in huskIds }
|
|
2299
2325
|
sceneImageByElement.clear()
|
|
2300
|
-
|
|
2301
|
-
|
|
2326
|
+
// Replace the live page now, while still on Main. A shutdown followed by
|
|
2327
|
+
// reconcile cannot clear this case: reconcile intentionally skips blank pages,
|
|
2328
|
+
// leaving the firmware's old positioned text visible until new speech arrives.
|
|
2329
|
+
// Queue the blank replacement before any following miniapp frame, without
|
|
2330
|
+
// tearing down the page (or its microphone session).
|
|
2331
|
+
Bridge.log("G2: clearDisplay() — replacing page after removing ${huskIds.size} positioned container(s)")
|
|
2332
|
+
if (pageCreated) createPageWithContainers()
|
|
2302
2333
|
}
|
|
2303
2334
|
}
|
|
2304
2335
|
|
|
@@ -2345,6 +2376,15 @@ class G2 : SGCManager() {
|
|
|
2345
2376
|
return false
|
|
2346
2377
|
}
|
|
2347
2378
|
|
|
2379
|
+
if (Looper.myLooper() != Looper.getMainLooper()) {
|
|
2380
|
+
mainHandler.post { applyBitmap(rx, ry, rw, rh, bmpData) }
|
|
2381
|
+
return true
|
|
2382
|
+
}
|
|
2383
|
+
return applyBitmap(rx, ry, rw, rh, bmpData)
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
// Called only on Main, including the inline scene-frame path.
|
|
2387
|
+
private fun applyBitmap(rx: Int, ry: Int, rw: Int, rh: Int, bmpData: ByteArray): Boolean {
|
|
2348
2388
|
// Pure state mutation: update the target container's bytes and mark it dirty. The reconcile
|
|
2349
2389
|
// loop is the sole sender, so two displayBitmap calls can never overlap a sendImageData and
|
|
2350
2390
|
// clobber the single-slot image ACK — no lock needed. Reuse an existing container if the rect
|
|
@@ -3000,15 +3040,15 @@ class G2 : SGCManager() {
|
|
|
3000
3040
|
}
|
|
3001
3041
|
// Only settle the flag if it's still empty — a displayBitmap during the await would
|
|
3002
3042
|
// have set new bytes, so leave it dirty for the next pass to send the real image.
|
|
3003
|
-
val jj = imageContainers.indexOfFirst { it
|
|
3043
|
+
val jj = imageContainers.indexOfFirst { it === container }
|
|
3004
3044
|
if (jj >= 0 && imageContainers[jj].bmpData.isEmpty()) {
|
|
3005
3045
|
imageContainers[jj].dirty = false
|
|
3006
3046
|
}
|
|
3007
3047
|
continue
|
|
3008
3048
|
}
|
|
3009
3049
|
sendImageData(container.id, container.name, sentBytes)
|
|
3010
|
-
// Re-find by
|
|
3011
|
-
val j = imageContainers.indexOfFirst { it
|
|
3050
|
+
// Re-find by identity: eviction can reuse this ID during the await.
|
|
3051
|
+
val j = imageContainers.indexOfFirst { it === container }
|
|
3012
3052
|
if (j >= 0 && imageContainers[j].bmpData.contentEquals(sentBytes)) {
|
|
3013
3053
|
imageContainers[j].dirty = false
|
|
3014
3054
|
}
|
|
@@ -3678,8 +3718,17 @@ class G2 : SGCManager() {
|
|
|
3678
3718
|
rightAuthenticated = false
|
|
3679
3719
|
startupPageCreated = false
|
|
3680
3720
|
pageCreated = false
|
|
3681
|
-
|
|
3682
|
-
|
|
3721
|
+
// Keep GATT teardown synchronous for manager replacement, but never
|
|
3722
|
+
// remove containers underneath Main's reconcile/scene iteration.
|
|
3723
|
+
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
3724
|
+
imageContainers.clear()
|
|
3725
|
+
textContainers.clear()
|
|
3726
|
+
} else {
|
|
3727
|
+
mainHandler.post {
|
|
3728
|
+
imageContainers.clear()
|
|
3729
|
+
textContainers.clear()
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3683
3732
|
dashboardShowing = 0
|
|
3684
3733
|
dashboardOpening = false
|
|
3685
3734
|
heartbeatCounter = 0
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.services
|
|
2
|
+
|
|
3
|
+
import com.mentra.bluetoothsdk.utils.DeviceTypes
|
|
4
|
+
import org.junit.Assert.*
|
|
5
|
+
import org.junit.Before
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
import org.junit.runner.RunWith
|
|
8
|
+
import org.robolectric.RobolectricTestRunner
|
|
9
|
+
import org.robolectric.RuntimeEnvironment
|
|
10
|
+
import org.robolectric.annotation.Config
|
|
11
|
+
|
|
12
|
+
@RunWith(RobolectricTestRunner::class)
|
|
13
|
+
@Config(sdk = [28])
|
|
14
|
+
class G2ConnectionRecoveryTest {
|
|
15
|
+
private fun recovery() = G2ConnectionRecovery(RuntimeEnvironment.getApplication())
|
|
16
|
+
private fun settings() = mapOf<String, Any>(
|
|
17
|
+
"default_wearable" to DeviceTypes.G2, "device_name" to "S200-test",
|
|
18
|
+
"brightness" to 75, "auto_brightness" to false,
|
|
19
|
+
"core_token" to "must-not-persist", "micEnabled" to true,
|
|
20
|
+
"searching" to true, "pending_device_name" to "unpaired",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
@Before fun reset() { recovery().clear() }
|
|
24
|
+
|
|
25
|
+
@Test fun `a fresh recovery owner restores identity and settings but not runtime or auth state`() {
|
|
26
|
+
recovery().save(settings())
|
|
27
|
+
val restored = recovery().read()!!
|
|
28
|
+
assertEquals("S200-test", restored["device_name"])
|
|
29
|
+
assertEquals(75, restored["brightness"])
|
|
30
|
+
assertEquals(false, restored["auto_brightness"])
|
|
31
|
+
assertFalse(restored.containsKey("core_token"))
|
|
32
|
+
assertFalse(restored.containsKey("micEnabled"))
|
|
33
|
+
assertFalse(restored.containsKey("searching"))
|
|
34
|
+
assertFalse(restored.containsKey("pending_device_name"))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Test fun `explicit disconnect clears recovery across owner recreation`() {
|
|
38
|
+
recovery().save(settings())
|
|
39
|
+
recovery().clear()
|
|
40
|
+
assertNull(recovery().read())
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@Test fun `forget or switching models cannot resurrect old G2 pairing`() {
|
|
44
|
+
recovery().save(settings())
|
|
45
|
+
recovery().save(settings() + ("device_name" to ""))
|
|
46
|
+
assertNull(recovery().read())
|
|
47
|
+
recovery().save(settings())
|
|
48
|
+
recovery().save(settings() + ("default_wearable" to DeviceTypes.G1))
|
|
49
|
+
assertNull(recovery().read())
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import android.os.Looper
|
|
4
|
+
import android.bluetooth.BluetoothGatt
|
|
5
|
+
import android.bluetooth.BluetoothGattCharacteristic
|
|
6
|
+
import java.util.UUID
|
|
7
|
+
import java.util.concurrent.ExecutorService
|
|
8
|
+
import java.util.concurrent.TimeUnit
|
|
9
|
+
import org.junit.Assert.assertTrue
|
|
10
|
+
import org.robolectric.annotation.Implementation
|
|
11
|
+
import org.robolectric.annotation.Implements
|
|
12
|
+
import org.robolectric.shadow.api.Shadow
|
|
13
|
+
import com.mentra.bluetoothsdk.Bridge
|
|
14
|
+
import org.junit.Assert.assertEquals
|
|
15
|
+
import org.junit.Test
|
|
16
|
+
import org.junit.runner.RunWith
|
|
17
|
+
import org.robolectric.RobolectricTestRunner
|
|
18
|
+
import org.robolectric.RuntimeEnvironment
|
|
19
|
+
import org.robolectric.Shadows.shadowOf
|
|
20
|
+
import org.robolectric.annotation.Config
|
|
21
|
+
import org.robolectric.annotation.LooperMode
|
|
22
|
+
|
|
23
|
+
@RunWith(RobolectricTestRunner::class)
|
|
24
|
+
@Config(sdk = [28], shadows = [RecordingGatt::class])
|
|
25
|
+
@LooperMode(LooperMode.Mode.PAUSED)
|
|
26
|
+
class G2DisplayThreadTest {
|
|
27
|
+
@Test fun `worker clear cannot remove containers under the main-thread reconcile loop`() {
|
|
28
|
+
Bridge.initialize(RuntimeEnvironment.getApplication())
|
|
29
|
+
val g2 = G2()
|
|
30
|
+
val add = G2::class.java.declaredMethods.single { it.name == "addTextContainer" }
|
|
31
|
+
add.isAccessible = true
|
|
32
|
+
add.invoke(g2, 0, 0, 576, 288, "captions", 0, 0, 0, 0)
|
|
33
|
+
add.invoke(g2, 10, 10, 100, 40, "HUD", 0, 0, 0, 0)
|
|
34
|
+
val field = G2::class.java.getDeclaredField("textContainers").apply { isAccessible = true }
|
|
35
|
+
val containers = field.get(g2) as List<*>
|
|
36
|
+
// This is the heartbeat's captured index range at the crash site.
|
|
37
|
+
val indices = containers.indices
|
|
38
|
+
val worker = Thread { g2.clearDisplay() }
|
|
39
|
+
worker.start()
|
|
40
|
+
worker.join()
|
|
41
|
+
assertEquals(2, containers.size)
|
|
42
|
+
indices.forEach { containers[it] } // crashed here when clear ran on the worker
|
|
43
|
+
shadowOf(Looper.getMainLooper()).idle()
|
|
44
|
+
assertEquals(1, containers.size) // only the positioned HUD husk is purged
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@Test fun `closing positioned captions clears the live page without waiting for new speech`() {
|
|
48
|
+
Bridge.initialize(RuntimeEnvironment.getApplication())
|
|
49
|
+
val g2 = G2()
|
|
50
|
+
val add = G2::class.java.declaredMethods.single { it.name == "addTextContainer" }
|
|
51
|
+
add.isAccessible = true
|
|
52
|
+
add.invoke(g2, 10, 10, 500, 100, "old captions", 0, 0, 0, 0)
|
|
53
|
+
val page = G2::class.java.getDeclaredField("pageCreated").apply { isAccessible = true }
|
|
54
|
+
page.setBoolean(g2, true)
|
|
55
|
+
|
|
56
|
+
g2.clearDisplay()
|
|
57
|
+
shadowOf(Looper.getMainLooper()).idle()
|
|
58
|
+
|
|
59
|
+
val field = G2::class.java.getDeclaredField("textContainers").apply { isAccessible = true }
|
|
60
|
+
assertEquals(0, (field.get(g2) as List<*>).size)
|
|
61
|
+
// No shutdown or deferred recreation: the replacement page is already live,
|
|
62
|
+
// even if the user remains silent and no new caption frame ever arrives.
|
|
63
|
+
assertEquals(true, page.getBoolean(g2))
|
|
64
|
+
g2.clearDisplay()
|
|
65
|
+
shadowOf(Looper.getMainLooper()).idle()
|
|
66
|
+
assertEquals(true, page.getBoolean(g2))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@Test fun `clear transmits an erase before dropping the old caption container and retries busy writes`() {
|
|
71
|
+
Bridge.initialize(RuntimeEnvironment.getApplication())
|
|
72
|
+
val g2 = G2()
|
|
73
|
+
val gatt = Shadow.newInstanceOf(BluetoothGatt::class.java)
|
|
74
|
+
val recorder = Shadow.extract<RecordingGatt>(gatt)
|
|
75
|
+
recorder.rejectWrites = 1
|
|
76
|
+
G2::class.java.getDeclaredField("rightGatt").apply { isAccessible = true }.set(g2, gatt)
|
|
77
|
+
G2::class.java.getDeclaredField("rightWriteChar").apply { isAccessible = true }.set(
|
|
78
|
+
g2, BluetoothGattCharacteristic(UUID.randomUUID(), 4, 16)
|
|
79
|
+
)
|
|
80
|
+
val add = G2::class.java.declaredMethods.single { it.name == "addTextContainer" }
|
|
81
|
+
add.isAccessible = true
|
|
82
|
+
add.invoke(g2, 10, 10, 500, 100, "old captions", 0, 0, 0, 0)
|
|
83
|
+
G2::class.java.getDeclaredField("pageCreated").apply { isAccessible = true }.setBoolean(g2, true)
|
|
84
|
+
|
|
85
|
+
g2.clearDisplay()
|
|
86
|
+
shadowOf(Looper.getMainLooper()).idle()
|
|
87
|
+
val executor = G2::class.java.getDeclaredField("bleWriteExecutor").apply { isAccessible = true }
|
|
88
|
+
.get(g2) as ExecutorService
|
|
89
|
+
executor.submit {}.get(5, TimeUnit.SECONDS)
|
|
90
|
+
|
|
91
|
+
// Transport header is eight bytes; protobuf field 1 is the EvenHub command.
|
|
92
|
+
val commands = recorder.accepted.map { it[9].toInt() }
|
|
93
|
+
assertEquals(5, commands.first()) // UPDATE_TEXT_DATA, before REBUILD_PAGE (7)
|
|
94
|
+
assertTrue(commands.indexOf(7) > 0)
|
|
95
|
+
assertTrue(String(recorder.accepted.first(), Charsets.UTF_8).contains(" ".repeat(12)))
|
|
96
|
+
assertTrue(recorder.attempted[0].contentEquals(recorder.attempted[1]))
|
|
97
|
+
assertEquals(recorder.accepted.size + 1, recorder.attempted.size)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@Implements(BluetoothGatt::class)
|
|
104
|
+
class RecordingGatt {
|
|
105
|
+
var rejectWrites = 0
|
|
106
|
+
val attempted = mutableListOf<ByteArray>()
|
|
107
|
+
val accepted = mutableListOf<ByteArray>()
|
|
108
|
+
|
|
109
|
+
@Implementation
|
|
110
|
+
fun writeCharacteristic(characteristic: BluetoothGattCharacteristic): Boolean {
|
|
111
|
+
val packet = characteristic.value.copyOf()
|
|
112
|
+
attempted.add(packet)
|
|
113
|
+
if (rejectWrites > 0) {
|
|
114
|
+
rejectWrites--
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
accepted.add(packet)
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"familyBaseVersion": "3.3.0",
|
|
4
|
-
"releaseIdentity": "3.3.0-dev.
|
|
5
|
-
"releaseSetId": "mentra-3.3.0-dev.
|
|
6
|
-
"sourceCommit": "
|
|
7
|
-
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.
|
|
8
|
-
"otaManifestSha256": "
|
|
4
|
+
"releaseIdentity": "3.3.0-dev.338",
|
|
5
|
+
"releaseSetId": "mentra-3.3.0-dev.338",
|
|
6
|
+
"sourceCommit": "27dcd64ac422939659d3dedd8b4f9041cc5ed77b",
|
|
7
|
+
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.338.json",
|
|
8
|
+
"otaManifestSha256": "bdb8f8a677f95e32babb498efcfe08d3022ce8727e16e57e1cedff7ac8542314"
|
|
9
9
|
});
|
|
10
10
|
//# sourceMappingURL=releaseMetadata.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.3.0\",\n \"releaseIdentity\": \"3.3.0-dev.
|
|
1
|
+
{"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.3.0\",\n \"releaseIdentity\": \"3.3.0-dev.338\",\n \"releaseSetId\": \"mentra-3.3.0-dev.338\",\n \"sourceCommit\": \"27dcd64ac422939659d3dedd8b4f9041cc5ed77b\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.338.json\",\n \"otaManifestSha256\": \"bdb8f8a677f95e32babb498efcfe08d3022ce8727e16e57e1cedff7ac8542314\"\n})\n"]}
|
|
@@ -16,7 +16,7 @@ enum BluetoothSdkDefaults {
|
|
|
16
16
|
/// silently disable it on every pair the app touches.
|
|
17
17
|
static let autoPowerOffEnabled = false
|
|
18
18
|
private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
|
|
19
|
-
private static let swiftPackageSdkVersion = "3.3.0-dev.
|
|
19
|
+
private static let swiftPackageSdkVersion = "3.3.0-dev.338"
|
|
20
20
|
private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
|
|
21
21
|
|
|
22
22
|
private static func normalizedSdkVersion(_ value: String?) -> String? {
|
|
@@ -3,9 +3,9 @@ import Foundation
|
|
|
3
3
|
/// Generated by release CI. Do not edit in a release checkout.
|
|
4
4
|
enum GeneratedReleaseMetadata {
|
|
5
5
|
static let familyBaseVersion = "3.3.0"
|
|
6
|
-
static let releaseIdentity = "3.3.0-dev.
|
|
7
|
-
static let releaseSetId = "mentra-3.3.0-dev.
|
|
8
|
-
static let sourceCommit = "
|
|
9
|
-
static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.
|
|
10
|
-
static let otaManifestSha256 = "
|
|
6
|
+
static let releaseIdentity = "3.3.0-dev.338"
|
|
7
|
+
static let releaseSetId = "mentra-3.3.0-dev.338"
|
|
8
|
+
static let sourceCommit = "27dcd64ac422939659d3dedd8b4f9041cc5ed77b"
|
|
9
|
+
static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.338.json"
|
|
10
|
+
static let otaManifestSha256 = "bdb8f8a677f95e32babb498efcfe08d3022ce8727e16e57e1cedff7ac8542314"
|
|
11
11
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/bluetooth-sdk",
|
|
3
|
-
"version": "3.3.0-dev.
|
|
3
|
+
"version": "3.3.0-dev.338",
|
|
4
4
|
"description": "SDK for communicating with smart glasses",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"react-native": "src/index.ts",
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"registry": "https://registry.npmjs.org/"
|
|
115
115
|
},
|
|
116
116
|
"dependencies": {
|
|
117
|
-
"@mentra/cloud-protocol": "3.3.0-dev.
|
|
117
|
+
"@mentra/cloud-protocol": "3.3.0-dev.338"
|
|
118
118
|
},
|
|
119
119
|
"devDependencies": {
|
|
120
120
|
"@types/node": "^25.9.3",
|
|
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
|
|
|
12
12
|
export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
|
|
13
13
|
"schemaVersion": 1,
|
|
14
14
|
"familyBaseVersion": "3.3.0",
|
|
15
|
-
"releaseIdentity": "3.3.0-dev.
|
|
16
|
-
"releaseSetId": "mentra-3.3.0-dev.
|
|
17
|
-
"sourceCommit": "
|
|
18
|
-
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.
|
|
19
|
-
"otaManifestSha256": "
|
|
15
|
+
"releaseIdentity": "3.3.0-dev.338",
|
|
16
|
+
"releaseSetId": "mentra-3.3.0-dev.338",
|
|
17
|
+
"sourceCommit": "27dcd64ac422939659d3dedd8b4f9041cc5ed77b",
|
|
18
|
+
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.338.json",
|
|
19
|
+
"otaManifestSha256": "bdb8f8a677f95e32babb498efcfe08d3022ce8727e16e57e1cedff7ac8542314"
|
|
20
20
|
})
|