@mentra/bluetooth-sdk 3.2.1-dev.314 → 3.3.0-beta.942

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.
Files changed (191) hide show
  1. package/README.md +72 -10
  2. package/android/build.gradle +15 -0
  3. package/android/lc3Lib/build.gradle +6 -1
  4. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  5. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +4 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +8 -2
  11. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +133 -26
  15. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  16. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +3 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  18. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +227 -36
  19. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  22. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  24. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  31. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
  32. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +194 -40
  33. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  34. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  35. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +247 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  37. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  42. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  43. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  45. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  48. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  50. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  51. package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
  52. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
  53. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  54. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  55. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  56. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
  57. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  58. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
  59. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
  62. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  63. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  64. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
  65. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  66. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  67. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  68. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  69. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +233 -0
  70. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  71. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  72. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  73. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  74. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  75. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  76. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  77. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  78. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  79. package/build/BluetoothSdk.types.d.ts +16 -0
  80. package/build/BluetoothSdk.types.d.ts.map +1 -1
  81. package/build/BluetoothSdk.types.js +1 -0
  82. package/build/BluetoothSdk.types.js.map +1 -1
  83. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  84. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  85. package/build/_private/BluetoothSdkModule.js +5 -1
  86. package/build/_private/BluetoothSdkModule.js.map +1 -1
  87. package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
  88. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  89. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  90. package/build/_private/nativeLogConsole.d.ts +19 -0
  91. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  92. package/build/_private/nativeLogConsole.js +12 -0
  93. package/build/_private/nativeLogConsole.js.map +1 -1
  94. package/build/generated/changelogCatalog.d.ts +5 -2
  95. package/build/generated/changelogCatalog.d.ts.map +1 -1
  96. package/build/generated/changelogCatalog.js +6 -2
  97. package/build/generated/changelogCatalog.js.map +1 -1
  98. package/build/generated/releaseMetadata.js +6 -6
  99. package/build/generated/releaseMetadata.js.map +1 -1
  100. package/build/index.d.ts.map +1 -1
  101. package/build/index.js +1 -0
  102. package/build/index.js.map +1 -1
  103. package/build/ota-transport/index.d.ts +4 -0
  104. package/build/ota-transport/index.d.ts.map +1 -1
  105. package/build/ota-transport/index.js +2 -0
  106. package/build/ota-transport/index.js.map +1 -1
  107. package/ios/BluetoothSdkModule.swift +17 -4
  108. package/ios/MentraBluetoothSDK.podspec +3 -2
  109. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  110. package/ios/Source/BluetoothSdkDefaults.swift +7 -2
  111. package/ios/Source/Bridge.swift +15 -2
  112. package/ios/Source/DeviceManager.swift +102 -66
  113. package/ios/Source/DeviceStore.swift +26 -7
  114. package/ios/Source/GeneratedChangelogCatalog.swift +3 -2
  115. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  116. package/ios/Source/MentraBluetoothSDK.swift +313 -54
  117. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  118. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  119. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  120. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  121. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  122. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  123. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  124. package/ios/Source/requests/DisplayRequests.swift +1 -1
  125. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  126. package/ios/Source/sgcs/Ar99.swift +1 -2
  127. package/ios/Source/sgcs/G1.swift +68 -38
  128. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  129. package/ios/Source/sgcs/G2.swift +261 -247
  130. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  131. package/ios/Source/sgcs/Mach1.swift +19 -7
  132. package/ios/Source/sgcs/MentraLive.swift +272 -197
  133. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  134. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  135. package/ios/Source/sgcs/MentraNex.swift +8 -31
  136. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  137. package/ios/Source/sgcs/Nimo.swift +280 -408
  138. package/ios/Source/sgcs/NimoCanvasCodec.swift +272 -0
  139. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  140. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  141. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  142. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  143. package/ios/Source/sgcs/SGCManager.swift +25 -1
  144. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  145. package/ios/Source/status/DeviceStatus.swift +1 -0
  146. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  147. package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
  148. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  149. package/ios/Source/utils/G1Text.swift +2 -24
  150. package/ios/Source/utils/G1TextSanitizer.swift +27 -0
  151. package/ios/Source/utils/G2Text.swift +11 -0
  152. package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
  153. package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
  154. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  155. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  156. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  157. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  158. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  159. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  160. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  161. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  162. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  163. package/ios/Tests/G1PairingTests.swift +162 -0
  164. package/ios/Tests/G1TextSanitizerTests.swift +32 -0
  165. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  166. package/ios/Tests/G2TextTests.swift +10 -0
  167. package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
  168. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  169. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  170. package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
  171. package/ios/Tests/NimoCanvasCodecTests.swift +217 -0
  172. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  173. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  174. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  175. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  176. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  177. package/ios/Tests/VersionInfoCommandTests.swift +194 -0
  178. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  179. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  180. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  181. package/package.json +2 -2
  182. package/plugin/build/withIos.d.ts +2 -0
  183. package/plugin/build/withIos.js +12 -1
  184. package/src/BluetoothSdk.types.ts +15 -0
  185. package/src/_private/BluetoothSdkModule.ts +10 -1
  186. package/src/_private/MentraLocalNetworkModule.ts +28 -0
  187. package/src/_private/nativeLogConsole.ts +19 -0
  188. package/src/generated/changelogCatalog.ts +6 -2
  189. package/src/generated/releaseMetadata.ts +6 -6
  190. package/src/index.ts +1 -0
  191. package/src/ota-transport/index.ts +10 -1
