@mentra/bluetooth-sdk 3.2.1-beta.319 → 3.2.1-beta.355
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 +1 -1
- package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
- package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +67 -12
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
- 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/MentraBluetoothSdk.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
- 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 +64 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +73 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +257 -395
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +225 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +168 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
- package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +557 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
- 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/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +308 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +270 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +194 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +199 -0
- package/build/BluetoothSdk.types.d.ts +11 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +2 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +3 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- 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/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +5 -0
- package/ios/MentraBluetoothSDK.podspec +3 -2
- package/ios/Source/BluetoothSdkDefaults.swift +6 -1
- package/ios/Source/Bridge.swift +15 -2
- package/ios/Source/DeviceManager.swift +23 -10
- package/ios/Source/DeviceStore.swift +26 -7
- package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +4 -0
- package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
- package/ios/Source/sgcs/G2.swift +2 -2
- package/ios/Source/sgcs/MentraLive.swift +66 -3
- package/ios/Source/sgcs/Nimo.swift +182 -371
- package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +219 -0
- package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
- package/ios/Source/sgcs/NimoCanvasSession.swift +150 -0
- package/ios/Source/sgcs/SGCManager.swift +24 -0
- package/ios/Source/utils/G2Text.swift +11 -0
- package/ios/Tests/DashboardRenderingTests.swift +31 -0
- package/ios/Tests/G2TextTests.swift +10 -0
- package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
- package/ios/Tests/NimoCanvasSessionTests.swift +112 -0
- package/ios/Tests/NimoCanvasTransportTests.swift +161 -0
- package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +11 -0
- package/src/_private/BluetoothSdkModule.ts +6 -0
- package/src/generated/changelogCatalog.ts +1 -1
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +1 -0
|
@@ -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
|
|
@@ -45,12 +46,15 @@ import java.util.concurrent.Executors
|
|
|
45
46
|
import java.util.concurrent.TimeUnit
|
|
46
47
|
import kotlin.jvm.JvmStatic
|
|
47
48
|
|
|
48
|
-
class DeviceManager {
|
|
49
|
+
class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
50
|
+
constructor() : this(initializeHardware = true)
|
|
49
51
|
companion object {
|
|
50
52
|
|
|
51
53
|
@Volatile
|
|
52
54
|
private var _instance: DeviceManager? = null
|
|
53
55
|
|
|
56
|
+
internal fun isInitialized(): Boolean = _instance != null
|
|
57
|
+
|
|
54
58
|
@JvmStatic
|
|
55
59
|
fun getInstance(): DeviceManager {
|
|
56
60
|
return _instance
|
|
@@ -59,6 +63,10 @@ class DeviceManager {
|
|
|
59
63
|
}
|
|
60
64
|
|
|
61
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
|
|
62
70
|
private var serviceStarted = false
|
|
63
71
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
64
72
|
private val executor: ExecutorService = Executors.newSingleThreadExecutor()
|
|
@@ -79,7 +87,8 @@ class DeviceManager {
|
|
|
79
87
|
// MARK: - End Unique
|
|
80
88
|
|
|
81
89
|
// MARK: - Properties
|
|
82
|
-
|
|
90
|
+
// Read by the welcome executor as well as device lifecycle handlers.
|
|
91
|
+
@Volatile var sgc: SGCManager? = null
|
|
83
92
|
var controller: ControllerManager? = null
|
|
84
93
|
|
|
85
94
|
// settings:
|
|
@@ -283,7 +292,22 @@ class DeviceManager {
|
|
|
283
292
|
|
|
284
293
|
init {
|
|
285
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
|
+
}
|
|
286
304
|
initializeViewStates()
|
|
305
|
+
if (initializeHardware) initializeHardwareServices()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Keep host routing usable without starting audio codecs, receivers or services.
|
|
309
|
+
// The public constructor always initializes the hardware environment.
|
|
310
|
+
private fun initializeHardwareServices() {
|
|
287
311
|
startForegroundService()
|
|
288
312
|
// setupPermissionMonitoring()
|
|
289
313
|
setupBluetoothStateMonitoring()
|
|
@@ -1497,6 +1521,10 @@ class DeviceManager {
|
|
|
1497
1521
|
Bridge.log("MAN: handleDeviceReady() ${sgc?.type}")
|
|
1498
1522
|
resetMicHealth()
|
|
1499
1523
|
defaultWearable = sgc?.type ?: ""
|
|
1524
|
+
synchronized(recoveryLock) {
|
|
1525
|
+
recoverG2Connection = defaultWearable == DeviceTypes.G2
|
|
1526
|
+
connectionRecovery.save(DeviceStore.store.getCategory("bluetooth"))
|
|
1527
|
+
}
|
|
1500
1528
|
searching = false
|
|
1501
1529
|
|
|
1502
1530
|
syncSystemTimeOnceForConnection(readyKey)
|
|
@@ -1518,13 +1546,18 @@ class DeviceManager {
|
|
|
1518
1546
|
2000
|
|
1519
1547
|
)
|
|
1520
1548
|
|
|
1521
|
-
//
|
|
1549
|
+
// A full-canvas device can opt out so this temporary message does not
|
|
1550
|
+
// erase the host's connected-edge scene replay.
|
|
1522
1551
|
if (shouldSendBootingMessage) {
|
|
1523
1552
|
shouldSendBootingMessage = false
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1553
|
+
val device = sgc
|
|
1554
|
+
if (device?.showConnectionConfirmation == true) {
|
|
1555
|
+
executor.execute {
|
|
1556
|
+
if (sgc !== device) return@execute
|
|
1557
|
+
device.sendTextWall("// MentraOS Connected")
|
|
1558
|
+
Thread.sleep(3000)
|
|
1559
|
+
if (sgc === device) device.clearDisplay()
|
|
1560
|
+
}
|
|
1528
1561
|
}
|
|
1529
1562
|
}
|
|
1530
1563
|
|
|
@@ -1677,7 +1710,7 @@ class DeviceManager {
|
|
|
1677
1710
|
dashboardSceneCleanupPending = false
|
|
1678
1711
|
val elementIds = pendingDashboardSceneElementIds.toList()
|
|
1679
1712
|
pendingDashboardSceneElementIds.clear()
|
|
1680
|
-
if (elementIds.isNotEmpty()) {
|
|
1713
|
+
if (elementIds.isNotEmpty() && sgc?.sceneHandoffRequiresClear == true) {
|
|
1681
1714
|
sgc?.clearSceneElements(elementIds)
|
|
1682
1715
|
}
|
|
1683
1716
|
}
|
|
@@ -1714,7 +1747,7 @@ class DeviceManager {
|
|
|
1714
1747
|
sceneStates[stateIndex] = null
|
|
1715
1748
|
if (stateIndex == 1 && dashboardSceneCleanupPending) {
|
|
1716
1749
|
pendingDashboardSceneElementIds.addAll(prevFrame.elements.map { it.id })
|
|
1717
|
-
} else if (layoutType != "clear_view") {
|
|
1750
|
+
} else if (layoutType != "clear_view" && shouldClearSceneHandoff(stateIndex)) {
|
|
1718
1751
|
sgc?.clearSceneElements(prevFrame.elements.map { it.id })
|
|
1719
1752
|
}
|
|
1720
1753
|
}
|
|
@@ -1781,7 +1814,7 @@ class DeviceManager {
|
|
|
1781
1814
|
val prevLegacyType = viewStates[stateIndex].layoutType
|
|
1782
1815
|
val cleanupDeferred = stateIndex == 1 && dashboardSceneCleanupPending
|
|
1783
1816
|
if (
|
|
1784
|
-
!cleanupDeferred &&
|
|
1817
|
+
!cleanupDeferred && shouldClearSceneHandoff(stateIndex) &&
|
|
1785
1818
|
prevLegacyType.isNotEmpty() &&
|
|
1786
1819
|
prevLegacyType != "clear_view" &&
|
|
1787
1820
|
prevLegacyType != "scene"
|
|
@@ -1797,7 +1830,7 @@ class DeviceManager {
|
|
|
1797
1830
|
// blank.
|
|
1798
1831
|
if (stateIndex == 1 && dashboardSceneCleanupPending) {
|
|
1799
1832
|
pendingDashboardSceneElementIds.addAll(prevFrame.elements.map { it.id })
|
|
1800
|
-
} else {
|
|
1833
|
+
} else if (shouldClearSceneHandoff(stateIndex)) {
|
|
1801
1834
|
sgc?.clearSceneElements(prevFrame.elements.map { it.id })
|
|
1802
1835
|
}
|
|
1803
1836
|
frame = frame.copy(replay = true, elements = frame.elements.map { it.copy(change = "created") })
|
|
@@ -1816,6 +1849,15 @@ class DeviceManager {
|
|
|
1816
1849
|
}
|
|
1817
1850
|
}
|
|
1818
1851
|
|
|
1852
|
+
// Hidden-view updates only change their replay slot. They must never clear
|
|
1853
|
+
// the visible view, and full-frame adapters need no separate handoff sweep.
|
|
1854
|
+
private fun shouldClearSceneHandoff(stateIndex: Int): Boolean {
|
|
1855
|
+
val visibleIndex = if (headUp && contextualDashboard) 1 else 0
|
|
1856
|
+
val device = sgc ?: return false
|
|
1857
|
+
return stateIndex == visibleIndex && !screenDisabled && device.fullyBooted &&
|
|
1858
|
+
!device.type.contains(DeviceTypes.SIMULATED) && device.sceneHandoffRequiresClear
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1819
1861
|
/** Guarded scene dispatch — mirrors sendCurrentState's send conditions. */
|
|
1820
1862
|
private fun dispatchSceneFrame(stateIndex: Int, frame: SceneFrame) {
|
|
1821
1863
|
if (screenDisabled) return
|
|
@@ -2389,8 +2431,15 @@ class DeviceManager {
|
|
|
2389
2431
|
}
|
|
2390
2432
|
|
|
2391
2433
|
fun disconnect(clearPendingIdentity: Boolean = true) {
|
|
2434
|
+
synchronized(recoveryLock) {
|
|
2435
|
+
recoverG2Connection = false
|
|
2436
|
+
connectionRecovery.clear()
|
|
2437
|
+
}
|
|
2392
2438
|
sgc?.clearDisplay()
|
|
2393
|
-
|
|
2439
|
+
// NIMO owns a background canvas encoder and this path discards its instance.
|
|
2440
|
+
// A link-level reconnect keeps the instance (and encoder) through disconnect().
|
|
2441
|
+
val device = sgc
|
|
2442
|
+
if (device is Nimo) device.cleanup() else device?.disconnect()
|
|
2394
2443
|
sgc = null // Clear the SGC reference after disconnect
|
|
2395
2444
|
resetSystemTimeSync()
|
|
2396
2445
|
resetMicHealth()
|
|
@@ -2440,6 +2489,10 @@ class DeviceManager {
|
|
|
2440
2489
|
}
|
|
2441
2490
|
|
|
2442
2491
|
fun forget() {
|
|
2492
|
+
synchronized(recoveryLock) {
|
|
2493
|
+
recoverG2Connection = false
|
|
2494
|
+
connectionRecovery.clear()
|
|
2495
|
+
}
|
|
2443
2496
|
Bridge.log("MAN: Forgetting smart glasses")
|
|
2444
2497
|
|
|
2445
2498
|
val live = sgc as? MentraLive
|
|
@@ -2554,6 +2607,8 @@ class DeviceManager {
|
|
|
2554
2607
|
|
|
2555
2608
|
// MARK: Cleanup
|
|
2556
2609
|
fun cleanup() {
|
|
2610
|
+
recoveryListenerId?.let { DeviceStore.store.removeListener(it) }
|
|
2611
|
+
recoveryListenerId = null
|
|
2557
2612
|
stopBluetoothStateMonitoring()
|
|
2558
2613
|
|
|
2559
2614
|
micReinitRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
@@ -142,6 +142,7 @@ object DeviceStore {
|
|
|
142
142
|
store.set("bluetooth", "gallery_mode", true)
|
|
143
143
|
store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
|
|
144
144
|
store.set("bluetooth", "loudness_gate_enabled", BluetoothSdkDefaults.LOUDNESS_GATE_ENABLED)
|
|
145
|
+
store.set("bluetooth", "auto_power_off_enabled", BluetoothSdkDefaults.AUTO_POWER_OFF_ENABLED)
|
|
145
146
|
store.set("bluetooth", "screen_disabled", false)
|
|
146
147
|
store.set("bluetooth", "button_photo_size", "max")
|
|
147
148
|
store.set("bluetooth", "button_max_recording_time", 10)
|
|
@@ -219,14 +220,30 @@ object DeviceStore {
|
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
// BLUETOOTH:
|
|
223
|
+
"bluetooth" to "contextual_dashboard" -> {
|
|
224
|
+
if (value is Boolean) {
|
|
225
|
+
CoroutineScope(Dispatchers.Main.immediate).launch {
|
|
226
|
+
// The setting changes the selected view even without a new head event.
|
|
227
|
+
// Re-read state on Main and retain the manager's readiness/display guards.
|
|
228
|
+
if (store.get("glasses", "headUp") == true) {
|
|
229
|
+
DeviceManager.getInstance().sendCurrentState()
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
222
234
|
"bluetooth" to "brightness" -> {
|
|
223
235
|
val b = (value as? Number)?.toInt() ?: 50
|
|
224
236
|
val auto = (store.get("bluetooth", "auto_brightness") as? Boolean) ?: true
|
|
225
237
|
CoroutineScope(Dispatchers.Main).launch {
|
|
226
|
-
DeviceManager.getInstance().sgc
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
238
|
+
val device = DeviceManager.getInstance().sgc ?: return@launch
|
|
239
|
+
device.setBrightness(b, auto)
|
|
240
|
+
if (device.showBrightnessConfirmation) {
|
|
241
|
+
device.sendTextWall("Set brightness to $b%")
|
|
242
|
+
delay(800) // 0.8 seconds
|
|
243
|
+
if (DeviceManager.getInstance().sgc === device) {
|
|
244
|
+
device.clearDisplay()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
230
247
|
}
|
|
231
248
|
}
|
|
232
249
|
"bluetooth" to "auto_brightness" -> {
|
|
@@ -234,16 +251,17 @@ object DeviceStore {
|
|
|
234
251
|
val auto = (value as? Boolean) ?: true
|
|
235
252
|
val autoBrightnessChanged = (oldValue as? Boolean) != auto
|
|
236
253
|
CoroutineScope(Dispatchers.Main).launch {
|
|
237
|
-
DeviceManager.getInstance().sgc
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
)
|
|
254
|
+
val device = DeviceManager.getInstance().sgc ?: return@launch
|
|
255
|
+
device.setBrightness(b, auto)
|
|
256
|
+
if (autoBrightnessChanged && device.showBrightnessConfirmation) {
|
|
257
|
+
device.sendTextWall(
|
|
258
|
+
if (auto) "Enabled auto brightness"
|
|
259
|
+
else "Disabled auto brightness"
|
|
260
|
+
)
|
|
245
261
|
delay(800) // 0.8 seconds
|
|
246
|
-
DeviceManager.getInstance().sgc
|
|
262
|
+
if (DeviceManager.getInstance().sgc === device) {
|
|
263
|
+
device.clearDisplay()
|
|
264
|
+
}
|
|
247
265
|
}
|
|
248
266
|
}
|
|
249
267
|
}
|
|
@@ -291,6 +309,12 @@ object DeviceStore {
|
|
|
291
309
|
"bluetooth" to "loudness_gate_enabled" -> {
|
|
292
310
|
DeviceManager.getInstance().sgc?.sendLoudnessGateSetting()
|
|
293
311
|
}
|
|
312
|
+
"bluetooth" to "auto_power_off_enabled" -> {
|
|
313
|
+
Bridge.log(
|
|
314
|
+
"DeviceStore: auto_power_off_enabled changed to $value — sending cs_swit type 11"
|
|
315
|
+
)
|
|
316
|
+
DeviceManager.getInstance().sgc?.sendAutoPowerOffSetting()
|
|
317
|
+
}
|
|
294
318
|
// Deliberately has no seeded default above: the key starts absent so
|
|
295
319
|
// that a session where the engine has not authorized tuning can only
|
|
296
320
|
// ever send a reset.
|
|
@@ -3,7 +3,7 @@ package com.mentra.bluetoothsdk
|
|
|
3
3
|
/** Generated from /changelogs. Do not edit directly. */
|
|
4
4
|
internal val GENERATED_RELEASE_CHANGELOGS: List<ReleaseChangelog> =
|
|
5
5
|
listOf(
|
|
6
|
-
ReleaseChangelog(version = "3.2.1", markdown = "This release is under active development. User-facing changes will be documented as they land."),
|
|
6
|
+
ReleaseChangelog(version = "3.2.1", markdown = "This release is under active development. User-facing changes will be documented as they land.\n\n- Mentra Miniapp SDK: `session.stream.preview({source: \"call\"})` and the `<StreamPreview>` component (from `@mentra/miniapp/react`) show the live video of a call your miniapp owns inside its UI. Requires the `CAMERA` permission; the lease survives the UI closing and ends with `handle.stop()` or the meeting. See [Stream preview](https://docs.mentraglass.com/app-devs/core-concepts/stream-preview)."),
|
|
7
7
|
ReleaseChangelog(version = "3.2.0", markdown = "This release is under active development. User-facing changes will be documented as they land.\n\n- Breaking Bluetooth SDK change: `forgetWifiNetwork(ssid)` now returns `WifiForgetResult` instead of `WifiStatusChangeEvent`. Migrate status-only consumers to the semantic `outcome`; the requested network remains `ssid`, while optional post-command connectivity is reported by `connected`, `currentSsid`, and `localIp`."),
|
|
8
8
|
ReleaseChangelog(version = "3.1.1", markdown = "- Improved Google login on iOS.\n- Pairing no longer requires phone microphone access."),
|
|
9
9
|
ReleaseChangelog(version = "3.1.0", markdown = "**Mentra App and glasses**\n\n- Improved glasses pairing and reconnection, including on iPad.\n- Improved Android background reliability for captions and miniapps.\n- Added Xingyi AR99 support.\n- More reliable photo and video capture, with improved shutter sounds and privacy-light timing. Taking photos no longer interrupts your running miniapp.\n- Added video thumbnails and smoother gallery browsing.\n- Easier Mentra Live updates through your phone, with clearer progress and better recovery from interruptions.\n\n**Miniapp updates**\n\n- **Mentra AI:** Control whether photos are included with questions. Improved visual answers and connected-app follow-ups.\n- **Captions:** Added offline transcription settings and top/bottom positioning on supported displays.\n- **Translation:** Restored all 60 languages.\n- **Mentra Maps:** Clearer directions on G1/G2, less display flickering, and improved Android offline routing.\n- **Livestreamer:** More video-quality controls and custom streaming options, plus improved startup and sharing.\n- **Merge:** Tap to expand an insight; swipe down or back to dismiss it.\n- **Mentra Notes:** Smoother back navigation.\n- **Recorder:** Fixed audio sharing."),
|
|
@@ -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.2.1"
|
|
6
|
-
const val RELEASE_IDENTITY: String = "3.2.1-beta.
|
|
7
|
-
const val RELEASE_SET_ID: String = "mentra-3.2.1-beta.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
9
|
-
const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.
|
|
10
|
-
const val OTA_MANIFEST_SHA256: String = "
|
|
6
|
+
const val RELEASE_IDENTITY: String = "3.2.1-beta.355"
|
|
7
|
+
const val RELEASE_SET_ID: String = "mentra-3.2.1-beta.355"
|
|
8
|
+
const val SOURCE_COMMIT: String = "e8566f5d4cab141cf4d1f93009527b5b136805c8"
|
|
9
|
+
const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.355.json"
|
|
10
|
+
const val OTA_MANIFEST_SHA256: String = "1a21430c982331457a73d65285d0b08f5a93b2a64d2ea004b8331ee9645a3453"
|
|
11
11
|
}
|
|
@@ -651,6 +651,10 @@ class MentraBluetoothSdk private constructor(
|
|
|
651
651
|
DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "loudness_gate_enabled", enabled)
|
|
652
652
|
}
|
|
653
653
|
|
|
654
|
+
fun setAutoPowerOffEnabled(enabled: Boolean) {
|
|
655
|
+
DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "auto_power_off_enabled", enabled)
|
|
656
|
+
}
|
|
657
|
+
|
|
654
658
|
@Deprecated(
|
|
655
659
|
message =
|
|
656
660
|
"Sticky action-button photo presets are deprecated. Prefer per-request " +
|
|
@@ -6,13 +6,14 @@ internal sealed interface VersionInfoAccumulatorOutcome {
|
|
|
6
6
|
data class Complete(val result: VersionInfoResult) : VersionInfoAccumulatorOutcome
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/** One request, explicit modern completion, or the
|
|
9
|
+
/** One request, explicit modern completion, or the legacy firmware's terminal chunk. */
|
|
10
10
|
internal class VersionInfoResponseAccumulator(private val expectedRequestId: String) {
|
|
11
11
|
private val values = mutableMapOf<String, Any>()
|
|
12
12
|
private val chunks = mutableMapOf<Int, Map<String, Any>>()
|
|
13
13
|
private var count: Int? = null
|
|
14
14
|
private var sid: String? = null
|
|
15
15
|
private var legacyStarted = false
|
|
16
|
+
private var legacyTwoChunkResponse = false
|
|
16
17
|
private var completed = false
|
|
17
18
|
|
|
18
19
|
fun accept(event: Map<String, Any>): VersionInfoAccumulatorOutcome {
|
|
@@ -41,10 +42,20 @@ internal class VersionInfoResponseAccumulator(private val expectedRequestId: Str
|
|
|
41
42
|
if (count != null) return VersionInfoAccumulatorOutcome.Ignored
|
|
42
43
|
when (event[RESPONSE_CHUNK_KEY] as? String ?: "version_info") {
|
|
43
44
|
"version_info" -> { values.clear(); merge(event); return finish() }
|
|
44
|
-
"version_info_1" -> {
|
|
45
|
+
"version_info_1" -> {
|
|
46
|
+
values.clear()
|
|
47
|
+
legacyStarted = true
|
|
48
|
+
legacyTwoChunkResponse = event["buildNumber"] == "27"
|
|
49
|
+
merge(event)
|
|
50
|
+
}
|
|
45
51
|
"version_info_2" -> {
|
|
46
52
|
if (!legacyStarted) return VersionInfoAccumulatorOutcome.Ignored
|
|
53
|
+
// Factory ASG27 sends only chunks 1 and 2. Its second chunk carries the
|
|
54
|
+
// OTA URL and legacy firmware version; ASG31+ moves firmware to chunk 3.
|
|
47
55
|
merge(event)
|
|
56
|
+
if (legacyTwoChunkResponse && values["buildNumber"] == "27" &&
|
|
57
|
+
!(event["otaVersionUrl"] as? String).isNullOrBlank()
|
|
58
|
+
) return finish()
|
|
48
59
|
}
|
|
49
60
|
"version_info_3" -> {
|
|
50
61
|
if (!legacyStarted) return VersionInfoAccumulatorOutcome.Ignored
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
package com.mentra.bluetoothsdk.sgcs
|
|
2
2
|
|
|
3
|
+
import com.mentra.bluetoothsdk.utils.G2Text
|
|
3
4
|
import com.mentra.bluetoothsdk.PhotoRequest
|
|
4
5
|
import android.bluetooth.BluetoothAdapter
|
|
5
6
|
import android.bluetooth.BluetoothGatt
|
|
@@ -1664,8 +1665,15 @@ class G2 : SGCManager() {
|
|
|
1664
1665
|
if (char == null || gatt == null) return
|
|
1665
1666
|
char.value = packet
|
|
1666
1667
|
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
|
1667
|
-
|
|
1668
|
-
|
|
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
|
+
}
|
|
1669
1677
|
if (leg != null) {
|
|
1670
1678
|
Bridge.log("G2/FILE: write $leg ${packet.size}B -> ${if (ok) "queued" else "DROPPED (stack busy)"}")
|
|
1671
1679
|
}
|
|
@@ -2190,7 +2198,7 @@ class G2 : SGCManager() {
|
|
|
2190
2198
|
val rBorderColor = borderColor ?: defaultTextBorderColor
|
|
2191
2199
|
val rBorderRadius = borderRadius ?: defaultTextBorderRadius
|
|
2192
2200
|
val rPaddingLength = paddingLength ?: defaultTextPaddingLength
|
|
2193
|
-
val content =
|
|
2201
|
+
val content = G2Text.containerContent(text)
|
|
2194
2202
|
|
|
2195
2203
|
// Pure state mutation: update the container's content and schedule its sends; the reconcile
|
|
2196
2204
|
// loop does the actual updateText writes. Reuse an existing container if the rect matches
|
|
@@ -2257,6 +2265,12 @@ class G2 : SGCManager() {
|
|
|
2257
2265
|
}
|
|
2258
2266
|
|
|
2259
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
|
+
}
|
|
2260
2274
|
Bridge.log("G2: clearDisplay()")
|
|
2261
2275
|
// Clear the text in place — do NOT shut down + rebuild the EvenHub page. Tearing the page
|
|
2262
2276
|
// down (rebuildPage) kills audio streaming AND triggers a firmware systemExit /
|
|
@@ -2266,9 +2280,22 @@ class G2 : SGCManager() {
|
|
|
2266
2280
|
// several seconds (incident 8164175a). Just blank the text + clear images; the reconcile
|
|
2267
2281
|
// loop pushes the blanked text on a live page, and a dead page is only resurrected for
|
|
2268
2282
|
// meaningful (non-blank) content, so a clear can't churn it back up.
|
|
2269
|
-
for (
|
|
2270
|
-
|
|
2271
|
-
|
|
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
|
+
}
|
|
2272
2299
|
}
|
|
2273
2300
|
for (i in imageContainers.indices) {
|
|
2274
2301
|
// The firmware still shows this container's image; emptying bmpData locally never reaches
|
|
@@ -2296,8 +2323,13 @@ class G2 : SGCManager() {
|
|
|
2296
2323
|
textContainers.removeAll { it.id in huskIds }
|
|
2297
2324
|
sceneTextByElement.entries.removeAll { it.value in huskIds }
|
|
2298
2325
|
sceneImageByElement.clear()
|
|
2299
|
-
|
|
2300
|
-
|
|
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()
|
|
2301
2333
|
}
|
|
2302
2334
|
}
|
|
2303
2335
|
|
|
@@ -2344,6 +2376,15 @@ class G2 : SGCManager() {
|
|
|
2344
2376
|
return false
|
|
2345
2377
|
}
|
|
2346
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 {
|
|
2347
2388
|
// Pure state mutation: update the target container's bytes and mark it dirty. The reconcile
|
|
2348
2389
|
// loop is the sole sender, so two displayBitmap calls can never overlap a sendImageData and
|
|
2349
2390
|
// clobber the single-slot image ACK — no lock needed. Reuse an existing container if the rect
|
|
@@ -2538,7 +2579,7 @@ class G2 : SGCManager() {
|
|
|
2538
2579
|
@Suppress("NAME_SHADOWING")
|
|
2539
2580
|
val height = minOf(maxOf(height, minTextContainerHeight), 288 - y)
|
|
2540
2581
|
run {
|
|
2541
|
-
val content =
|
|
2582
|
+
val content = G2Text.containerContent(text)
|
|
2542
2583
|
val existingId = sceneTextByElement[elementId]
|
|
2543
2584
|
if (existingId != null) {
|
|
2544
2585
|
val idx = textContainers.indexOfFirst { it.id == existingId }
|
|
@@ -2999,15 +3040,15 @@ class G2 : SGCManager() {
|
|
|
2999
3040
|
}
|
|
3000
3041
|
// Only settle the flag if it's still empty — a displayBitmap during the await would
|
|
3001
3042
|
// have set new bytes, so leave it dirty for the next pass to send the real image.
|
|
3002
|
-
val jj = imageContainers.indexOfFirst { it
|
|
3043
|
+
val jj = imageContainers.indexOfFirst { it === container }
|
|
3003
3044
|
if (jj >= 0 && imageContainers[jj].bmpData.isEmpty()) {
|
|
3004
3045
|
imageContainers[jj].dirty = false
|
|
3005
3046
|
}
|
|
3006
3047
|
continue
|
|
3007
3048
|
}
|
|
3008
3049
|
sendImageData(container.id, container.name, sentBytes)
|
|
3009
|
-
// Re-find by
|
|
3010
|
-
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 }
|
|
3011
3052
|
if (j >= 0 && imageContainers[j].bmpData.contentEquals(sentBytes)) {
|
|
3012
3053
|
imageContainers[j].dirty = false
|
|
3013
3054
|
}
|
|
@@ -3677,8 +3718,17 @@ class G2 : SGCManager() {
|
|
|
3677
3718
|
rightAuthenticated = false
|
|
3678
3719
|
startupPageCreated = false
|
|
3679
3720
|
pageCreated = false
|
|
3680
|
-
|
|
3681
|
-
|
|
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
|
+
}
|
|
3682
3732
|
dashboardShowing = 0
|
|
3683
3733
|
dashboardOpening = false
|
|
3684
3734
|
heartbeatCounter = 0
|