@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.
@@ -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.333"
7
- const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.333"
8
- const val SOURCE_COMMIT: String = "018df9fcb3483fd63d11050d51fb76509d8c2a68"
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.333.json"
10
- const val OTA_MANIFEST_SHA256: String = "870846e4db7cb05389372b52d813902c5805022dddd21ed9bd7a0a86131361c3"
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
- val ok = gatt.writeCharacteristic(char)
1669
- bgcapNoteWriteResult(ok) // BGCAP
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 (i in textContainers.indices) {
2271
- textContainers[i].content = "\n"
2272
- textContainers[i].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
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
- Bridge.log("G2: clearDisplay() — purging ${huskIds.size} positioned husk container(s), one rebuild")
2301
- displayScope.launch { coalescedPageRebuild() }
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.id == container.id }
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 id: the list may have shifted (eviction) during the await.
3011
- val j = imageContainers.indexOfFirst { it.id == container.id }
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
- imageContainers.clear()
3682
- textContainers.clear()
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.333",
5
- "releaseSetId": "mentra-3.3.0-dev.333",
6
- "sourceCommit": "018df9fcb3483fd63d11050d51fb76509d8c2a68",
7
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.333.json",
8
- "otaManifestSha256": "870846e4db7cb05389372b52d813902c5805022dddd21ed9bd7a0a86131361c3"
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.333\",\n \"releaseSetId\": \"mentra-3.3.0-dev.333\",\n \"sourceCommit\": \"018df9fcb3483fd63d11050d51fb76509d8c2a68\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.333.json\",\n \"otaManifestSha256\": \"870846e4db7cb05389372b52d813902c5805022dddd21ed9bd7a0a86131361c3\"\n})\n"]}
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.333"
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.333"
7
- static let releaseSetId = "mentra-3.3.0-dev.333"
8
- static let sourceCommit = "018df9fcb3483fd63d11050d51fb76509d8c2a68"
9
- static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.333.json"
10
- static let otaManifestSha256 = "870846e4db7cb05389372b52d813902c5805022dddd21ed9bd7a0a86131361c3"
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.333",
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.333"
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.333",
16
- "releaseSetId": "mentra-3.3.0-dev.333",
17
- "sourceCommit": "018df9fcb3483fd63d11050d51fb76509d8c2a68",
18
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.333.json",
19
- "otaManifestSha256": "870846e4db7cb05389372b52d813902c5805022dddd21ed9bd7a0a86131361c3"
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
  })