From 90600c6e72ef2b96f3c876335c9da15b3212ffe1 Mon Sep 17 00:00:00 2001 From: aheschl1 Date: Tue, 4 Aug 2026 22:37:41 -0700 Subject: [PATCH 1/4] G2 native notification service --- .../mentra/bluetoothsdk/BluetoothSdkModule.kt | 8 + .../com/mentra/bluetoothsdk/DeviceManager.kt | 7 + .../java/com/mentra/bluetoothsdk/sgcs/G2.kt | 596 +++++++++++++++++- .../mentra/bluetoothsdk/sgcs/SGCManager.kt | 11 + .../ios/BluetoothSdkModule.swift | 15 + .../ios/Source/sgcs/SGCManager.swift | 10 + .../bluetooth-sdk/src/BluetoothSdk.types.ts | 23 + .../src/_private/BluetoothSdkModule.ts | 12 +- mobile/modules/crust/src/Crust.types.ts | 6 +- mobile/modules/engine/src/engine.ts | 6 + .../src/services/G2NotificationBridge.ts | 67 ++ .../__tests__/G2NotificationBridge.test.ts | 126 ++++ mobile/modules/engine/src/stores/settings.ts | 10 + mobile/src/app/miniapps/settings/super.tsx | 16 +- 14 files changed, 888 insertions(+), 25 deletions(-) create mode 100644 mobile/modules/engine/src/services/G2NotificationBridge.ts create mode 100644 mobile/modules/engine/src/services/__tests__/G2NotificationBridge.test.ts diff --git a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt index 921962e723..0e0802cd43 100644 --- a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +++ b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt @@ -555,6 +555,14 @@ class BluetoothSdkModule : Module() { sdk?.sendIncidentId(incidentId, apiBaseUrl) } + // MARK: - Native Notification Centre (internal, G2 only) + + // Via deviceManager, not the MentraBluetoothSdk facade: internal surface (like + // dbg1/ping), not public SDK API. + AsyncFunction("sendPhoneNotification") { notification: Map -> + deviceManager?.sendPhoneNotification(notification) + } + // MARK: - WiFi Commands SdkCoroutineFunction("requestWifiScan") { -> requireSdk().requestWifiScan().map { it.toMap() } } diff --git a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt index 6b5bedf754..1db269b086 100644 --- a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +++ b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt @@ -1619,6 +1619,13 @@ class DeviceManager { sgc?.sendIncidentId(incidentId, apiBaseUrl) } + /** Push a notification into the glasses' own notification centre; no-op outside G2. */ + fun sendPhoneNotification(notification: Map) { + // Package only - never the notification text. + Bridge.log("MAN: sendPhoneNotification from ${notification["packageName"]}") + sgc?.sendPhoneNotification(notification) + } + fun sendWifiCredentials(ssid: String, password: String) { Bridge.log("MAN: Sending wifi credentials: $ssid") sgc?.sendWifiCredentials(ssid, password) diff --git a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt index 2cb4bd0906..afc250ead9 100644 --- a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +++ b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt @@ -33,10 +33,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull // ---------- G2 Protocol Constants ---------- @@ -45,6 +48,8 @@ private object G2BLE { // EvenHub BLE characteristic UUIDs (NOT the G1 UART UUIDs!) val CHAR_WRITE: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E5401") val CHAR_NOTIFY: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E5402") + val FILE_WRITE: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E7401") + val FILE_NOTIFY: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E7402") val AUDIO_NOTIFY: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E6402") val SERVICE_UUID: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E0000") val CLIENT_CHARACTERISTIC_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") @@ -59,6 +64,9 @@ private object G2BLE { private enum class ServiceID(val value: Byte) { DASHBOARD(0x01), // UI_BACKGROUND_DASHBOARD_APP_ID MENU(0x03), // UI_FOREGROUND_MEUN_ID (typo is intentional — matches Even's proto) + NOTIFICATION(0x04), // UI_FOREGROUND_NOTIFICATION_ID (the on-glasses notification centre) + FILE_CMD(0xC4.toByte()), // Even File Service — command channel (START/DATA/RESULT_CHECK) + FILE_DATA(0xC5.toByte()), // Even File Service — raw file bytes EVEN_AI(0x07), // UI_FOREGROUND_EVEN_AI_ID NAVIGATION(0x08), // UI_BACKGROUND_NAVIGATION_ID (compass/heading lives here) G2_SETTING(0x09), // UI_SETTING_APP_ID @@ -834,6 +842,174 @@ private object EvenAIProto { } } +// ---------- Notification Service (notification.proto, service ID 4) ---------- + +/** + * Control plane for the on-glasses notification centre. Content does not travel here — it goes + * over the file service below. Wire formats: `notes/g2-notification-service.md`. + */ +private object NotificationProto { + const val CMD_CTRL = 1 + const val CMD_NOTIFICATION_IOS = 2 + const val CMD_WHITELIST_CTRL = 3 + const val CMD_WHITELIST_CHK = 4 + const val CMD_COMM_RSP = 161 + + fun notificationCtrl( + magicRandom: Int, + notifEnable: Int = 1, + autoDispEnable: Int = 1, + dispTime: Int = 5, + avoidDisturbEnable: Int = 0 + ): ByteArray { + val ctrlW = ProtobufWriter() + ctrlW.writeInt32Field(1, notifEnable) + ctrlW.writeInt32Field(2, autoDispEnable) + ctrlW.writeInt32Field(3, dispTime) + ctrlW.writeInt32Field(5, avoidDisturbEnable) + + val w = ProtobufWriter() + w.writeInt32Field(1, CMD_CTRL) + w.writeInt32Field(2, magicRandom) + w.writeMessageField(3, ctrlW.toByteArray()) + return w.toByteArray() + } + + /** + * `whitelistDisable = 1` stops the glasses filtering incoming notifications against the + * whitelist file they hold, so we can filter phone-side instead of maintaining that file. + */ + fun whitelistCtrl(magicRandom: Int, whitelistDisable: Int): ByteArray { + val wlW = ProtobufWriter() + wlW.writeInt32Field(1, whitelistDisable) + + val w = ProtobufWriter() + w.writeInt32Field(1, CMD_WHITELIST_CTRL) + w.writeInt32Field(2, magicRandom) + w.writeMessageField(6, wlW.toByteArray()) + return w.toByteArray() + } + + fun cmdName(cmd: Int): String = + when (cmd) { + CMD_CTRL -> "CTRL" + CMD_NOTIFICATION_IOS -> "NOTIFICATION_IOS" + CMD_WHITELIST_CTRL -> "WHITELIST_CTRL" + CMD_WHITELIST_CHK -> "WHITELIST_CHK" + CMD_COMM_RSP -> "COMM_RSP" + else -> "cmd_$cmd" + } +} + +// ---------- Even File Service (service 0xC4 cmd / 0xC5 data) ---------- + +/** + * The glasses' generic file-push channel, and how notification content gets across: the body is a + * JSON document pushed as a file. Unlike every other G2 service this is **not** protobuf — + * SEND_START is a fixed 93-byte struct and the data phase writes raw bytes. + * + * Each phase is acked with a 2-byte `[cid][status]`: + * START(fileType, length, crc32, filename) → ack → DATA → raw bytes on 0xC5 → ack + * → RESULT_CHECK → ack + */ +private object FileService { + // eEvenFileSendServiceCID — first byte of every 0xC4 payload + const val CID_SEND_START = 0 + const val CID_SEND_DATA = 1 + const val CID_SEND_RESULT_CHECK = 2 + + const val TYPE_ANDROID_MSG_JSON_NOTIFICATION = 1 + + // Notification bodies and the whitelist share this path: the firmware has one filename + // constant (`BleG2GlassesFilePath.notifyWhitelist`) and discriminates on `fileType`. + const val PATH_NOTIFY = "user/notify_whitelist.json" + + const val FILENAME_FIELD_LEN = 80 + const val SEND_START_LEN = 93 // 1 + 4 + 4 + 4 + 80 + + // eEvenFileServiceRsp + fun statusName(status: Int): String = + when (status) { + 0 -> "SUCCESS" + 1 -> "START_ERR" + 2 -> "DATA_CRC_ERR" + 3 -> "FLASH_WRITE_ERR" + 4 -> "TIMEOUT" + 5 -> "NO_RESOURCES" + 6 -> "RESULT_CHECK_FAIL" + 7 -> "FAIL" + 8 -> "CANCEL" + else -> "status_$status" + } + + private fun ByteArrayOutputStream.writeU32LE(value: Int) { + write(value and 0xFF) + write((value ushr 8) and 0xFF) + write((value ushr 16) and 0xFF) + write((value ushr 24) and 0xFF) + } + + /** 93-byte fixed struct: cid | fileType u32 | fileLength u32 | fileCrc32 u32 | filename[80]. */ + fun sendStart(fileType: Int, fileLength: Int, fileCrc32: Int, filename: String): ByteArray { + val out = ByteArrayOutputStream() + out.write(CID_SEND_START) + out.writeU32LE(fileType) + out.writeU32LE(fileLength) + out.writeU32LE(fileCrc32) + + val nameBytes = filename.toByteArray(Charsets.US_ASCII) + require(nameBytes.size < FILENAME_FIELD_LEN) { "filename too long: $filename" } + out.write(nameBytes) + repeat(FILENAME_FIELD_LEN - nameBytes.size) { out.write(0) } // NUL-padded to 80 + + return out.toByteArray().also { check(it.size == SEND_START_LEN) } + } + + fun sendData(): ByteArray = byteArrayOf(CID_SEND_DATA.toByte()) + + fun resultCheck(): ByteArray = byteArrayOf(CID_SEND_RESULT_CHECK.toByte()) +} + +// ---------- Notification payload JSON ---------- + +/** + * The JSON document the glasses expect on the file service — these nine fields, exactly. Schema + * confirmed against a BLE capture of the Even app; see `notes/g2-notification-service.md`. + * + * `time_s` is UTC epoch seconds while `date` is **device-local** wall time; formatting `date` in + * UTC would skew every displayed timestamp by the device's offset. + */ +private object NotificationJson { + fun androidNotification( + msgId: Int, + action: Int, + appIdentifier: String, + title: String, + subtitle: String, + message: String, + postTimeMs: Long, + displayName: String + ): ByteArray { + val dateFmt = java.text.SimpleDateFormat("yyyyMMdd'T'HHmmss", java.util.Locale.US) + val body = + org.json.JSONObject() + .put("msg_id", msgId) + .put("action", action) + .put("app_identifier", appIdentifier) + .put("title", title) + .put("subtitle", subtitle) + .put("message", message) + .put("time_s", postTimeMs / 1000) + .put("date", dateFmt.format(java.util.Date(postTimeMs))) + .put("display_name", displayName) + + return org.json.JSONObject() + .put("android_notification", body) + .toString() + .toByteArray(Charsets.UTF_8) + } +} + // ---------- Menu Protobuf Builders (menu.proto, service ID 3) ---------- private object MenuProto { @@ -1147,7 +1323,14 @@ private class G2ReceiveManager { val status = rawData[7].toInt() and 0xFF val resultCode = (status shr 1) and 0x0F - if (resultCode != 0) return null + if (resultCode != 0) { + // Silent drops here look identical to an ack timeout at the file layer, so say so. + val sid = serviceId.toInt() and 0xFF + if (sid == 0xC4 || sid == 0xC5) { + Bridge.log("G2/FILE: inbound dropped before dispatch — resultCode=$resultCode") + } + return null + } val isLast = serialNum == totalPackets val hasCrc = isLast @@ -1256,6 +1439,9 @@ class G2 : SGCManager() { private var rightGatt: BluetoothGatt? = null private var leftWriteChar: BluetoothGattCharacteristic? = null private var rightWriteChar: BluetoothGattCharacteristic? = null + // The file service has its own characteristic group (…7401/…7402), separate from the UI one, + // and is driven over the right leg only — see [writeFilePackets]. + private var rightFileWriteChar: BluetoothGattCharacteristic? = null private var leftNotifyChar: BluetoothGattCharacteristic? = null private var rightNotifyChar: BluetoothGattCharacteristic? = null private var leftAudioChar: BluetoothGattCharacteristic? = null @@ -1557,27 +1743,33 @@ class G2 : SGCManager() { } } - private fun writeOnePacket(packet: ByteArray, left: Boolean, right: Boolean) { - if (right) { - rightWriteChar?.let { char -> - rightGatt?.let { gatt -> - char.value = packet - char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - bgcapNoteWriteResult(gatt.writeCharacteristic(char)) // BGCAP - } - } - } - if (left) { - leftWriteChar?.let { char -> - leftGatt?.let { gatt -> - char.value = packet - char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - bgcapNoteWriteResult(gatt.writeCharacteristic(char)) // BGCAP - } - } + /** + * One characteristic write. Shared by the UI path ([writeOnePacket]) and the file path + * ([writeFilePackets]), which differ only in the characteristic pair they target. No-ops if + * the leg isn't bound. [leg] non-null also logs the write — file transfers only; the UI path + * is far too hot for that. + */ + private fun writeTo( + char: BluetoothGattCharacteristic?, + gatt: BluetoothGatt?, + packet: ByteArray, + leg: String? + ) { + if (char == null || gatt == null) return + char.value = packet + char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + val ok = gatt.writeCharacteristic(char) + bgcapNoteWriteResult(ok) // BGCAP + if (leg != null) { + Bridge.log("G2/FILE: write $leg ${packet.size}B -> ${if (ok) "queued" else "DROPPED (stack busy)"}") } } + private fun writeOnePacket(packet: ByteArray, left: Boolean, right: Boolean) { + if (right) writeTo(rightWriteChar, rightGatt, packet, null) + if (left) writeTo(leftWriteChar, leftGatt, packet, null) + } + private fun sendToGlasses( packets: List, left: Boolean = false, @@ -1672,6 +1864,152 @@ class G2 : SGCManager() { sendToGlasses(packets) } + // ---------- Even File Service ---------- + + /** + * Ack slot. One transfer is ever in flight (serialized on [fileTransferMutex]), so one slot + * suffices. @Volatile + CompletableDeferred for the same reason as [pendingImgAck]: the ack + * lands on the BLE callback thread. + */ + @Volatile private var pendingFileAckCid: Int? = null + + @Volatile private var pendingFileAck: CompletableDeferred? = null + private val fileTransferMutex = Mutex() + + /** + * Generous on purpose. `sendToGlasses` enqueues rather than transmits, and the BLE write queue + * routinely runs 2-5s deep under display/audio load — a tighter timeout would measure our own + * queue latency instead of the glasses' response. + */ + private val FILE_ACK_TIMEOUT_MS = 15000L + + /** [ServiceID.FILE_CMD] carries the phase opcodes, [ServiceID.FILE_DATA] the raw file bytes. */ + private suspend fun sendOnFileService(serviceId: Byte, payload: ByteArray) { + // reserveFlag=false per the capture: every file frame carries status byte 0x00, where a + // set flag stamps 0x20 (what the multi-packet dashboard frames use). + writeFilePackets( + sendManager.buildPackets(serviceId = serviceId, payload = payload, reserveFlag = false) + ) + } + + /** + * File-service writes go to the bulk-group write characteristic ([G2BLE.FILE_WRITE], ATT + * 0x0882) — **not** the UI write characteristic every other service uses. A file opcode sent + * to the UI characteristic is silently ignored: no ack, no error. + * + * Right leg only, per the capture: every 0xC4/0xC5 frame went to the right connection, with + * ATT 0x0882/0x0884 active only there. Both legs expose the characteristic; the glasses relay + * internally. + * + * Paced like [sendToGlasses] — back-to-back WRITE_TYPE_NO_RESPONSE writes in a multi-fragment + * body get dropped as "stack busy" and read as an ack timeout. suspend + delay so the gap + * doesn't block the main looper. + */ + private suspend fun writeFilePackets(packets: List) { + if (rightFileWriteChar == null) { + Bridge.log("G2/FILE: no FILE WRITE characteristic bound — cannot send") + return + } + for ((index, packet) in packets.withIndex()) { + if (index > 0) delay(BLE_PACKET_GAP_MS) + writeTo(rightFileWriteChar, rightGatt, packet, "RIGHT") + } + } + + /** + * Arm the ack slot *before* transmitting, so a fast reply can't land while we aren't listening. + * Separate from [awaitFileAck] because the data phase transmits twice (the 0xC4 open and the + * 0xC5 bytes) against a single ack. + */ + private fun armFileAck(cid: Int): CompletableDeferred { + val ack = CompletableDeferred() + pendingFileAckCid = cid + pendingFileAck = ack + return ack + } + + /** Wait for the armed ack's `[cid][status]`. Returns the status, or null on timeout. */ + private suspend fun awaitFileAck(ack: CompletableDeferred): Int? { + val status = withTimeoutOrNull(FILE_ACK_TIMEOUT_MS) { ack.await() } + pendingFileAck = null + pendingFileAckCid = null + return status + } + + /** + * Push a file to the glasses over the Even File Service. Bodies of any length are fine — + * [G2SendManager.buildPackets] fragments correctly, verified by re-encoding a full capture + * byte-for-byte. + * + * Returns RESULT_CHECK's `eEvenFileServiceRsp` status (0 = SUCCESS), or null if a phase timed + * out. A failing phase returns its own status, so a checksum rejection (DATA_CRC_ERR at + * RESULT_CHECK) is distinguishable from a refusal at START. + */ + private suspend fun sendFile(fileType: Int, filename: String, bytes: ByteArray): Int? = + fileTransferMutex.withLock { + val crc32 = evenCrc32(bytes) + Bridge.log( + "G2/FILE: START type=$fileType len=${bytes.size} crc32=0x${ + String.format("%08X", crc32) + } name=$filename" + ) + + val startAck = armFileAck(FileService.CID_SEND_START) + sendOnFileService(ServiceID.FILE_CMD.value, FileService.sendStart(fileType, bytes.size, crc32, filename)) + val startStatus = awaitFileAck(startAck) + if (startStatus != 0) { + Bridge.log("G2/FILE: START failed — ${startStatus?.let(FileService::statusName) ?: "timeout"}") + return@withLock startStatus + } + + // The data phase opens and fills without an ack in between. The captured session is + // TX 0xC4 01 -> TX 0xC5 -> RX 0xC5 01 00 + // i.e. ONE ack, after the bytes. Waiting on the 0xC4 open stalls until timeout, since + // the glasses never ack it on its own. + val dataAck = armFileAck(FileService.CID_SEND_DATA) + sendOnFileService(ServiceID.FILE_CMD.value, FileService.sendData()) + // WRITE_TYPE_NO_RESPONSE writes go straight at the stack; back-to-back ones get + // dropped as "stack busy", and dropping the raw bytes here would read as a timeout. + delay(BLE_PACKET_GAP_MS) + sendOnFileService(ServiceID.FILE_DATA.value, bytes) + val dataStatus = awaitFileAck(dataAck) + if (dataStatus != 0) { + Bridge.log("G2/FILE: DATA failed — ${dataStatus?.let(FileService::statusName) ?: "timeout"}") + return@withLock dataStatus + } + + val checkAck = armFileAck(FileService.CID_SEND_RESULT_CHECK) + sendOnFileService(ServiceID.FILE_CMD.value, FileService.resultCheck()) + val checkStatus = awaitFileAck(checkAck) + Bridge.log( + "G2/FILE: RESULT_CHECK — ${checkStatus?.let(FileService::statusName) ?: "timeout"}" + ) + // DATA_CRC_ERR means framing and struct were accepted and only the checksum is wrong. + if (checkStatus == 2) { + Bridge.log("G2/FILE: DATA_CRC_ERR — everything but evenCrc32 is correct") + } + return@withLock checkStatus + } + + /** + * SEND_START's `fileCrc32`: CRC-32/Castagnoli polynomial, but MSB-first and unreflected, with + * no final xor — not stock CRC-32C. Verified against all four captured transfers. + */ + private val evenCrc32Table: IntArray = + IntArray(256) { i -> + var c = i shl 24 + repeat(8) { c = if (c < 0) (c shl 1) xor 0x1EDC6F41 else c shl 1 } + c + } + + private fun evenCrc32(data: ByteArray): Int { + var crc = 0 + for (byte in data) { + crc = (crc shl 8) xor evenCrc32Table[((byte.toInt() and 0xFF) xor (crc ushr 24)) and 0xFF] + } + return crc + } + private fun sendMenuCommand(payload: ByteArray) { val packets = sendManager.buildPackets( @@ -3557,6 +3895,7 @@ class G2 : SGCManager() { activeMenuAppId = null lastClickTimestamp = null lastMenuSelectTimestamp = null + notificationCentreArmed = false DeviceStore.apply("glasses", "connected", false) DeviceStore.apply("glasses", "fullyBooted", false) } @@ -3574,6 +3913,7 @@ class G2 : SGCManager() { rightWriteChar = null leftNotifyChar = null rightNotifyChar = null + rightFileWriteChar = null leftAudioChar = null rightAudioChar = null DEVICE_SEARCH_ID = "NOT_SET" @@ -3722,6 +4062,145 @@ class G2 : SGCManager() { Bridge.log("G2: Sent RING_DISCONNECT_INFO for MAC $mac") } + // ---------- Native Notification Centre ---------- + + /** + * Set the notification centre's control plane: whether it's on, and whether the glasses filter + * against the whitelist file they hold. Two commands with a gap — back-to-back writes on this + * service have been seen to drop. + */ + private suspend fun pinControlPlane(notifEnable: Int, whitelistDisable: Int) { + Bridge.log("G2/NOTIF: pinning notifEnable=$notifEnable whitelistDisable=$whitelistDisable") + sendToGlasses( + sendManager.buildPackets( + serviceId = ServiceID.NOTIFICATION.value, + payload = NotificationProto.notificationCtrl( + magicRandom = sendManager.nextMagicRandom(), + notifEnable = notifEnable, + autoDispEnable = 1, + dispTime = 5, + avoidDisturbEnable = 0 + ), + reserveFlag = true + ) + ) + delay(400) + sendToGlasses( + sendManager.buildPackets( + serviceId = ServiceID.NOTIFICATION.value, + payload = NotificationProto.whitelistCtrl( + magicRandom = sendManager.nextMagicRandom(), + whitelistDisable = whitelistDisable + ), + reserveFlag = true + ) + ) + } + + /** + * Bounded hand-off from the Expo bridge thread to the BLE send loop. A push is three BLE round + * trips serialized behind [fileTransferMutex] — seconds under display load — so an unbounded + * queue would grow faster than it drains during a burst. DROP_OLDEST because a backlog of + * stale notifications is worth less than the newest one. + */ + private val notificationQueue = + Channel>(capacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + private var notificationPumpStarted = false + + /** + * Whether THIS connection has had its notification centre switched on. Armed lazily on the + * first push, so a user who never enables the feature never has these settings written; + * cleared on disconnect, since the glasses don't persist it and re-arming is two writes. + */ + @Volatile private var notificationCentreArmed = false + + /** + * Push a phone notification into the G2's own notification centre. Runs in PARALLEL with the + * normal MentraOS card — neither replaces nor suppresses it. Enqueue and return; the transfer + * happens on [notificationQueue]'s consumer. + */ + override fun sendPhoneNotification(notification: Map) { + ensureNotificationPump() + notificationQueue.trySend(notification) + } + + /** + * Start the single consumer coroutine, once. Synchronized because a racing check-then-set + * would start two pumps, and two transfers would then interleave on the one ack slot. + */ + @Synchronized + private fun ensureNotificationPump() { + if (notificationPumpStarted) return + notificationPumpStarted = true + displayScope.launch { + for (notification in notificationQueue) { + // One bad notification must not kill the pump for the process's lifetime. + try { + pushNotificationToCentre(notification) + } catch (e: Exception) { + Bridge.log("G2/NOTIF: push failed — ${e.message}") + } + } + } + } + + private suspend fun pushNotificationToCentre(notification: Map) { + val isFullyBooted = DeviceStore.get("glasses", "fullyBooted") as? Boolean ?: false + if (!isFullyBooted) { + Bridge.log("G2/NOTIF: glasses not ready - dropping") + return + } + + if (!notificationCentreArmed) { + // whitelistDisable=1 turns off the on-glass per-app filter, which otherwise drops + // every notification absent from the stored whitelist. We push no whitelist: + // per-app filtering already happens phone-side in the notification listener. + pinControlPlane(notifEnable = 1, whitelistDisable = 1) + notificationCentreArmed = true + Bridge.log("G2/NOTIF: centre armed (on-glass filtering disabled)") + } + + val packageName = notification["packageName"] as? String ?: "" + val appName = notification["appName"] as? String ?: "" + val title = notification["title"] as? String ?: "" + val subtitle = notification["subtitle"] as? String ?: "" + val body = notification["body"] as? String ?: "" + // JS numbers cross the bridge as Double - `as? Long`/`as? Int` would silently null out. + val timestampMs = (notification["timestampMs"] as? Number)?.toLong() ?: System.currentTimeMillis() + val action = (notification["action"] as? Number)?.toInt() ?: 0 + // Normally StatusBarNotification.getId(), already an int; the fallback covers made-up ids. + val msgId = (notification["notificationId"] as? String)?.toIntOrNull() ?: nextSyntheticMsgId() + + val bytes = NotificationJson.androidNotification( + msgId = msgId, + action = action, + appIdentifier = packageName, + title = title, + subtitle = subtitle, + message = body, + postTimeMs = timestampMs, + displayName = appName + ) + + // Length and package, never the text. + Bridge.log("G2/NOTIF: pushing ${bytes.size}B from $packageName msgId=$msgId action=$action") + val status = + sendFile(FileService.TYPE_ANDROID_MSG_JSON_NOTIFICATION, FileService.PATH_NOTIFY, bytes) + Bridge.log("G2/NOTIF: push -> ${status?.let(FileService::statusName) ?: "timeout"}") + } + + /** + * Fallback `msg_id` when the id isn't numeric. Four digits like the captured ones — a wider + * field costs payload budget, and the firmware has only been seen handling short ids. + */ + private var syntheticMsgId = 2000 + + private fun nextSyntheticMsgId(): Int { + syntheticMsgId = if (syntheticMsgId >= 9999) 2000 else syntheticMsgId + 1 + return syntheticMsgId + } + // ---------- SGCManager: Device Control ---------- override fun setHeadUpAngle(angle: Int) { @@ -4099,6 +4578,7 @@ class G2 : SGCManager() { rightWriteChar = null leftNotifyChar = null rightNotifyChar = null + rightFileWriteChar = null leftAudioChar = null rightAudioChar = null authStarted = false @@ -4109,6 +4589,7 @@ class G2 : SGCManager() { pageCreated = false dashboardShowing = 0 dashboardOpening = false + notificationCentreArmed = false DeviceStore.apply("glasses", "connected", false) DeviceStore.apply("glasses", "fullyBooted", false) @@ -4170,6 +4651,16 @@ class G2 : SGCManager() { enqueueGattOp { enableNotifications(gatt, char) } } + G2BLE.FILE_WRITE -> { + Bridge.log("G2: Found FILE WRITE char on $side") + if (side != "LEFT") rightFileWriteChar = char + } + + G2BLE.FILE_NOTIFY -> { + Bridge.log("G2: Found FILE NOTIFY char on $side") + enqueueGattOp { enableNotifications(gatt, char) } + } + G2BLE.AUDIO_NOTIFY -> { Bridge.log("G2: Found AUDIO char on $side") if (side == "LEFT") leftAudioChar = char @@ -4210,6 +4701,10 @@ class G2 : SGCManager() { val sourceKey = if (side == "LEFT") "L" else "R" when (characteristic.uuid) { + // File-service acks arrive on their own characteristic and share the standard + // transport framing, so they need no demux — just the usual decode. + G2BLE.FILE_NOTIFY -> mainHandler.post { handleNotifyData(data, sourceKey) } + G2BLE.AUDIO_NOTIFY -> handleAudioData(data, sourceKey) G2BLE.CHAR_NOTIFY -> { // Correlate an in-flight image ACK INLINE on the BLE callback thread, before @@ -4341,6 +4836,9 @@ class G2 : SGCManager() { ServiceID.NAVIGATION.value -> handleNavigationResponse(payload) ServiceID.EVEN_AI.value -> handleEvenAIResponse(payload) ServiceID.EVEN_HUB_CTRL.value -> handleEvenHubCtrlResponse(payload) + ServiceID.NOTIFICATION.value -> handleNotificationResponse(payload) + ServiceID.FILE_CMD.value -> handleFileServiceResponse(payload) + ServiceID.FILE_DATA.value -> handleFileServiceResponse(payload) else -> { Bridge.log( "G2: Unhandled service ${serviceId.toInt() and 0xFF} (${payload.size} bytes): ${ @@ -4351,6 +4849,66 @@ class G2 : SGCManager() { } } + /** + * Even File Service acks. A real ack is **exactly two bytes** — `[cid][status]`. Resolves + * whichever phase [sendFile] is waiting on. + * + * The size check is load-bearing: our own transmissions come back on this characteristic, and + * with the transport header stripped they present as payloads starting with the very CID we + * just sent. Accepting those as SUCCESS makes every phase pass unconditionally, including the + * RESULT_CHECK that verifies the checksum. + */ + private fun handleFileServiceResponse(payload: ByteArray) { + if (payload.isEmpty()) { + Bridge.log("G2/FILE: empty response") + return + } + + val cid = payload[0].toInt() and 0xFF + if (payload.size != 2 || cid > FileService.CID_SEND_RESULT_CHECK) { + Bridge.log("G2/FILE: ignoring ${payload.size}B frame — our own echo, not an ack") + return + } + + val status = payload[1].toInt() and 0xFF + Bridge.log("G2/FILE: ACK cid=$cid status=$status (${FileService.statusName(status)})") + + if (pendingFileAckCid == cid) { + pendingFileAck?.complete(status) + } else { + Bridge.log("G2/FILE: ack cid=$cid ignored — waiting on ${pendingFileAckCid ?: "nothing"}") + } + } + + /** + * Notification service (0x04). Log-only — it acknowledges [pinControlPlane] and reports the + * glasses' own notification activity; content never travels here. Field map in + * `notes/g2-notification-service.md`. + */ + private fun handleNotificationResponse(payload: ByteArray) { + val fields = ProtobufReader(payload).parseFields() + val cmd = fields[1] as? Int ?: -1 + val magic = fields[2] as? Int + + // COMM_RSP carries field 5 = {f1=commandId that failed, f2=errorCode}. + var verdict = "" + if (cmd == NotificationProto.CMD_COMM_RSP) { + val rsp = (fields[5] as? ByteArray)?.let { ProtobufReader(it).parseFields() } + val failedCmd = rsp?.get(1) as? Int + val errorCode = rsp?.get(2) as? Int + verdict = + " REJECTED cmd=${failedCmd?.let { NotificationProto.cmdName(it) } ?: "?"}" + + " errorCode=$errorCode" + + (if (errorCode == 8) " (NOT_SUPPORT)" else "") + } + + Bridge.log( + "G2/NOTIF: RX ${NotificationProto.cmdName(cmd)}" + + (magic?.let { " magic=$it" } ?: "") + + verdict + ) + } + /** * EvenAI service (0x07). Logs the decoded EvenAIDataPackage so we can read the CONFIG * (Hey Even) echo: commandId=10 (CONFIG), config sub-message in field 13. diff --git a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt index 79ab867b73..b8bfd5976a 100644 --- a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +++ b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt @@ -254,6 +254,17 @@ abstract class SGCManager { // Notification Panel (default no-op — only G2 supports this) open suspend fun showNotificationsPanel() {} + /** + * Push a phone notification into the glasses' OWN notification centre (default no-op — + * only G2 supports this). Runs *alongside* the normal MentraOS flow. + * + * Fire-and-forget - enqueue and return; never block the Expo bridge thread on BLE. + * + * Keys: `notificationId`, `packageName`, `appName`, `title`, `subtitle`, `body`, + * `timestampMs`, `action` (0 = posted). JS numbers arrive as Double — read via `as? Number`. + */ + open fun sendPhoneNotification(notification: Map) {} + // Controller bridging (default no-op — only G2 supports pairing with a ring controller) open fun connectController() {} open fun disconnectController() {} diff --git a/mobile/modules/bluetooth-sdk/ios/BluetoothSdkModule.swift b/mobile/modules/bluetooth-sdk/ios/BluetoothSdkModule.swift index aa5eafc229..8b9d693092 100644 --- a/mobile/modules/bluetooth-sdk/ios/BluetoothSdkModule.swift +++ b/mobile/modules/bluetooth-sdk/ios/BluetoothSdkModule.swift @@ -281,6 +281,21 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate { } } + // MARK: - Native Notification Centre (internal, Android/G2 only) + + // Parity stub: iOS drivers all inherit the protocol no-op (glasses read notifications + // over ANCS). The JS bridge is Android-gated, so a log line here means something + // bypassed that gate. + AsyncFunction("sendPhoneNotification") { (notification: [String: Any]) in + // Package only — never the notification text. + Bridge.log( + "MAN: sendPhoneNotification from \(notification["packageName"] ?? "unknown") — ignored, iOS uses ANCS" + ) + await MainActor.run { + DeviceManager.shared.sgc?.sendPhoneNotification(notification) + } + } + // MARK: - WiFi Commands AsyncFunction("requestWifiScan") { diff --git a/mobile/modules/bluetooth-sdk/ios/Source/sgcs/SGCManager.swift b/mobile/modules/bluetooth-sdk/ios/Source/sgcs/SGCManager.swift index dcb61ea144..c07ef655bc 100644 --- a/mobile/modules/bluetooth-sdk/ios/Source/sgcs/SGCManager.swift +++ b/mobile/modules/bluetooth-sdk/ios/Source/sgcs/SGCManager.swift @@ -144,6 +144,12 @@ protocol SGCManager { func showNotificationsPanel() async + /// Push a phone notification into the glasses' own notification centre. + /// + /// Android/G2 only in practice — iOS glasses read notifications over ANCS. Declared for + /// parity so the shared TypeScript module type is callable on both platforms. + func sendPhoneNotification(_ notification: [String: Any]) + // MARK: - Calendar Events func sendCalendarEvents(_ events: [[String: Any]]) @@ -351,6 +357,10 @@ extension SGCManager { func showNotificationsPanel() async {} + // MARK: - Native notification centre (default no-op — Android/G2 only; iOS uses ANCS) + + func sendPhoneNotification(_: [String: Any]) {} + // MARK: - IMU (default no-op — only G2 streams accelerometer data) func setImuEnabled(_: Bool) async { diff --git a/mobile/modules/bluetooth-sdk/src/BluetoothSdk.types.ts b/mobile/modules/bluetooth-sdk/src/BluetoothSdk.types.ts index af782cbc76..95722d0bad 100644 --- a/mobile/modules/bluetooth-sdk/src/BluetoothSdk.types.ts +++ b/mobile/modules/bluetooth-sdk/src/BluetoothSdk.types.ts @@ -950,6 +950,29 @@ export interface PhoneNotificationDismissedEvent { timestamp: number } +/** + * Outbound payload for `sendPhoneNotification` — a notification pushed INTO the glasses' own + * notification centre. The inverse of {@link PhoneNotificationEvent}, which reports + * notifications the glasses relayed TO the phone (iOS/ANCS). Drivers map these keys onto + * whatever their firmware expects. + */ +export interface NativePhoneNotification { + /** Stable id from the phone's notification listener; parsed to an int where firmware needs one. */ + notificationId: string + /** Reverse-DNS package id of the originating app. */ + packageName: string + /** Human app name (e.g. "Messages"). */ + appName: string + title: string + /** Android `android.subText`. Empty when the listener didn't capture one. */ + subtitle: string + body: string + /** Unix ms post time. */ + timestampMs: number + /** 0 = posted. Non-zero is reserved for the removal path. */ + action: number +} + export type PublicGlassesStatus = Omit< GlassesStatus, "otaUpdateAvailable" | "otaProgress" | "otaInProgress" | "otaVersionUrl" diff --git a/mobile/modules/bluetooth-sdk/src/_private/BluetoothSdkModule.ts b/mobile/modules/bluetooth-sdk/src/_private/BluetoothSdkModule.ts index dd40ee5ba2..918a526d7e 100644 --- a/mobile/modules/bluetooth-sdk/src/_private/BluetoothSdkModule.ts +++ b/mobile/modules/bluetooth-sdk/src/_private/BluetoothSdkModule.ts @@ -26,6 +26,7 @@ import { GalleryStatusEvent, HotspotStatusChangeEvent, MicPreference, + NativePhoneNotification, ObservableStoreCategory, OtaQueryResult, OtaStartAckEvent, @@ -250,6 +251,13 @@ declare class BluetoothSdkNativeModule extends NativeModule + /** + * Push a phone notification into the glasses' OWN notification centre - parallel to + * the normal flow (miniapp forwarding + a locally drawn card). + * Implemented only by Android G2 driver; every other driver inherits a no-op. + */ + sendPhoneNotification(notification: NativePhoneNotification): Promise + // Helper methods for type-safe observable store access updateGlasses(values: Partial): Promise updateBluetoothSettings(values: BluetoothSettingsUpdate): Promise @@ -267,7 +275,7 @@ export type BluetoothSdkInternalModule = BluetoothSdkNativeModule const NativeBluetoothSdkModule = requireNativeModule("BluetoothSdk") const DEFAULT_CONNECT_OPTIONS: Required = { - saveAsDefault: true, + saveAsDefault: true, cancelExistingConnectionAttempt: true, } @@ -293,7 +301,7 @@ const CAMERA_ROI_POSITION_VALUES: Record = { narrow: {fov: 82, roiPosition: 0}, standard: {fov: 102, roiPosition: 0}, diff --git a/mobile/modules/crust/src/Crust.types.ts b/mobile/modules/crust/src/Crust.types.ts index 1bc9806b04..33d718832f 100644 --- a/mobile/modules/crust/src/Crust.types.ts +++ b/mobile/modules/crust/src/Crust.types.ts @@ -14,6 +14,9 @@ export type CrustModuleEvents = { onNavRoute: (params: NavRoutePayload) => void onNavOffRoute: (params: NavOffRoutePayload) => void onHeading: (params: HeadingPayload) => void + phone_notification: (event: PhoneNotificationEvent) => void + phone_notification_dismissed: (event: PhoneNotificationDismissedEvent) => void + captions_tester_incident: (event: CaptionsTesterIncidentEvent) => void } export type NavOffRoutePayload = { @@ -65,9 +68,6 @@ export type NavLocationPayload = { accuracy: number | null /** Unix ms timestamp of the fix. */ timestamp: number - phone_notification: (event: PhoneNotificationEvent) => void - phone_notification_dismissed: (event: PhoneNotificationDismissedEvent) => void - captions_tester_incident: (event: CaptionsTesterIncidentEvent) => void } export type ChangeEventPayload = { diff --git a/mobile/modules/engine/src/engine.ts b/mobile/modules/engine/src/engine.ts index 4d92999077..9a4883d329 100644 --- a/mobile/modules/engine/src/engine.ts +++ b/mobile/modules/engine/src/engine.ts @@ -17,6 +17,7 @@ import {startOtaService, stopOtaService} from "./services/OtaService" import {startAudioCloudUplink, stopAudioCloudUplink} from "./services/AudioCloudUplink" import {startDeviceEventRouter, stopDeviceEventRouter} from "./services/DeviceEventRouter" import {startPhoneNotificationsSync, stopPhoneNotificationsSync} from "./services/PhoneNotificationsSync" +import {startG2NotificationBridge, stopG2NotificationBridge} from "./services/G2NotificationBridge" import {startCaptionsTesterReportService, stopCaptionsTesterReportService} from "./services/CaptionsTesterReportService" import { startMentraJSCrashloopReportService, @@ -101,6 +102,10 @@ export const engine = { startGlassesSettingsSync() // Same for phone-notification config -> the native listener (Android). startPhoneNotificationsSync() + // Optional parallel path: also push notifications into the G2's own notification centre. + // Inert unless the super-mode toggle is on and a G2 is connected; never affects the + // normal flow (miniapp forwarding + the locally drawn Notify card). + startG2NotificationBridge() // Android internal/e2e: laptop captions tester can broadcast a failure intent; // engine owns turning that into a Cloud V2 report. startCaptionsTesterReportService() @@ -136,6 +141,7 @@ export const engine = { await safely("ota service", stopOtaService) await safely("audio cloud uplink", stopAudioCloudUplink) await safely("phone notifications sync", stopPhoneNotificationsSync) + await safely("g2 notification bridge", stopG2NotificationBridge) await safely("captions tester report service", stopCaptionsTesterReportService) await safely("mentrajs crashloop report service", stopMentraJSCrashloopReportService) await safely("miniapp engine", stopMiniappEngine) diff --git a/mobile/modules/engine/src/services/G2NotificationBridge.ts b/mobile/modules/engine/src/services/G2NotificationBridge.ts new file mode 100644 index 0000000000..0f2dc5fe46 --- /dev/null +++ b/mobile/modules/engine/src/services/G2NotificationBridge.ts @@ -0,0 +1,67 @@ +/** + * G2 native-notification bridge - engine-owned. Pushes captured phone notifications into the + * G2's OWN notification centre via the bluetooth-sdk's `sendPhoneNotification` seam. + * + * Runs in parallel with the normal flow (miniapp forwarding, plus a + * locally drawn card and speech when Notify is running). Both subscribe to the same native `phone_notification` + * event independently, so turning this off changes nothing about it. + * + * Gated on: Android (capture is the NotificationListenerService; iOS glasses use ANCS), the + * `g2_native_notifications` toggle, and a connected G2. Deliberately NOT gated on Notify + * running, on connection state (native drops it), or on a whitelist. The per-app blocklist is + * honoured for free — the native listener applies it before emitting. + * + * Started by `engine.start()`. Idempotent. + */ +import {Platform} from "react-native" +import CrustModule from "@mentra/crust" +import type {PhoneNotificationEvent} from "@mentra/crust" +import BluetoothSdk from "@mentra/bluetooth-sdk/internal" + +import {useSettingsStore, SETTINGS} from "../stores/settings" +import {useGlassesStore} from "../stores/glasses" +import {DeviceTypes} from "../types/enums" + +let subscription: {remove: () => void} | null = null + +function shouldPush(): boolean { + if (Platform.OS !== "android") return false + if (!useSettingsStore.getState().getSetting(SETTINGS.g2_native_notifications.key)) return false + return useGlassesStore.getState().deviceModel === DeviceTypes.G2 +} + +function forward(event: PhoneNotificationEvent): void { + if (!shouldPush()) return + + void Promise.resolve( + BluetoothSdk.sendPhoneNotification({ + notificationId: String(event.notificationId ?? ""), + packageName: String(event.packageName ?? ""), + appName: String(event.app ?? ""), + title: String(event.title ?? ""), + // Empty until the native listener reads `android.subText` — a listener change, not a + // protocol one. The glasses' schema has the field, so it stays in the payload. + subtitle: "", + body: String(event.content ?? ""), + timestampMs: Number(event.timestamp ?? Date.now()), + // 0 = posted. Dismissals would map to a non-zero action; not wired yet. + action: 0, + }), + ).catch((err: unknown) => + // Never log the notification text. + console.warn(`G2NotificationBridge: sendPhoneNotification failed: ${(err as Error)?.message ?? err}`), + ) +} + +export function startG2NotificationBridge(): void { + if (subscription) return + // Subscribe unconditionally on Android; the toggle is read per-notification instead, so + // flipping it takes effect immediately with no resubscribe and no stale-listener window. + if (Platform.OS !== "android") return + subscription = CrustModule.addListener("phone_notification", forward) +} + +export function stopG2NotificationBridge(): void { + subscription?.remove() + subscription = null +} diff --git a/mobile/modules/engine/src/services/__tests__/G2NotificationBridge.test.ts b/mobile/modules/engine/src/services/__tests__/G2NotificationBridge.test.ts new file mode 100644 index 0000000000..58a84fb2fe --- /dev/null +++ b/mobile/modules/engine/src/services/__tests__/G2NotificationBridge.test.ts @@ -0,0 +1,126 @@ +/// + +import {beforeEach, describe, expect, mock, test} from "bun:test" + +// The bridge is Android-only, so the whole suite runs as Android. +mock.module("react-native", () => ({ + __esModule: true, + Platform: {OS: "android"}, +})) + +let capturedHandler: ((event: unknown) => void) | null = null +const mockRemove = mock(() => {}) +const mockAddListener = mock((_event: string, handler: (event: unknown) => void) => { + capturedHandler = handler + return {remove: mockRemove} +}) +mock.module("@mentra/crust", () => ({ + __esModule: true, + default: {addListener: mockAddListener}, +})) + +const mockSendPhoneNotification = mock(() => Promise.resolve()) +mock.module("@mentra/bluetooth-sdk/internal", () => ({ + __esModule: true, + default: {sendPhoneNotification: mockSendPhoneNotification}, +})) + +const values: Record = {} +mock.module("../../stores/settings", () => ({ + SETTINGS: {g2_native_notifications: {key: "g2_native_notifications"}}, + useSettingsStore: { + getState: () => ({getSetting: (key: string) => values[key]}), + }, +})) + +let deviceModel = "Even Realities G2" +mock.module("../../stores/glasses", () => ({ + useGlassesStore: { + getState: () => ({deviceModel}), + }, +})) + +const {startG2NotificationBridge, stopG2NotificationBridge} = require("../G2NotificationBridge") + +const NOTIFICATION = { + notificationId: "1237", + app: "Snapchat", + title: "Sarah", + content: "is typing...", + priority: 1, + timestamp: 1785737563000, + packageName: "com.snapchat.android", +} + +describe("G2NotificationBridge", () => { + beforeEach(() => { + stopG2NotificationBridge() + mockAddListener.mockClear() + mockRemove.mockClear() + mockSendPhoneNotification.mockClear() + capturedHandler = null + deviceModel = "Even Realities G2" + values.g2_native_notifications = true + }) + + test("pushes to the glasses when the toggle is on and a G2 is connected", () => { + startG2NotificationBridge() + capturedHandler?.(NOTIFICATION) + + expect(mockSendPhoneNotification).toHaveBeenCalledWith({ + notificationId: "1237", + packageName: "com.snapchat.android", + appName: "Snapchat", + title: "Sarah", + subtitle: "", + body: "is typing...", + timestampMs: 1785737563000, + action: 0, + }) + }) + + test("pushes nothing while the toggle is off", () => { + values.g2_native_notifications = false + startG2NotificationBridge() + capturedHandler?.(NOTIFICATION) + + expect(mockSendPhoneNotification).not.toHaveBeenCalled() + }) + + test("pushes nothing on non-G2 glasses", () => { + deviceModel = "Mentra Live" + startG2NotificationBridge() + capturedHandler?.(NOTIFICATION) + + expect(mockSendPhoneNotification).not.toHaveBeenCalled() + }) + + test("reads the toggle per notification, so flipping it needs no resubscribe", () => { + startG2NotificationBridge() + + values.g2_native_notifications = false + capturedHandler?.(NOTIFICATION) + expect(mockSendPhoneNotification).not.toHaveBeenCalled() + + values.g2_native_notifications = true + capturedHandler?.(NOTIFICATION) + expect(mockSendPhoneNotification).toHaveBeenCalledTimes(1) + expect(mockAddListener).toHaveBeenCalledTimes(1) + }) + + test("subscribes once across repeated starts and unsubscribes on stop", () => { + startG2NotificationBridge() + startG2NotificationBridge() + expect(mockAddListener).toHaveBeenCalledTimes(1) + + stopG2NotificationBridge() + expect(mockRemove).toHaveBeenCalledTimes(1) + }) + + test("a failed push is swallowed rather than surfacing as an unhandled rejection", () => { + mockSendPhoneNotification.mockImplementationOnce(() => Promise.reject(new Error("not connected"))) + startG2NotificationBridge() + + expect(() => capturedHandler?.(NOTIFICATION)).not.toThrow() + }) +}) diff --git a/mobile/modules/engine/src/stores/settings.ts b/mobile/modules/engine/src/stores/settings.ts index ac19d0c886..2b80c7217e 100644 --- a/mobile/modules/engine/src/stores/settings.ts +++ b/mobile/modules/engine/src/stores/settings.ts @@ -148,6 +148,16 @@ export const SETTINGS: Record = { saveOnServer: false, persist: true, }, + // When on, notifications are ALSO pushed into the G2's own notification centre, + // alongside the normal MentraOS card + speech rather than replacing it. Android + + // G2 only. Local-only (saveOnServer: false), and OFF by default. + g2_native_notifications: { + key: "g2_native_notifications", + defaultValue: () => false, + writable: true, + saveOnServer: false, + persist: true, + }, china_deployment: { key: "china_deployment", defaultValue: () => (process.env.EXPO_PUBLIC_DEPLOYMENT_REGION === "china" ? true : false), diff --git a/mobile/src/app/miniapps/settings/super.tsx b/mobile/src/app/miniapps/settings/super.tsx index 7eb1ea1cab..24a90d6693 100644 --- a/mobile/src/app/miniapps/settings/super.tsx +++ b/mobile/src/app/miniapps/settings/super.tsx @@ -1,6 +1,7 @@ -import {ScrollView, View} from "react-native" +import {Platform, ScrollView, View} from "react-native" import BluetoothSdk from "@mentra/bluetooth-sdk-internal" +import {DeviceTypes} from "@/../../cloud/packages/types/src" import {Header, Screen} from "@/components/ignite" import ToggleSetting from "@/components/settings/ToggleSetting" import {Group} from "@/components/ui/Group" @@ -19,6 +20,11 @@ export default function SuperSettingsScreen() { const [iosAppSwitcherBottomSwipe, setIosAppSwitcherBottomSwipe] = useSetting( SETTINGS.ios_app_switcher_bottom_swipe.key, ) + const [defaultWearable] = useSetting(SETTINGS.default_wearable.key) + const [g2NativeNotifications, setG2NativeNotifications] = useSetting(SETTINGS.g2_native_notifications.key) + // Needs both halves: the Android notification listener and the G2 driver. iOS glasses read + // notifications over ANCS and never take a pushed one. + const showG2NativeNotifications = Platform.OS === "android" && defaultWearable === DeviceTypes.G2 const {push} = useNavigationStore.getState() return ( @@ -52,6 +58,14 @@ export default function SuperSettingsScreen() { onValueChange={(value) => setUseNativeDashboard(value)} /> + {showG2NativeNotifications && ( + setG2NativeNotifications(value)} + /> + )} + Date: Sun, 9 Aug 2026 14:31:26 -0700 Subject: [PATCH 2/4] Keep glasses notification ids stable across updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone id is "$packageName-${sbn.key}" — never numeric — so every push minted a fresh synthetic msg_id and updates stacked as duplicate cards on the glasses. Memoize phone id -> msg_id (LRU, 512 entries) so re-posts of the same notification reuse their msg_id and replace the card in place. Pin the real id shape in the bridge test fixture. --- .../java/com/mentra/bluetoothsdk/sgcs/G2.kt | 26 ++++++++++++++++--- .../__tests__/G2NotificationBridge.test.ts | 5 ++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt index afc250ead9..77f6871aa5 100644 --- a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +++ b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt @@ -4169,8 +4169,7 @@ class G2 : SGCManager() { // JS numbers cross the bridge as Double - `as? Long`/`as? Int` would silently null out. val timestampMs = (notification["timestampMs"] as? Number)?.toLong() ?: System.currentTimeMillis() val action = (notification["action"] as? Number)?.toInt() ?: 0 - // Normally StatusBarNotification.getId(), already an int; the fallback covers made-up ids. - val msgId = (notification["notificationId"] as? String)?.toIntOrNull() ?: nextSyntheticMsgId() + val msgId = msgIdFor(notification["notificationId"] as? String ?: "") val bytes = NotificationJson.androidNotification( msgId = msgId, @@ -4191,8 +4190,27 @@ class G2 : SGCManager() { } /** - * Fallback `msg_id` when the id isn't numeric. Four digits like the captured ones — a wider - * field costs payload budget, and the firmware has only been seen handling short ids. + * The glasses key notification cards on a numeric `msg_id`; the phone id is a string + * ("$packageName-${sbn.key}"), so ids are minted here. The same phone id keeps the same + * msg_id while it stays in the LRU: a notification re-posts under its id on update, and + * reusing the msg_id makes the glasses replace the card in place rather than stack a + * duplicate. Numeric phone ids pass straight through; an empty id gets a fresh mint, since + * there is nothing to correlate on. Only touched from [notificationQueue]'s single consumer. + */ + private val msgIdsByPhoneId = + object : LinkedHashMap(32, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = size > 512 + } + + private fun msgIdFor(notificationId: String): Int { + notificationId.toIntOrNull()?.let { return it } + if (notificationId.isEmpty()) return nextSyntheticMsgId() + return msgIdsByPhoneId.getOrPut(notificationId) { nextSyntheticMsgId() } + } + + /** + * Four digits like the captured ones — a wider field costs payload budget, and the firmware + * has only been seen handling short ids. */ private var syntheticMsgId = 2000 diff --git a/mobile/modules/engine/src/services/__tests__/G2NotificationBridge.test.ts b/mobile/modules/engine/src/services/__tests__/G2NotificationBridge.test.ts index 58a84fb2fe..f1a6e51be3 100644 --- a/mobile/modules/engine/src/services/__tests__/G2NotificationBridge.test.ts +++ b/mobile/modules/engine/src/services/__tests__/G2NotificationBridge.test.ts @@ -43,7 +43,8 @@ mock.module("../../stores/glasses", () => ({ const {startG2NotificationBridge, stopG2NotificationBridge} = require("../G2NotificationBridge") const NOTIFICATION = { - notificationId: "1237", + // Real shape from the native listener: "$packageName-${sbn.key}" — never a plain int. + notificationId: "com.snapchat.android-0|com.snapchat.android|1237|null|10203", app: "Snapchat", title: "Sarah", content: "is typing...", @@ -68,7 +69,7 @@ describe("G2NotificationBridge", () => { capturedHandler?.(NOTIFICATION) expect(mockSendPhoneNotification).toHaveBeenCalledWith({ - notificationId: "1237", + notificationId: "com.snapchat.android-0|com.snapchat.android|1237|null|10203", packageName: "com.snapchat.android", appName: "Snapchat", title: "Sarah", From ce87eb9810c136836cd6ac4662381d73ca2d3f07 Mon Sep 17 00:00:00 2001 From: aheschl1 Date: Sun, 9 Aug 2026 14:47:28 -0700 Subject: [PATCH 3/4] Address notification review findings - Fail sendFile before arming the ack when no FILE WRITE characteristic is bound, instead of stalling the pump for the full 15s ack timeout. - Skip minted msg_ids still mapped to a live phone id, so the synthetic counter wrapping cannot make the glasses replace the wrong card. - Drop the notification-centre armed latch when the glasses refuse a CTRL/WHITELIST_CTRL pin, so the next push re-pins instead of pushing into a disabled centre. - Remove trailing whitespace in DEFAULT_CONNECT_OPTIONS. --- .../java/com/mentra/bluetoothsdk/sgcs/G2.kt | 32 ++++++++++++++++--- .../src/_private/BluetoothSdkModule.ts | 2 +- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt index 77f6871aa5..e5f1a5e9ce 100644 --- a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +++ b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt @@ -1947,6 +1947,12 @@ class G2 : SGCManager() { */ private suspend fun sendFile(fileType: Int, filename: String, bytes: ByteArray): Int? = fileTransferMutex.withLock { + if (rightFileWriteChar == null) { + // Checked before arming: an armed ack with nothing transmitted holds the caller + // on a wait that can only time out. + Bridge.log("G2/FILE: no FILE WRITE characteristic bound — cannot send") + return@withLock null + } val crc32 = evenCrc32(bytes) Bridge.log( "G2/FILE: START type=$fileType len=${bytes.size} crc32=0x${ @@ -4111,7 +4117,8 @@ class G2 : SGCManager() { /** * Whether THIS connection has had its notification centre switched on. Armed lazily on the * first push, so a user who never enables the feature never has these settings written; - * cleared on disconnect, since the glasses don't persist it and re-arming is two writes. + * cleared on disconnect and on a refused pin, since the glasses don't persist it and + * re-arming is two writes. */ @Volatile private var notificationCentreArmed = false @@ -4204,16 +4211,26 @@ class G2 : SGCManager() { private fun msgIdFor(notificationId: String): Int { notificationId.toIntOrNull()?.let { return it } - if (notificationId.isEmpty()) return nextSyntheticMsgId() - return msgIdsByPhoneId.getOrPut(notificationId) { nextSyntheticMsgId() } + if (notificationId.isEmpty()) return mintMsgId() + return msgIdsByPhoneId.getOrPut(notificationId) { mintMsgId() } } /** * Four digits like the captured ones — a wider field costs payload budget, and the firmware - * has only been seen handling short ids. + * has only been seen handling short ids. Minting skips ids still mapped to a phone id: the + * counter wraps after 8000 mints while up to 512 mappings stay live, and a reused id would + * make the glasses replace the wrong card. Any 513 consecutive candidates contain a free id. */ private var syntheticMsgId = 2000 + private fun mintMsgId(): Int { + repeat(msgIdsByPhoneId.size + 1) { + val candidate = nextSyntheticMsgId() + if (!msgIdsByPhoneId.containsValue(candidate)) return candidate + } + return nextSyntheticMsgId() + } + private fun nextSyntheticMsgId(): Int { syntheticMsgId = if (syntheticMsgId >= 9999) 2000 else syntheticMsgId + 1 return syntheticMsgId @@ -4918,6 +4935,13 @@ class G2 : SGCManager() { " REJECTED cmd=${failedCmd?.let { NotificationProto.cmdName(it) } ?: "?"}" + " errorCode=$errorCode" + (if (errorCode == 8) " (NOT_SUPPORT)" else "") + // A refused pin means the centre is NOT armed, whatever the flag says; dropping the + // latch makes the next push re-pin instead of pushing into a disabled centre forever. + if (failedCmd == NotificationProto.CMD_CTRL || + failedCmd == NotificationProto.CMD_WHITELIST_CTRL + ) { + notificationCentreArmed = false + } } Bridge.log( diff --git a/mobile/modules/bluetooth-sdk/src/_private/BluetoothSdkModule.ts b/mobile/modules/bluetooth-sdk/src/_private/BluetoothSdkModule.ts index c4c218cf2e..1471e2ce13 100644 --- a/mobile/modules/bluetooth-sdk/src/_private/BluetoothSdkModule.ts +++ b/mobile/modules/bluetooth-sdk/src/_private/BluetoothSdkModule.ts @@ -277,7 +277,7 @@ export type BluetoothSdkInternalModule = BluetoothSdkNativeModule const NativeBluetoothSdkModule = requireNativeModule("BluetoothSdk") const DEFAULT_CONNECT_OPTIONS: Required = { - saveAsDefault: true, + saveAsDefault: true, cancelExistingConnectionAttempt: true, } From 9b904a1f8ada1ac77911db303f9c490a1da7d7ae Mon Sep 17 00:00:00 2001 From: aheschl1 Date: Sun, 9 Aug 2026 15:39:02 -0700 Subject: [PATCH 4/4] fix --- .../android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt index e5f1a5e9ce..08c37ddb7c 100644 --- a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +++ b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt @@ -4160,11 +4160,14 @@ class G2 : SGCManager() { } if (!notificationCentreArmed) { + // Set before the writes go out: a refusal can only arrive after its write, so the + // clear-on-reject always lands after this set. Set after the pin instead, a refusal + // arriving inside the pin's inter-write gap would be overwritten. + notificationCentreArmed = true // whitelistDisable=1 turns off the on-glass per-app filter, which otherwise drops // every notification absent from the stored whitelist. We push no whitelist: // per-app filtering already happens phone-side in the notification listener. pinControlPlane(notifEnable = 1, whitelistDisable = 1) - notificationCentreArmed = true Bridge.log("G2/NOTIF: centre armed (on-glass filtering disabled)") }