@@ -29,6 +29,10 @@ internal class ClassicAudioConnectionTracker(
29
29
  val connected: Boolean
30
30
  get() = connectedProfiles.isNotEmpty()
31
31
 
32
+ /** BLE may already be gone when the user explicitly disconnects Classic audio. */
33
+ @Synchronized
34
+ fun targetForDisconnect(): String? = targetAddress
35
+
32
36
  @Synchronized
33
37
  fun setTarget(address: String) {
34
38
  val normalizedAddress = address.normalizedBluetoothAddress()
@@ -0,0 +1,70 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothDevice
4
+ import android.os.Handler
5
+ import android.os.ParcelUuid
6
+
7
+ /** One bounded SDP discovery; a BLE bond alone does not populate Android's Classic UUID cache. */
8
+ internal class ClassicAudioServiceDiscovery(
9
+ private val handler: Handler,
10
+ private val timeoutMs: Long,
11
+ private val log: (String) -> Unit,
12
+ ) {
13
+ private class Pending(val device: BluetoothDevice, val ready: () -> Unit) {
14
+ lateinit var timeout: Runnable
15
+ }
16
+ private var pending: Pending? = null
17
+
18
+ fun connectWhenDiscovered(device: BluetoothDevice, ready: () -> Unit) {
19
+ if (hasAudioSink(device)) {
20
+ cancel()
21
+ ready()
22
+ return
23
+ }
24
+ if (pending?.device?.address == device.address) return
25
+ cancel()
26
+ val request = Pending(device, ready)
27
+ request.timeout = Runnable {
28
+ if (pending === request) {
29
+ cancel()
30
+ log("SDP timed out for ${device.address}; Classic audio remains disconnected")
31
+ }
32
+ }
33
+ pending = request
34
+ handler.postDelayed(request.timeout, timeoutMs)
35
+ try {
36
+ log("Refreshing Classic service UUIDs for ${device.address}")
37
+ if (!device.fetchUuidsWithSdp()) {
38
+ cancel()
39
+ log("SDP request rejected for ${device.address}; Classic audio remains disconnected")
40
+ }
41
+ } catch (error: Exception) {
42
+ cancel()
43
+ log("SDP request failed for ${device.address}: ${error.message}")
44
+ }
45
+ }
46
+
47
+ /** ACTION_UUID is a completion hint. Read the current OS cache, not a stale intent payload. */
48
+ fun onUuids(device: BluetoothDevice) {
49
+ val request = pending ?: return
50
+ if (device.address != request.device.address) return
51
+ if (hasAudioSink(request.device)) {
52
+ cancel()
53
+ log("Discovered A2DP Sink UUID for ${device.address}")
54
+ request.ready()
55
+ } else {
56
+ // Bonding can announce cached UUIDs before SDP finishes. Keep the original deadline.
57
+ log("UUID event without A2DP Sink for ${device.address}; waiting for SDP within the existing deadline")
58
+ }
59
+ }
60
+
61
+ fun cancel() {
62
+ pending?.let { handler.removeCallbacks(it.timeout) }
63
+ pending = null
64
+ }
65
+
66
+ companion object {
67
+ private val AUDIO_SINK = ParcelUuid.fromString("0000110b-0000-1000-8000-00805f9b34fb")
68
+ fun hasAudioSink(device: BluetoothDevice): Boolean = device.uuids?.contains(AUDIO_SINK) == true
69
+ }
70
+ }
@@ -1370,8 +1370,8 @@ class G1 : SGCManager() {
1370
1370
  context!!.registerReceiver(bondingReceiver, filter)
1371
1371
  isBondingReceiverRegistered = true
1372
1372
 
1373
- preferredG1DeviceId = DeviceStore.get("bluetooth", "device_name") as String?
1374
-
1373
+ // Keep the target supplied by connectById. device_name still belongs to
1374
+ // the previous pairing until DeviceManager promotes the ready device.
1375
1375
  if (!bluetoothAdapter!!.isEnabled()) {
1376
1376
  return
1377
1377
  }
@@ -2,37 +2,16 @@ package com.mentra.bluetoothsdk.sgcs
2
2
 
3
3
  import java.text.Normalizer
4
4
 
5
- private val G1_COMBINING_MARKS = Regex("\\p{M}+")
5
+ // Embedded G1 1.5.6 Latin glyphs. See notes/g1-character-support.md.
6
+ private const val G1_LATIN_GLYPHS = "ÀÂÇÈÉÊËÎÏÔÙÛÜàçèéêëîïôùûüÿŸÄäÖößẞâÁáÍíÑñúÓóÚⁱIJijŠšŽžÃÅÆÌÐÒÕØÝÞãåæìðòõøýþĄąĆćČčĎďĘęĚěĞğŁłŃńŇňŘřŚśŞşŤťŮůŹźŻżĂăıȘșȚțǖǘǚǜǎǐǒǔŐőŰű"
7
+ private val G1_LATIN_CLUSTERS = Regex("\\p{IsLatin}\\p{M}*")
6
8
 
7
- /**
8
- * Converts text to the base Latin glyphs available in the Even Realities G1 firmware.
9
- *
10
- * G1 does not ship glyphs for combining diacritics. Normalize at the device boundary so
11
- * miniapps can retain the real text everywhere else and newer glasses receive it unchanged.
12
- */
13
- internal fun sanitizeG1DisplayText(text: String): String {
14
- val expanded = buildString(text.length) {
15
- text.forEach { character ->
16
- when (character) {
17
- 'Đ', 'Ð' -> append('D')
18
- 'đ', 'ð' -> append('d')
19
- 'Ł' -> append('L')
20
- 'ł' -> append('l')
21
- 'Ø' -> append('O')
22
- 'ø' -> append('o')
23
- 'Æ' -> append("AE")
24
- 'æ' -> append("ae")
25
- 'Œ' -> append("OE")
26
- 'œ' -> append("oe")
27
- 'ẞ' -> append("SS")
28
- 'ß' -> append("ss")
29
- 'Þ' -> append("TH")
30
- 'þ' -> append("th")
31
- else -> append(character)
32
- }
33
- }
34
- }
35
-
36
- return Normalizer.normalize(expanded, Normalizer.Form.NFD)
37
- .replace(G1_COMBINING_MARKS, "")
9
+ /** Preserves mapped Latin glyphs and falls back only for unmapped Latin clusters. */
10
+ internal fun sanitizeG1DisplayText(text: String): String = G1_LATIN_CLUSTERS.replace(text) { match ->
11
+ val composed = Normalizer.normalize(match.value, Normalizer.Form.NFC)
12
+ if (composed.length == 1 && (composed[0] in ' '..'~' || composed[0] in G1_LATIN_GLYPHS)) {
13
+ composed
14
+ } else {
15
+ sanitizeLatinDisplayText(match.value, preserveOtherScripts = true)
16
+ }
38
17
  }
@@ -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
@@ -1363,17 +1364,18 @@ class G2 : SGCManager() {
1363
1364
  // Map device names to serial numbers (populated from manufacturer data during scan)
1364
1365
  private val deviceNameToSerialNumber = mutableMapOf<String, String>()
1365
1366
 
1366
- // Saved addresses for reconnection
1367
+ // Scope each side's address to the selected serial. Legacy global addresses have
1368
+ // no trustworthy owner, so the first connection after upgrading learns them by scan.
1367
1369
  private var leftGlassAddress: String?
1368
1370
  get() =
1369
1371
  context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1370
- .getString(KEY_LEFT_ADDRESS, null)
1372
+ .getString("$KEY_LEFT_ADDRESS:$DEVICE_SEARCH_ID", null)
1371
1373
  set(value) {
1372
1374
  context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1373
1375
  .edit()
1374
1376
  .apply {
1375
- if (value != null) putString(KEY_LEFT_ADDRESS, value)
1376
- else remove(KEY_LEFT_ADDRESS)
1377
+ if (value != null) putString("$KEY_LEFT_ADDRESS:$DEVICE_SEARCH_ID", value)
1378
+ else remove("$KEY_LEFT_ADDRESS:$DEVICE_SEARCH_ID")
1377
1379
  }
1378
1380
  .apply()
1379
1381
  }
@@ -1381,13 +1383,13 @@ class G2 : SGCManager() {
1381
1383
  private var rightGlassAddress: String?
1382
1384
  get() =
1383
1385
  context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1384
- .getString(KEY_RIGHT_ADDRESS, null)
1386
+ .getString("$KEY_RIGHT_ADDRESS:$DEVICE_SEARCH_ID", null)
1385
1387
  set(value) {
1386
1388
  context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1387
1389
  .edit()
1388
1390
  .apply {
1389
- if (value != null) putString(KEY_RIGHT_ADDRESS, value)
1390
- else remove(KEY_RIGHT_ADDRESS)
1391
+ if (value != null) putString("$KEY_RIGHT_ADDRESS:$DEVICE_SEARCH_ID", value)
1392
+ else remove("$KEY_RIGHT_ADDRESS:$DEVICE_SEARCH_ID")
1391
1393
  }
1392
1394
  .apply()
1393
1395
  }
@@ -1664,8 +1666,15 @@ class G2 : SGCManager() {
1664
1666
  if (char == null || gatt == null) return
1665
1667
  char.value = packet
1666
1668
  char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
1667
- val ok = gatt.writeCharacteristic(char)
1668
- bgcapNoteWriteResult(ok) // BGCAP
1669
+ // A rejected write never entered Android's queue. Retry on this dedicated
1670
+ // BLE worker before later commands (especially a page replacement) overtake it.
1671
+ var ok = false
1672
+ for (attempt in 0 until 5) {
1673
+ ok = gatt.writeCharacteristic(char)
1674
+ bgcapNoteWriteResult(ok)
1675
+ if (ok) break
1676
+ if (attempt < 4) Thread.sleep(BLE_PACKET_GAP_MS)
1677
+ }
1669
1678
  if (leg != null) {
1670
1679
  Bridge.log("G2/FILE: write $leg ${packet.size}B -> ${if (ok) "queued" else "DROPPED (stack busy)"}")
1671
1680
  }
@@ -2190,7 +2199,7 @@ class G2 : SGCManager() {
2190
2199
  val rBorderColor = borderColor ?: defaultTextBorderColor
2191
2200
  val rBorderRadius = borderRadius ?: defaultTextBorderRadius
2192
2201
  val rPaddingLength = paddingLength ?: defaultTextPaddingLength
2193
- val content = if (text.isEmpty()) " " else text
2202
+ val content = G2Text.containerContent(text)
2194
2203
 
2195
2204
  // Pure state mutation: update the container's content and schedule its sends; the reconcile
2196
2205
  // loop does the actual updateText writes. Reuse an existing container if the rect matches
@@ -2257,6 +2266,12 @@ class G2 : SGCManager() {
2257
2266
  }
2258
2267
 
2259
2268
  override fun clearDisplay() {
2269
+ // Expo invokes this on its worker queue; all container mutations must
2270
+ // share Main with reconcileDisplay and scene updates.
2271
+ if (Looper.myLooper() != Looper.getMainLooper()) {
2272
+ mainHandler.post { clearDisplay() }
2273
+ return
2274
+ }
2260
2275
  Bridge.log("G2: clearDisplay()")
2261
2276
  // Clear the text in place — do NOT shut down + rebuild the EvenHub page. Tearing the page
2262
2277
  // down (rebuildPage) kills audio streaming AND triggers a firmware systemExit /
@@ -2266,9 +2281,22 @@ class G2 : SGCManager() {
2266
2281
  // several seconds (incident 8164175a). Just blank the text + clear images; the reconcile
2267
2282
  // loop pushes the blanked text on a live page, and a dead page is only resurrected for
2268
2283
  // meaningful (non-blank) content, so a clear can't churn it back up.
2269
- for (i in textContainers.indices) {
2270
- textContainers[i].content = "\n"
2271
- textContainers[i].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2284
+ for (container in textContainers) {
2285
+ // Erase the firmware's existing text BEFORE forgetting its container.
2286
+ // A queued reconcile cannot do this after the positioned container is removed.
2287
+ // Cover the previous byte range, rather than replacing only its first character.
2288
+ val blank = " ".repeat(maxOf(1, container.content.toByteArray(Charsets.UTF_8).size))
2289
+ container.content = blank
2290
+ container.pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2291
+ if (pageCreated) {
2292
+ val erase = EvenHubProto.updateTextMessage(
2293
+ containerID = container.id,
2294
+ contentOffset = 0,
2295
+ contentLength = blank.length,
2296
+ content = blank
2297
+ )
2298
+ repeat(1 + EVEN_HUB_RESEND_COUNT) { sendEvenHubCommand(erase) }
2299
+ }
2272
2300
  }
2273
2301
  for (i in imageContainers.indices) {
2274
2302
  // The firmware still shows this container's image; emptying bmpData locally never reaches
@@ -2296,8 +2324,13 @@ class G2 : SGCManager() {
2296
2324
  textContainers.removeAll { it.id in huskIds }
2297
2325
  sceneTextByElement.entries.removeAll { it.value in huskIds }
2298
2326
  sceneImageByElement.clear()
2299
- Bridge.log("G2: clearDisplay() — purging ${huskIds.size} positioned husk container(s), one rebuild")
2300
- displayScope.launch { coalescedPageRebuild() }
2327
+ // Replace the live page now, while still on Main. A shutdown followed by
2328
+ // reconcile cannot clear this case: reconcile intentionally skips blank pages,
2329
+ // leaving the firmware's old positioned text visible until new speech arrives.
2330
+ // Queue the blank replacement before any following miniapp frame, without
2331
+ // tearing down the page (or its microphone session).
2332
+ Bridge.log("G2: clearDisplay() — replacing page after removing ${huskIds.size} positioned container(s)")
2333
+ if (pageCreated) createPageWithContainers()
2301
2334
  }
2302
2335
  }
2303
2336
 
@@ -2344,6 +2377,15 @@ class G2 : SGCManager() {
2344
2377
  return false
2345
2378
  }
2346
2379
 
2380
+ if (Looper.myLooper() != Looper.getMainLooper()) {
2381
+ mainHandler.post { applyBitmap(rx, ry, rw, rh, bmpData) }
2382
+ return true
2383
+ }
2384
+ return applyBitmap(rx, ry, rw, rh, bmpData)
2385
+ }
2386
+
2387
+ // Called only on Main, including the inline scene-frame path.
2388
+ private fun applyBitmap(rx: Int, ry: Int, rw: Int, rh: Int, bmpData: ByteArray): Boolean {
2347
2389
  // Pure state mutation: update the target container's bytes and mark it dirty. The reconcile
2348
2390
  // loop is the sole sender, so two displayBitmap calls can never overlap a sendImageData and
2349
2391
  // clobber the single-slot image ACK — no lock needed. Reuse an existing container if the rect
@@ -2538,7 +2580,7 @@ class G2 : SGCManager() {
2538
2580
  @Suppress("NAME_SHADOWING")
2539
2581
  val height = minOf(maxOf(height, minTextContainerHeight), 288 - y)
2540
2582
  run {
2541
- val content = if (text.isEmpty()) " " else text
2583
+ val content = G2Text.containerContent(text)
2542
2584
  val existingId = sceneTextByElement[elementId]
2543
2585
  if (existingId != null) {
2544
2586
  val idx = textContainers.indexOfFirst { it.id == existingId }
@@ -2999,15 +3041,15 @@ class G2 : SGCManager() {
2999
3041
  }
3000
3042
  // Only settle the flag if it's still empty — a displayBitmap during the await would
3001
3043
  // have set new bytes, so leave it dirty for the next pass to send the real image.
3002
- val jj = imageContainers.indexOfFirst { it.id == container.id }
3044
+ val jj = imageContainers.indexOfFirst { it === container }
3003
3045
  if (jj >= 0 && imageContainers[jj].bmpData.isEmpty()) {
3004
3046
  imageContainers[jj].dirty = false
3005
3047
  }
3006
3048
  continue
3007
3049
  }
3008
3050
  sendImageData(container.id, container.name, sentBytes)
3009
- // Re-find by id: the list may have shifted (eviction) during the await.
3010
- val j = imageContainers.indexOfFirst { it.id == container.id }
3051
+ // Re-find by identity: eviction can reuse this ID during the await.
3052
+ val j = imageContainers.indexOfFirst { it === container }
3011
3053
  if (j >= 0 && imageContainers[j].bmpData.contentEquals(sentBytes)) {
3012
3054
  imageContainers[j].dirty = false
3013
3055
  }
@@ -3677,8 +3719,17 @@ class G2 : SGCManager() {
3677
3719
  rightAuthenticated = false
3678
3720
  startupPageCreated = false
3679
3721
  pageCreated = false
3680
- imageContainers.clear()
3681
- textContainers.clear()
3722
+ // Keep GATT teardown synchronous for manager replacement, but never
3723
+ // remove containers underneath Main's reconcile/scene iteration.
3724
+ if (Looper.myLooper() == Looper.getMainLooper()) {
3725
+ imageContainers.clear()
3726
+ textContainers.clear()
3727
+ } else {
3728
+ mainHandler.post {
3729
+ imageContainers.clear()
3730
+ textContainers.clear()
3731
+ }
3732
+ }
3682
3733
  dashboardShowing = 0
3683
3734
  dashboardOpening = false
3684
3735
  heartbeatCounter = 0
@@ -0,0 +1,39 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import java.text.Normalizer
4
+
5
+ private val LATIN_COMBINING_MARKS = Regex("\\p{M}+")
6
+ private val LATIN_LETTERS_WITH_MARKS = Regex("\\p{IsLatin}\\p{M}*")
7
+
8
+ /** Converts accented and extended Latin letters to base glyphs at the device boundary. */
9
+ internal fun sanitizeLatinDisplayText(text: String, preserveOtherScripts: Boolean = false): String {
10
+ val expanded = buildString(text.length) {
11
+ text.forEach { character ->
12
+ when (character) {
13
+ 'Đ', 'Ð' -> append('D')
14
+ 'đ', 'ð' -> append('d')
15
+ 'Ł' -> append('L')
16
+ 'ł' -> append('l')
17
+ 'Ø' -> append('O')
18
+ 'ø' -> append('o')
19
+ 'Æ' -> append("AE")
20
+ 'æ' -> append("ae")
21
+ 'Œ' -> append("OE")
22
+ 'œ' -> append("oe")
23
+ 'ẞ' -> append("SS")
24
+ 'ß' -> append("ss")
25
+ 'Þ' -> append("TH")
26
+ 'þ' -> append("th")
27
+ else -> append(character)
28
+ }
29
+ }
30
+ }
31
+
32
+ if (preserveOtherScripts) {
33
+ return LATIN_LETTERS_WITH_MARKS.replace(expanded) { match ->
34
+ Normalizer.normalize(match.value, Normalizer.Form.NFD).replace(LATIN_COMBINING_MARKS, "")
35
+ }
36
+ }
37
+ return Normalizer.normalize(expanded, Normalizer.Form.NFD)
38
+ .replace(LATIN_COMBINING_MARKS, "")
39
+ }