@mentra/bluetooth-sdk 3.2.0-dev.181 → 3.2.0-dev.190

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 (37) hide show
  1. package/README.md +22 -0
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +15 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +8 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +9 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/NativeNotifications.kt +42 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/EvenFileService.kt +101 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/EvenNotificationProtocol.kt +113 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +378 -24
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NotificationIds.kt +19 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  12. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/EvenFileServiceTest.kt +153 -0
  13. package/build/BluetoothSdk.types.d.ts +50 -0
  14. package/build/BluetoothSdk.types.d.ts.map +1 -1
  15. package/build/BluetoothSdk.types.js.map +1 -1
  16. package/build/_private/BluetoothSdkModule.d.ts +5 -1
  17. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  18. package/build/_private/BluetoothSdkModule.js.map +1 -1
  19. package/build/generated/releaseMetadata.js +5 -5
  20. package/build/generated/releaseMetadata.js.map +1 -1
  21. package/build/index.d.ts +1 -0
  22. package/build/index.d.ts.map +1 -1
  23. package/build/index.js +4 -0
  24. package/build/index.js.map +1 -1
  25. package/ios/BluetoothSdkModule.swift +23 -0
  26. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  27. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  28. package/ios/Source/MentraBluetoothSDK.swift +10 -0
  29. package/ios/Source/NativeNotifications.swift +52 -0
  30. package/ios/Source/sgcs/G2.swift +131 -21
  31. package/ios/Source/sgcs/SGCManager.swift +22 -0
  32. package/ios/Tests/NativeNotificationTests.swift +20 -0
  33. package/package.json +1 -1
  34. package/src/BluetoothSdk.types.ts +54 -0
  35. package/src/_private/BluetoothSdkModule.ts +8 -0
  36. package/src/generated/releaseMetadata.ts +5 -5
  37. package/src/index.ts +6 -0
@@ -22,6 +22,8 @@ import android.os.Looper
22
22
  import android.util.Base64
23
23
  import com.mentra.bluetoothsdk.Bridge
24
24
  import com.mentra.bluetoothsdk.DeviceManager
25
+ import com.mentra.bluetoothsdk.NativeNotificationConfig
26
+ import com.mentra.bluetoothsdk.NativeNotificationStatus
25
27
  import com.mentra.bluetoothsdk.DeviceStore
26
28
  import com.mentra.bluetoothsdk.utils.DeviceTypes
27
29
  import java.io.ByteArrayOutputStream
@@ -29,6 +31,9 @@ import java.util.TimeZone
29
31
  import java.util.UUID
30
32
  import java.util.regex.Pattern
31
33
  import kotlinx.coroutines.CompletableDeferred
34
+ import kotlinx.coroutines.CancellationException
35
+ import kotlinx.coroutines.asCoroutineDispatcher
36
+ import kotlinx.coroutines.ensureActive
32
37
  import kotlinx.coroutines.CoroutineScope
33
38
  import kotlinx.coroutines.Dispatchers
34
39
  import kotlinx.coroutines.Job
@@ -36,7 +41,10 @@ import kotlinx.coroutines.SupervisorJob
36
41
  import kotlinx.coroutines.channels.Channel
37
42
  import kotlinx.coroutines.delay
38
43
  import kotlinx.coroutines.isActive
44
+ import kotlinx.coroutines.cancelAndJoin
39
45
  import kotlinx.coroutines.launch
46
+ import kotlinx.coroutines.sync.Mutex
47
+ import kotlinx.coroutines.sync.withLock
40
48
  import kotlinx.coroutines.withTimeoutOrNull
41
49
 
42
50
  // ---------- G2 Protocol Constants ----------
