@mentra/bluetooth-sdk 3.2.0-dev.180 → 3.2.0-dev.187
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 +29 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +21 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/ConnectedDeviceMatcher.kt +69 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +66 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/NativeNotifications.kt +42 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +38 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/EvenFileService.kt +101 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/EvenNotificationProtocol.kt +113 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +378 -24
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NotificationIds.kt +19 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ConnectedDeviceMatcherTest.kt +161 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +87 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/EvenFileServiceTest.kt +153 -0
- package/build/BluetoothSdk.types.d.ts +57 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +6 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +28 -49
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/scanSession.d.ts +21 -0
- package/build/_private/scanSession.d.ts.map +1 -0
- package/build/_private/scanSession.js +75 -0
- package/build/_private/scanSession.js.map +1 -0
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +2 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/build/react/useBluetoothScan.d.ts +2 -1
- package/build/react/useBluetoothScan.d.ts.map +1 -1
- package/build/react/useBluetoothScan.js +13 -0
- package/build/react/useBluetoothScan.js.map +1 -1
- package/build/react/useMentraBluetooth.d.ts +2 -1
- package/build/react/useMentraBluetooth.d.ts.map +1 -1
- package/build/react/useMentraBluetooth.js +1 -0
- package/build/react/useMentraBluetooth.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +30 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +53 -2
- package/ios/Source/NativeNotifications.swift +52 -0
- package/ios/Source/connection/ScanSession.swift +11 -0
- package/ios/Source/internal/BluetoothAvailability.swift +7 -0
- package/ios/Source/internal/ConnectedDeviceMatcher.swift +45 -0
- package/ios/Source/sgcs/G2.swift +131 -21
- package/ios/Source/sgcs/SGCManager.swift +22 -0
- package/ios/Tests/ConnectedDeviceMatcherTests.swift +27 -0
- package/ios/Tests/NativeNotificationTests.swift +20 -0
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +62 -0
- package/src/_private/BluetoothSdkModule.ts +47 -57
- package/src/_private/scanSession.ts +88 -0
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +7 -0
- package/src/react/useBluetoothScan.ts +12 -1
- package/src/react/useMentraBluetooth.ts +2 -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
|
-
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
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
|
-
|
|
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 {
|
|
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() {}
|