@@ -45,6 +53,8 @@ private object G2BLE {
45
53
  // EvenHub BLE characteristic UUIDs (NOT the G1 UART UUIDs!)
46
54
  val CHAR_WRITE: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E5401")
47
55
  val CHAR_NOTIFY: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E5402")
56
+ val FILE_WRITE: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E7401")
57
+ val FILE_NOTIFY: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E7402")
48
58
  val AUDIO_NOTIFY: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E6402")
49
59
  val SERVICE_UUID: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E0000")
50
60
  val CLIENT_CHARACTERISTIC_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
@@ -59,6 +69,9 @@ private object G2BLE {
59
69
  private enum class ServiceID(val value: Byte) {
60
70
  DASHBOARD(0x01), // UI_BACKGROUND_DASHBOARD_APP_ID
61
71
  MENU(0x03), // UI_FOREGROUND_MEUN_ID (typo is intentional — matches Even's proto)
72
+ NOTIFICATION(0x04), // UI_FOREGROUND_NOTIFICATION_ID (the on-glasses notification centre)
73
+ FILE_CMD(0xC4.toByte()), // Even File Service — command channel (START/DATA/RESULT_CHECK)
74
+ FILE_DATA(0xC5.toByte()), // Even File Service — raw file bytes
62
75
  EVEN_AI(0x07), // UI_FOREGROUND_EVEN_AI_ID
63
76
  NAVIGATION(0x08), // UI_BACKGROUND_NAVIGATION_ID (compass/heading lives here)
64
77
  G2_SETTING(0x09), // UI_SETTING_APP_ID
@@ -140,7 +153,7 @@ private enum class DevCfgCommandId(val value: Int) {
140
153
 
141
154
  // ---------- CRC16 ----------
142
155
 
143
- private fun calcCRC16(data: ByteArray): Int {
156
+ internal fun g2Crc16(data: ByteArray): Int {
144
157
  var crc = 0xFFFF
145
158
  for (byte in data) {
146
159
  val b = byte.toInt() and 0xFF
@@ -834,6 +847,75 @@ private object EvenAIProto {
834
847
  }
835
848
  }
836
849
 
850
+ // ---------- Notification Service (notification.proto, service ID 4) ----------
851
+
852
+ /**
853
+ * Control plane for the on-glasses notification centre. Content does not travel here — it goes
854
+ * over the file service below. Wire formats: `notes/g2-notification-service.md`.
855
+ */
856
+ internal object NotificationProto {
857
+ const val CMD_CTRL = 1
858
+ const val CMD_NOTIFICATION_IOS = 2
859
+ const val CMD_WHITELIST_CTRL = 3
860
+ const val CMD_WHITELIST_CHK = 4
861
+ const val CMD_COMM_RSP = 161
862
+
863
+ /** Disabling presentation must not change the firmware's app-filter policy. */
864
+ fun controls(config: NativeNotificationConfig, controlMagic: Int, filterMagic: Int): List<Pair<Int, ByteArray>> = buildList {
865
+ add(controlMagic to notificationCtrl(
866
+ controlMagic, if (config.enabled) 1 else 0,
867
+ if (config.autoDisplay) 1 else 0, config.durationSeconds,
868
+ if (config.doNotDisturb) 1 else 0,
869
+ ))
870
+ if (config.enabled) add(filterMagic to whitelistCtrl(filterMagic, whitelistDisable = 1))
871
+ }
872
+
873
+ fun notificationCtrl(
874
+ magicRandom: Int,
875
+ notifEnable: Int = 1,
876
+ autoDispEnable: Int = 1,
877
+ dispTime: Int = 5,
878
+ avoidDisturbEnable: Int = 0
879
+ ): ByteArray {
880
+ val ctrlW = ProtobufWriter()
881
+ ctrlW.writeInt32Field(1, notifEnable)
882
+ ctrlW.writeInt32Field(2, autoDispEnable)
883
+ ctrlW.writeInt32Field(3, dispTime)
884
+ ctrlW.writeInt32Field(5, avoidDisturbEnable)
885
+
886
+ val w = ProtobufWriter()
887
+ w.writeInt32Field(1, CMD_CTRL)
888
+ w.writeInt32Field(2, magicRandom)
889
+ w.writeMessageField(3, ctrlW.toByteArray())
890
+ return w.toByteArray()
891
+ }
892
+
893
+ /**
894
+ * `whitelistDisable = 1` stops the glasses filtering incoming notifications against the
895
+ * whitelist file they hold, so we can filter phone-side instead of maintaining that file.
896
+ */
897
+ fun whitelistCtrl(magicRandom: Int, whitelistDisable: Int): ByteArray {
898
+ val wlW = ProtobufWriter()
899
+ wlW.writeInt32Field(1, whitelistDisable)
900
+
901
+ val w = ProtobufWriter()
902
+ w.writeInt32Field(1, CMD_WHITELIST_CTRL)
903
+ w.writeInt32Field(2, magicRandom)
904
+ w.writeMessageField(6, wlW.toByteArray())
905
+ return w.toByteArray()
906
+ }
907
+
908
+ fun cmdName(cmd: Int): String =
909
+ when (cmd) {
910
+ CMD_CTRL -> "CTRL"
911
+ CMD_NOTIFICATION_IOS -> "NOTIFICATION_IOS"
912
+ CMD_WHITELIST_CTRL -> "WHITELIST_CTRL"
913
+ CMD_WHITELIST_CHK -> "WHITELIST_CHK"
914
+ CMD_COMM_RSP -> "COMM_RSP"
915
+ else -> "cmd_$cmd"
916
+ }
917
+ }
918
+
837
919
  // ---------- Menu Protobuf Builders (menu.proto, service ID 3) ----------
838
920
 
839
921
  private object MenuProto {
@@ -1061,7 +1143,7 @@ private object EvenBLETransport {
1061
1143
  }
1062
1144
 
1063
1145
  val totalPackets = chunks.size.toByte()
1064
- val crc = calcCRC16(payload)
1146
+ val crc = g2Crc16(payload)
1065
1147
 
1066
1148
  val packets = mutableListOf<ByteArray>()
1067
1149
  for ((i, chunk) in chunks.withIndex()) {
@@ -1147,7 +1229,14 @@ private class G2ReceiveManager {
1147
1229
  val status = rawData[7].toInt() and 0xFF
1148
1230
  val resultCode = (status shr 1) and 0x0F
1149
1231
 
1150
- if (resultCode != 0) return null
1232
+ if (resultCode != 0) {
1233
+ // Silent drops here look identical to an ack timeout at the file layer, so say so.
1234
+ val sid = serviceId.toInt() and 0xFF
1235
+ if (sid == 0xC4 || sid == 0xC5) {
1236
+ Bridge.log("G2/FILE: inbound dropped before dispatch — resultCode=$resultCode")
1237
+ }
1238
+ return null
1239
+ }
1151
1240
 
1152
1241
  val isLast = serialNum == totalPackets
1153
1242
  val hasCrc = isLast
@@ -1256,6 +1345,9 @@ class G2 : SGCManager() {
1256
1345
  private var rightGatt: BluetoothGatt? = null
1257
1346
  private var leftWriteChar: BluetoothGattCharacteristic? = null
1258
1347
  private var rightWriteChar: BluetoothGattCharacteristic? = null
1348
+ // The file service has its own characteristic group (…7401/…7402), separate from the UI one,
1349
+ // and is driven over the right leg only — see [writeFilePackets].
1350
+ private var rightFileWriteChar: BluetoothGattCharacteristic? = null
1259
1351
  private var leftNotifyChar: BluetoothGattCharacteristic? = null
1260
1352
  private var rightNotifyChar: BluetoothGattCharacteristic? = null
1261
1353
  private var leftAudioChar: BluetoothGattCharacteristic? = null
@@ -1557,27 +1649,33 @@ class G2 : SGCManager() {
1557
1649
  }
1558
1650
  }
1559
1651
 
1560
- private fun writeOnePacket(packet: ByteArray, left: Boolean, right: Boolean) {
1561
- if (right) {
1562
- rightWriteChar?.let { char ->
1563
- rightGatt?.let { gatt ->
1564
- char.value = packet
1565
- char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
1566
- bgcapNoteWriteResult(gatt.writeCharacteristic(char)) // BGCAP
1567
- }
1568
- }
1569
- }
1570
- if (left) {
1571
- leftWriteChar?.let { char ->
1572
- leftGatt?.let { gatt ->
1573
- char.value = packet
1574
- char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
1575
- bgcapNoteWriteResult(gatt.writeCharacteristic(char)) // BGCAP
1576
- }
1577
- }
1652
+ /**
1653
+ * One characteristic write. Shared by the UI path ([writeOnePacket]) and the file path
1654
+ * ([writeFilePackets]), which differ only in the characteristic pair they target. No-ops if
1655
+ * the leg isn't bound. [leg] non-null also logs the write — file transfers only; the UI path
1656
+ * is far too hot for that.
1657
+ */
1658
+ private fun writeTo(
1659
+ char: BluetoothGattCharacteristic?,
1660
+ gatt: BluetoothGatt?,
1661
+ packet: ByteArray,
1662
+ leg: String?
1663
+ ) {
1664
+ if (char == null || gatt == null) return
1665
+ char.value = packet
1666
+ char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
1667
+ val ok = gatt.writeCharacteristic(char)
1668
+ bgcapNoteWriteResult(ok) // BGCAP
1669
+ if (leg != null) {
1670
+ Bridge.log("G2/FILE: write $leg ${packet.size}B -> ${if (ok) "queued" else "DROPPED (stack busy)"}")
1578
1671
  }
1579
1672
  }
1580
1673
 
1674
+ private fun writeOnePacket(packet: ByteArray, left: Boolean, right: Boolean) {
1675
+ if (right) writeTo(rightWriteChar, rightGatt, packet, null)
1676
+ if (left) writeTo(leftWriteChar, leftGatt, packet, null)
1677
+ }
1678
+
1581
1679
  private fun sendToGlasses(
1582
1680
  packets: List<ByteArray>,
1583
1681
  left: Boolean = false,
@@ -1585,9 +1683,12 @@ class G2 : SGCManager() {
1585
1683
  ) {
1586
1684
  // Bridge.log("G2: sendToGlasses() - sending ${packets.size} packets first byte: ${packets[0][0]}")
1587
1685
  if (packets.isEmpty()) return
1686
+ val expectedLeft = leftGatt
1687
+ val expectedRight = rightGatt
1688
+ fun connectionIsCurrent() = (!left || leftGatt === expectedLeft) && (!right || rightGatt === expectedRight)
1588
1689
  // Single-packet sends (the common case for text/settings) go straight through.
1589
1690
  if (packets.size == 1) {
1590
- writeOnePacket(packets[0], left, right)
1691
+ bleWriteExecutor.execute { if (connectionIsCurrent()) writeOnePacket(packets[0], left, right) }
1591
1692
  return
1592
1693
  }
1593
1694
  // Multi-packet bursts (bitmaps, large protobufs): pace the whole burst on a dedicated
@@ -1596,6 +1697,7 @@ class G2 : SGCManager() {
1596
1697
  // image ACK and would otherwise starve it under load — see [bleWriteExecutor]).
1597
1698
  bleWriteExecutor.execute {
1598
1699
  for (i in packets.indices) {
1700
+ if (!connectionIsCurrent()) return@execute
1599
1701
  writeOnePacket(packets[i], left, right)
1600
1702
  if (i < packets.size - 1) {
1601
1703
  try {
@@ -1672,6 +1774,24 @@ class G2 : SGCManager() {
1672
1774
  sendToGlasses(packets)
1673
1775
  }
1674
1776
 
1777
+ // File and display writes share the same BLE executor. File ACKs are scoped to
1778
+ // the current GATT connection; a cancelled/ambiguous transfer requires reconnect.
1779
+ private val notificationFiles = EvenFileService { serviceId, payload ->
1780
+ val packets = sendManager.buildPackets(serviceId, payload, reserveFlag = false)
1781
+ val targetGatt = rightGatt ?: throw java.io.IOException("not_connected")
1782
+ val targetChar = rightFileWriteChar ?: throw java.io.IOException("file_service_unavailable")
1783
+ kotlinx.coroutines.withContext(bleWriteExecutor.asCoroutineDispatcher()) {
1784
+ for (packet in packets) {
1785
+ kotlinx.coroutines.currentCoroutineContext().ensureActive()
1786
+ if (rightGatt !== targetGatt) throw java.io.IOException("connection_changed")
1787
+ targetChar.value = packet
1788
+ targetChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
1789
+ if (!targetGatt.writeCharacteristic(targetChar)) throw java.io.IOException("ble_write_rejected")
1790
+ Thread.sleep(BLE_PACKET_GAP_MS)
1791
+ }
1792
+ }
1793
+ }
1794
+
1675
1795
  private fun sendMenuCommand(payload: ByteArray) {
1676
1796
  val packets =
1677
1797
  sendManager.buildPackets(
@@ -3557,6 +3677,8 @@ class G2 : SGCManager() {
3557
3677
  activeMenuAppId = null
3558
3678
  lastClickTimestamp = null
3559
3679
  lastMenuSelectTimestamp = null
3680
+ stopNotificationSession(connectionChanged = true)
3681
+ notificationError = ""
3560
3682
  DeviceStore.apply("glasses", "connected", false)
3561
3683
  DeviceStore.apply("glasses", "fullyBooted", false)
3562
3684
  }
@@ -3574,6 +3696,7 @@ class G2 : SGCManager() {
3574
3696
  rightWriteChar = null
3575
3697
  leftNotifyChar = null
3576
3698
  rightNotifyChar = null
3699
+ rightFileWriteChar = null
3577
3700
  leftAudioChar = null
3578
3701
  rightAudioChar = null
3579
3702
  DEVICE_SEARCH_ID = "NOT_SET"
@@ -3722,6 +3845,170 @@ class G2 : SGCManager() {
3722
3845
  Bridge.log("G2: Sent RING_DISCONNECT_INFO for MAC $mac")
3723
3846
  }
3724
3847
 
3848
+ // ---------- Native Notification Centre ----------
3849
+
3850
+ @Volatile private var notificationConfig = NativeNotificationConfig()
3851
+ private var notificationJob: Job? = null
3852
+ private var notificationQueue: Channel<Map<String, Any>>? = null
3853
+ private var notificationEpoch = 0L
3854
+ private var notificationError = ""
3855
+ private var notificationControlMagics = emptySet<Int>()
3856
+ private var notificationSubmitted = false
3857
+
3858
+ override fun getNativeNotificationStatus() = NativeNotificationStatus(
3859
+ supported = true,
3860
+ source = "phone",
3861
+ authorization = "system", // Android listener permission is owned by the host.
3862
+ state = when {
3863
+ DeviceStore.get("glasses", "fullyBooted") != true -> "unavailable"
3864
+ notificationFiles.needsReconnect -> "needs_reconnect"
3865
+ notificationError.isNotEmpty() -> "failed"
3866
+ !notificationConfig.enabled -> "disabled"
3867
+ !notificationSubmitted -> "configuring"
3868
+ else -> "submitted"
3869
+ },
3870
+ config = notificationConfig,
3871
+ error = notificationError,
3872
+ )
3873
+
3874
+ private fun publishNotificationStatus() {
3875
+ Bridge.sendTypedMessage("native_notification_status", getNativeNotificationStatus().toMap())
3876
+ }
3877
+
3878
+ override fun configureNativeNotifications(config: NativeNotificationConfig) {
3879
+ mainHandler.post {
3880
+ if (notificationConfig == config && notificationJob?.isActive == true) return@post
3881
+ notificationConfig = config.copy(blockedApps = config.blockedApps.toList())
3882
+ restartNotificationSession()
3883
+ }
3884
+ }
3885
+
3886
+ private fun stopNotificationSession(connectionChanged: Boolean) {
3887
+ notificationEpoch++
3888
+ notificationSubmitted = false
3889
+ notificationControlMagics = emptySet()
3890
+ notificationJob?.cancel()
3891
+ notificationQueue?.let { queue ->
3892
+ while (true) {
3893
+ val value = queue.tryReceive().getOrNull() ?: break
3894
+ reportNotification(value, "cancelled", "session_changed")
3895
+ }
3896
+ queue.close()
3897
+ }
3898
+ notificationQueue = null
3899
+ if (connectionChanged) notificationFiles.resetConnection()
3900
+ }
3901
+
3902
+ private fun restartNotificationSession() {
3903
+ val oldJob = notificationJob
3904
+ stopNotificationSession(connectionChanged = false)
3905
+ val epoch = notificationEpoch
3906
+ val config = notificationConfig
3907
+ notificationError = ""
3908
+ val queue = Channel<Map<String, Any>>(8)
3909
+ notificationQueue = if (config.enabled) queue else null
3910
+ notificationJob = displayScope.launch {
3911
+ oldJob?.cancelAndJoin()
3912
+ if (epoch != notificationEpoch || DeviceStore.get("glasses", "fullyBooted") != true) return@launch
3913
+ try {
3914
+ sendNotificationControls(config, epoch)
3915
+ notificationSubmitted = true
3916
+ publishNotificationStatus()
3917
+ if (!config.enabled) return@launch
3918
+ for (notification in queue) {
3919
+ try {
3920
+ if (epoch != notificationEpoch) throw CancellationException("session_changed")
3921
+ pushNotificationToCentre(notification)
3922
+ } catch (cancelled: CancellationException) {
3923
+ reportNotification(notification, "cancelled", "session_changed")
3924
+ throw cancelled
3925
+ } catch (error: Exception) {
3926
+ notificationError = error.message ?: "transfer_failed"
3927
+ reportNotification(notification, "failed", notificationError)
3928
+ publishNotificationStatus()
3929
+ }
3930
+ }
3931
+ } catch (cancelled: CancellationException) {
3932
+ throw cancelled
3933
+ } catch (error: Exception) {
3934
+ notificationError = error.message ?: "configuration_failed"
3935
+ publishNotificationStatus()
3936
+ } finally {
3937
+ queue.close()
3938
+ while (true) {
3939
+ val value = queue.tryReceive().getOrNull() ?: break
3940
+ reportNotification(value, "cancelled", "session_changed")
3941
+ }
3942
+ }
3943
+ }
3944
+ }
3945
+
3946
+ private suspend fun sendNotificationControls(config: NativeNotificationConfig, epoch: Long) {
3947
+ val targetGatt = rightGatt ?: throw java.io.IOException("not_connected")
3948
+ val targetChar = rightWriteChar ?: throw java.io.IOException("not_connected")
3949
+ val controlMagic = sendManager.nextMagicRandom()
3950
+ val filterMagic = sendManager.nextMagicRandom()
3951
+ val commands = NotificationProto.controls(config, controlMagic, filterMagic)
3952
+ notificationControlMagics = commands.map { it.first }.toSet()
3953
+ for ((index, command) in commands.withIndex()) {
3954
+ val payload = command.second
3955
+ if (index > 0) delay(400) // Verified device requirement between control commands.
3956
+ val packets = sendManager.buildPackets(ServiceID.NOTIFICATION.value, payload, reserveFlag = true)
3957
+ kotlinx.coroutines.withContext(bleWriteExecutor.asCoroutineDispatcher()) {
3958
+ for (packet in packets) {
3959
+ kotlinx.coroutines.currentCoroutineContext().ensureActive()
3960
+ if (epoch != notificationEpoch || targetGatt !== rightGatt) throw CancellationException("session_changed")
3961
+ targetChar.value = packet
3962
+ targetChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
3963
+ if (!targetGatt.writeCharacteristic(targetChar)) throw java.io.IOException("ble_write_rejected")
3964
+ Thread.sleep(BLE_PACKET_GAP_MS)
3965
+ }
3966
+ }
3967
+ }
3968
+ }
3969
+
3970
+ override fun sendPhoneNotification(notification: Map<String, Any>) {
3971
+ require(((notification["action"] as? Number)?.toInt() ?: 0) == 0) { "Notification removal is not supported" }
3972
+ mainHandler.post {
3973
+ if ((notification["packageName"] as? String) in notificationConfig.blockedApps) {
3974
+ reportNotification(notification, "dropped", "blocked_app")
3975
+ } else if (!notificationConfig.enabled || DeviceStore.get("glasses", "fullyBooted") != true) {
3976
+ reportNotification(notification, "dropped", "disabled_or_disconnected")
3977
+ } else if (notificationQueue?.trySend(notification)?.isSuccess != true) {
3978
+ reportNotification(notification, "dropped", "queue_full_or_unavailable")
3979
+ }
3980
+ }
3981
+ }
3982
+
3983
+ private fun reportNotification(notification: Map<String, Any>, status: String, reason: String) {
3984
+ Bridge.sendTypedMessage("native_notification_delivery", mapOf(
3985
+ "notificationId" to (notification["notificationId"] as? String ?: ""),
3986
+ "status" to status, "reason" to reason,
3987
+ ))
3988
+ }
3989
+
3990
+ private suspend fun pushNotificationToCentre(notification: Map<String, Any>) {
3991
+ check(!notificationFiles.needsReconnect) { "needs_reconnect" }
3992
+ val bytes = NotificationJson.androidNotification(
3993
+ msgId = msgIdFor(notification["notificationId"] as? String ?: ""),
3994
+ action = 0, // Only posted/updated notifications have a verified wire operation.
3995
+ appIdentifier = notification["packageName"] as? String ?: "",
3996
+ title = notification["title"] as? String ?: "",
3997
+ subtitle = notification["subtitle"] as? String ?: "",
3998
+ message = notification["body"] as? String ?: "",
3999
+ postTimeMs = (notification["timestampMs"] as? Number)?.toLong() ?: System.currentTimeMillis(),
4000
+ displayName = notification["appName"] as? String ?: "",
4001
+ )
4002
+ val status = notificationFiles.transfer(FileService.TYPE_ANDROID_MSG_JSON_NOTIFICATION, FileService.PATH_NOTIFY, bytes)
4003
+ if (status != 0) throw java.io.IOException(FileService.statusName(status))
4004
+ notificationError = ""
4005
+ reportNotification(notification, "delivered", "")
4006
+ publishNotificationStatus()
4007
+ }
4008
+
4009
+ private val notificationIds = NotificationIds()
4010
+ private fun msgIdFor(notificationId: String): Int = notificationIds.forPhoneId(notificationId)
4011
+
3725
4012
  // ---------- SGCManager: Device Control ----------
3726
4013
 
3727
4014
  override fun setHeadUpAngle(angle: Int) {
@@ -4041,6 +4328,7 @@ class G2 : SGCManager() {
4041
4328
  return object : BluetoothGattCallback() {
4042
4329
  override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
4043
4330
  mainHandler.post {
4331
+ if (gatt !== (if (side == "LEFT") leftGatt else rightGatt)) return@post
4044
4332
  if (newState == BluetoothProfile.STATE_CONNECTED) {
4045
4333
  Bridge.log("G2: Connected to $side: ${gatt.device?.name ?: "unknown"}")
4046
4334
 
@@ -4099,6 +4387,7 @@ class G2 : SGCManager() {
4099
4387
  rightWriteChar = null
4100
4388
  leftNotifyChar = null
4101
4389
  rightNotifyChar = null
4390
+ rightFileWriteChar = null
4102
4391
  leftAudioChar = null
4103
4392
  rightAudioChar = null
4104
4393
  authStarted = false
@@ -4109,8 +4398,11 @@ class G2 : SGCManager() {
4109
4398
  pageCreated = false
4110
4399
  dashboardShowing = 0
4111
4400
  dashboardOpening = false
4401
+ stopNotificationSession(connectionChanged = true)
4402
+ notificationError = ""
4112
4403
  DeviceStore.apply("glasses", "connected", false)
4113
4404
  DeviceStore.apply("glasses", "fullyBooted", false)
4405
+ publishNotificationStatus()
4114
4406
 
4115
4407
  startReconnectionTimer()
4116
4408
  }
@@ -4170,6 +4462,16 @@ class G2 : SGCManager() {
4170
4462
  enqueueGattOp { enableNotifications(gatt, char) }
4171
4463
  }
4172
4464
 
4465
+ G2BLE.FILE_WRITE -> {
4466
+ Bridge.log("G2: Found FILE WRITE char on $side")
4467
+ if (side != "LEFT") rightFileWriteChar = char
4468
+ }
4469
+
4470
+ G2BLE.FILE_NOTIFY -> {
4471
+ Bridge.log("G2: Found FILE NOTIFY char on $side")
4472
+ enqueueGattOp { enableNotifications(gatt, char) }
4473
+ }
4474
+
4173
4475
  G2BLE.AUDIO_NOTIFY -> {
4174
4476
  Bridge.log("G2: Found AUDIO char on $side")
4175
4477
  if (side == "LEFT") leftAudioChar = char
@@ -4206,10 +4508,17 @@ class G2 : SGCManager() {
4206
4508
  gatt: BluetoothGatt,
4207
4509
  characteristic: BluetoothGattCharacteristic
4208
4510
  ) {
4209
- val data = characteristic.value ?: return
4511
+ if (gatt !== (if (side == "LEFT") leftGatt else rightGatt)) return
4512
+ val data = characteristic.value?.copyOf() ?: return
4210
4513
 
4211
4514
  val sourceKey = if (side == "LEFT") "L" else "R"
4212
4515
  when (characteristic.uuid) {
4516
+ // File-service acks arrive on their own characteristic and share the standard
4517
+ // transport framing, so they need no demux — just the usual decode.
4518
+ G2BLE.FILE_NOTIFY -> mainHandler.post {
4519
+ if (gatt === rightGatt && side == "RIGHT") handleNotifyData(data, sourceKey)
4520
+ }
4521
+
4213
4522
  G2BLE.AUDIO_NOTIFY -> handleAudioData(data, sourceKey)
4214
4523
  G2BLE.CHAR_NOTIFY -> {
4215
4524
  // Correlate an in-flight image ACK INLINE on the BLE callback thread, before
@@ -4218,7 +4527,9 @@ class G2 : SGCManager() {
4218
4527
  // promptly even while the main looper is busy draining a packet burst /
4219
4528
  // heartbeats — the cause of the intermittent "glasses stopped responding".
4220
4529
  correlateImageAck(data, sourceKey)
4221
- mainHandler.post { handleNotifyData(data, sourceKey) }
4530
+ mainHandler.post {
4531
+ if (gatt === (if (side == "LEFT") leftGatt else rightGatt)) handleNotifyData(data, sourceKey)
4532
+ }
4222
4533
  }
4223
4534
  }
4224
4535
  }
@@ -4322,6 +4633,10 @@ class G2 : SGCManager() {
4322
4633
  }
4323
4634
 
4324
4635
  private fun handleNotifyData(data: ByteArray, sourceKey: String) {
4636
+ if (data.size > 6 && (data[6] == ServiceID.FILE_CMD.value || data[6] == ServiceID.FILE_DATA.value)) {
4637
+ if (sourceKey == "R") notificationFiles.acceptFrame(data)
4638
+ return
4639
+ }
4325
4640
  val result = receiveManager.handlePacket(data, sourceKey) ?: return
4326
4641
 
4327
4642
  val serviceId = result.first
@@ -4341,6 +4656,7 @@ class G2 : SGCManager() {
4341
4656
  ServiceID.NAVIGATION.value -> handleNavigationResponse(payload)
4342
4657
  ServiceID.EVEN_AI.value -> handleEvenAIResponse(payload)
4343
4658
  ServiceID.EVEN_HUB_CTRL.value -> handleEvenHubCtrlResponse(payload)
4659
+ ServiceID.NOTIFICATION.value -> handleNotificationResponse(payload)
4344
4660
  else -> {
4345
4661
  Bridge.log(
4346
4662
  "G2: Unhandled service ${serviceId.toInt() and 0xFF} (${payload.size} bytes): ${
@@ -4351,6 +4667,43 @@ class G2 : SGCManager() {
4351
4667
  }
4352
4668
  }
4353
4669
 
4670
+ /**
4671
+ * Notification service (0x04). Reports control refusals and the
4672
+ * glasses' own notification activity; content never travels here. Field map in
4673
+ * `notes/g2-notification-service.md`.
4674
+ */
4675
+ private fun handleNotificationResponse(payload: ByteArray) {
4676
+ val fields = ProtobufReader(payload).parseFields()
4677
+ val cmd = fields[1] as? Int ?: -1
4678
+ val magic = fields[2] as? Int
4679
+
4680
+ // COMM_RSP carries field 5 = {f1=commandId that failed, f2=errorCode}.
4681
+ var verdict = ""
4682
+ if (cmd == NotificationProto.CMD_COMM_RSP) {
4683
+ val rsp = (fields[5] as? ByteArray)?.let { ProtobufReader(it).parseFields() }
4684
+ val failedCmd = rsp?.get(1) as? Int
4685
+ val errorCode = rsp?.get(2) as? Int
4686
+ verdict =
4687
+ " REJECTED cmd=${failedCmd?.let { NotificationProto.cmdName(it) } ?: "?"}" +
4688
+ " errorCode=$errorCode" +
4689
+ (if (errorCode == 8) " (NOT_SUPPORT)" else "")
4690
+ // Only a response correlated to the current controls can invalidate this session.
4691
+ if (magic in notificationControlMagics && errorCode != null && errorCode != 0 &&
4692
+ (failedCmd == NotificationProto.CMD_CTRL || failedCmd == NotificationProto.CMD_WHITELIST_CTRL)
4693
+ ) {
4694
+ notificationError = "configuration_rejected_$errorCode"
4695
+ stopNotificationSession(connectionChanged = false)
4696
+ publishNotificationStatus()
4697
+ }
4698
+ }
4699
+
4700
+ Bridge.log(
4701
+ "G2/NOTIF: RX ${NotificationProto.cmdName(cmd)}" +
4702
+ (magic?.let { " magic=$it" } ?: "") +
4703
+ verdict
4704
+ )
4705
+ }
4706
+
4354
4707
  /**
4355
4708
  * EvenAI service (0x07). Logs the decoded EvenAIDataPackage so we can read the CONFIG
4356
4709
  * (Hey Even) echo: commandId=10 (CONFIG), config sub-message in field 13.
@@ -4544,6 +4897,7 @@ class G2 : SGCManager() {
4544
4897
  }
4545
4898
  if (!isFullyBooted) {
4546
4899
  DeviceStore.apply("glasses", "fullyBooted", true)
4900
+ restartNotificationSession()
4547
4901
  }
4548
4902
  }
4549
4903
 
@@ -0,0 +1,19 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ /**
4
+ * IDs remain stable for this driver's lifetime, including reconnects. Firmware history removal
5
+ * is not observable, so never recycle an ID that may still identify a retained card.
6
+ */
7
+ internal class NotificationIds {
8
+ private val ids = mutableMapOf<String, Int>()
9
+ private var next = 2000
10
+
11
+ fun forPhoneId(phoneId: String): Int {
12
+ if (phoneId.isNotEmpty()) ids[phoneId]?.let { return it }
13
+ check(next <= 9999) { "notification_id_capacity_exhausted" }
14
+ val id = next++
15
+ // Anonymous notifications also consume an ID; reusing one could replace an unrelated card.
16
+ if (phoneId.isNotEmpty()) ids[phoneId] = id
17
+ return id
18
+ }
19
+ }
@@ -257,6 +257,17 @@ abstract class SGCManager {
257
257
  // Notification Panel (default no-op — only G2 supports this)
258
258
  open suspend fun showNotificationsPanel() {}
259
259
 
260
+ /** Enqueue supported phone notification content; unsupported drivers fail explicitly. */
261
+ open fun sendPhoneNotification(notification: Map<String, Any>) {
262
+ throw UnsupportedOperationException("Phone notification upload is not supported")
263
+ }
264
+
265
+ open fun configureNativeNotifications(config: com.mentra.bluetoothsdk.NativeNotificationConfig) {
266
+ throw UnsupportedOperationException("Native notifications are not supported")
267
+ }
268
+
269
+ open fun getNativeNotificationStatus() = com.mentra.bluetoothsdk.NativeNotificationStatus()
270
+
260
271
  // Controller bridging (default no-op — only G2 supports pairing with a ring controller)
261
272
  open fun connectController() {}
262
273
  open fun disconnectController() {}