mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-03 00:54:34 -04:00
fix(notifications): put every notification on its own channel and tap target (#7415)
This commit is contained in:
1 parent
84095fd93e
commit
667d9f34dc
50 files changed
+1876
-1492
No files matched your search
Generated
+16
@@ -1040,15 +1040,28 @@ mesh_map_location_description
|
||||
### MESHTASTIC ###
|
||||
meshtastic
|
||||
meshtastic_alerts_notifications
|
||||
meshtastic_alerts_notifications_description
|
||||
meshtastic_app_name
|
||||
meshtastic_broadcast_notifications
|
||||
meshtastic_broadcast_notifications_description
|
||||
meshtastic_client_notifications
|
||||
meshtastic_client_notifications_description
|
||||
meshtastic_device_status_notifications
|
||||
meshtastic_device_status_notifications_description
|
||||
meshtastic_low_battery_notifications
|
||||
meshtastic_low_battery_notifications_description
|
||||
meshtastic_low_battery_temporary_remote_notifications
|
||||
meshtastic_low_battery_temporary_remote_notifications_description
|
||||
meshtastic_mesh_beacon_notifications
|
||||
meshtastic_mesh_beacon_notifications_description
|
||||
meshtastic_messages_notifications
|
||||
meshtastic_messages_notifications_description
|
||||
meshtastic_new_nodes_notifications
|
||||
meshtastic_new_nodes_notifications_description
|
||||
meshtastic_service_notifications
|
||||
meshtastic_service_notifications_description
|
||||
meshtastic_waypoints_notifications
|
||||
meshtastic_waypoints_notifications_description
|
||||
### MESSAGE ###
|
||||
message
|
||||
message_delivery_status
|
||||
@@ -1267,10 +1280,13 @@ not_connected
|
||||
not_now
|
||||
note
|
||||
notes
|
||||
### NOTIFICATION ###
|
||||
notification_group_mesh
|
||||
notification_permission_blocked_notice
|
||||
notification_permission_denied_notice
|
||||
notification_permission_rationale
|
||||
notification_permissions_description
|
||||
notification_reaction_to
|
||||
### NOTIFICATIONS ###
|
||||
notifications_for_channel_and_direct_messages
|
||||
notifications_for_low_battery_alerts
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
|
||||
<!-- API 33+ Notification runtime permissions -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- API 36.1+ lets a running firmware update show as a Live Update -->
|
||||
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
|
||||
|
||||
<!-- Permissions required for providing location (from phone GPS) to mesh -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.service
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.mock
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.Telemetry
|
||||
|
||||
class Fakes {
|
||||
val service: RadioInterfaceService = mock(MockMode.autofill)
|
||||
}
|
||||
|
||||
class FakeMeshNotificationManager : MeshNotificationManager {
|
||||
override fun clearNotifications() {}
|
||||
|
||||
override fun initChannels() {}
|
||||
|
||||
override fun updateServiceStateNotification(
|
||||
state: org.meshtastic.core.model.ConnectionState,
|
||||
telemetry: Telemetry?,
|
||||
) {}
|
||||
|
||||
override suspend fun updateMessageNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
message: String,
|
||||
isBroadcast: Boolean,
|
||||
channelName: String?,
|
||||
isSilent: Boolean,
|
||||
) {}
|
||||
|
||||
override suspend fun updateWaypointNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
message: String,
|
||||
waypointId: Int,
|
||||
isSilent: Boolean,
|
||||
) {}
|
||||
|
||||
override suspend fun updateReactionNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
emoji: String,
|
||||
isBroadcast: Boolean,
|
||||
channelName: String?,
|
||||
isSilent: Boolean,
|
||||
) {}
|
||||
|
||||
override fun showAlertNotification(contactKey: String, name: String, alert: String) {}
|
||||
|
||||
override fun showNewNodeSeenNotification(node: Node) {}
|
||||
|
||||
override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {}
|
||||
|
||||
override fun showClientNotification(clientNotification: ClientNotification) {}
|
||||
|
||||
override suspend fun cancelMessageNotification(contactKey: String) {}
|
||||
|
||||
override fun cancelLowBatteryNotification(node: Node) {}
|
||||
|
||||
override fun clearClientNotification(notification: ClientNotification) {}
|
||||
}
|
||||
+4
-14
@@ -27,15 +27,14 @@ import org.meshtastic.core.repository.FromRadioPacketHandler
|
||||
import org.meshtastic.core.repository.LockdownCoordinator
|
||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.MqttManager
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioSessionContext
|
||||
import org.meshtastic.core.repository.ServiceStateWriter
|
||||
import org.meshtastic.core.repository.XModemManager
|
||||
import org.meshtastic.core.repository.notificationId
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.client_notification
|
||||
import org.meshtastic.core.resources.duplicated_public_key_title
|
||||
@@ -57,7 +56,7 @@ class FromRadioPacketHandlerImpl(
|
||||
private val xmodemManager: Lazy<XModemManager>,
|
||||
private val mqttManager: MqttManager,
|
||||
private val packetHandler: PacketHandler,
|
||||
private val notificationManager: NotificationManager,
|
||||
private val serviceNotifications: MeshNotificationManager,
|
||||
private val lockdownCoordinator: LockdownCoordinator,
|
||||
private val firmwareUpdateStatusRepository: FirmwareUpdateStatusRepository,
|
||||
private val radioInterfaceService: RadioInterfaceService,
|
||||
@@ -156,7 +155,7 @@ class FromRadioPacketHandlerImpl(
|
||||
private fun handleClientNotification(cn: ClientNotification, session: RadioSessionContext) {
|
||||
val admitted =
|
||||
radioInterfaceService.runIfSessionActive(session) {
|
||||
if (!notificationManager.suppressClientNotificationModal(cn)) {
|
||||
if (!serviceNotifications.suppressClientNotificationModal(cn)) {
|
||||
serviceStateWriter.setClientNotification(cn)
|
||||
}
|
||||
}
|
||||
@@ -212,15 +211,6 @@ class FromRadioPacketHandlerImpl(
|
||||
else -> Pair(getStringSuspend(Res.string.client_notification), Notification.Type.Info)
|
||||
}
|
||||
|
||||
notificationManager.dispatchClientNotification(
|
||||
Notification(
|
||||
title = title,
|
||||
type = type,
|
||||
message = cn.message,
|
||||
category = Notification.Category.Alert,
|
||||
id = cn.notificationId(),
|
||||
),
|
||||
cn,
|
||||
)
|
||||
serviceNotifications.showClientNotification(cn, title, type)
|
||||
}
|
||||
}
|
||||
+17
-30
@@ -57,8 +57,6 @@ import org.meshtastic.core.repository.MessageAnnouncement
|
||||
import org.meshtastic.core.repository.MessageFilter
|
||||
import org.meshtastic.core.repository.NeighborInfoHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
@@ -73,8 +71,6 @@ import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.critical_alert
|
||||
import org.meshtastic.core.resources.error_duty_cycle
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.mesh_beacon_notification_body
|
||||
import org.meshtastic.core.resources.mesh_beacon_notification_title
|
||||
import org.meshtastic.core.resources.unknown_username
|
||||
import org.meshtastic.core.resources.waypoint_received
|
||||
import org.meshtastic.proto.MeshBeacon
|
||||
@@ -103,7 +99,6 @@ class MeshDataHandlerImpl(
|
||||
private val packetHandler: PacketHandler,
|
||||
private val serviceStateWriter: ServiceStateWriter,
|
||||
private val packetRepository: Lazy<PacketRepository>,
|
||||
private val notificationManager: NotificationManager,
|
||||
private val serviceNotifications: MeshNotificationManager,
|
||||
private val analytics: PlatformAnalytics,
|
||||
private val dataMapper: MeshDataMapper,
|
||||
@@ -260,15 +255,7 @@ class MeshDataHandlerImpl(
|
||||
// does not warrant a notification.
|
||||
val channelSet = radioConfigRepository.channelSetFlow.first()
|
||||
if (beacon.isAlreadyJoined(channelSet.lora_config, channelSet.settings)) return@launchSessionWork
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getStringSuspend(Res.string.mesh_beacon_notification_title),
|
||||
message = offer.message.ifBlank { getStringSuspend(Res.string.mesh_beacon_notification_body) },
|
||||
category = Notification.Category.MeshBeacon,
|
||||
// Literal URI avoids a core:navigation module dep (see NodeManagerImpl).
|
||||
deepLinkUri = "meshtastic://meshtastic/discovery",
|
||||
),
|
||||
)
|
||||
serviceNotifications.showMeshBeaconNotification(offer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -554,13 +541,10 @@ class MeshDataHandlerImpl(
|
||||
// conversation on screen — only mute silences it.
|
||||
dataPacket.dataType == PortNum.ALERT_APP.value ->
|
||||
if (!muted) {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getSenderName(dataPacket),
|
||||
message = dataPacket.alert ?: getStringSuspend(Res.string.critical_alert),
|
||||
category = Notification.Category.Alert,
|
||||
contactKey = contactKey,
|
||||
),
|
||||
serviceNotifications.showAlertNotification(
|
||||
contactKey,
|
||||
getSenderName(dataPacket),
|
||||
dataPacket.alert ?: getStringSuspend(Res.string.critical_alert),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -578,6 +562,11 @@ class MeshDataHandlerImpl(
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam over the waypoint notification text; compose-resources cannot load in the plain-JVM tests. */
|
||||
internal var waypointMessageFormatter: suspend (String) -> String = { waypointName ->
|
||||
getStringSuspend(Res.string.waypoint_received, waypointName)
|
||||
}
|
||||
|
||||
private suspend fun getSenderName(packet: DataPacket): String {
|
||||
if (packet.source is NodeAddress.Local) {
|
||||
val myId = nodeManager.getMyId()
|
||||
@@ -609,15 +598,13 @@ class MeshDataHandlerImpl(
|
||||
}
|
||||
|
||||
PortNum.WAYPOINT_APP.value -> {
|
||||
val message = getStringSuspend(Res.string.waypoint_received, dataPacket.waypoint!!.name)
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getSenderName(dataPacket),
|
||||
message = message,
|
||||
category = Notification.Category.Message,
|
||||
contactKey = contactKey,
|
||||
isSilent = isSilent,
|
||||
),
|
||||
val waypoint = dataPacket.waypoint!!
|
||||
serviceNotifications.updateWaypointNotification(
|
||||
contactKey,
|
||||
getSenderName(dataPacket),
|
||||
waypointMessageFormatter(waypoint.name),
|
||||
waypoint.id,
|
||||
isSilent,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+4
-13
@@ -39,10 +39,9 @@ import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.NodeAddress
|
||||
import org.meshtastic.core.model.util.NodeIdLookup
|
||||
import org.meshtastic.core.repository.ConnectionIdentity
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioSessionContext
|
||||
import org.meshtastic.core.resources.Res
|
||||
@@ -85,7 +84,7 @@ private val DEFAULT_NODE_NAME_REGEX = Regex("^Meshtastic [0-9a-fA-F]{4}$")
|
||||
@Single(binds = [NodeManager::class, NodeIdLookup::class])
|
||||
class NodeManagerImpl(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val notificationManager: NotificationManager,
|
||||
private val serviceNotifications: MeshNotificationManager,
|
||||
private val radioInterfaceService: RadioInterfaceService,
|
||||
private val scope: ServiceScope,
|
||||
) : NodeManager {
|
||||
@@ -564,7 +563,7 @@ class NodeManagerImpl(
|
||||
// Commit retirement first. A dispatch already in progress will fail its final state revalidation; one that
|
||||
// completed before the commit is removed by this cancellation. Side effects run once, outside the CAS loop.
|
||||
removedNums.forEach { num ->
|
||||
notificationManager.cancel(num)
|
||||
serviceNotifications.cancelNewNodeNotification(num)
|
||||
val keyDescription =
|
||||
committedHints[num]?.let(::publicKeyLogFingerprint)
|
||||
?: if (num in committedPresentNums) "none" else "absent"
|
||||
@@ -1213,15 +1212,7 @@ class NodeManagerImpl(
|
||||
return@launchSessionWork
|
||||
}
|
||||
Logger.d { "[NodeIdentity] notification-dispatch num=${node.num}" }
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = title,
|
||||
message = node.user.long_name,
|
||||
category = Notification.Category.NodeEvent,
|
||||
id = node.num,
|
||||
deepLinkUri = "meshtastic://meshtastic/nodes/${node.num}",
|
||||
),
|
||||
)
|
||||
serviceNotifications.showNewNodeSeenNotification(node, title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-35
@@ -27,16 +27,11 @@ import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.util.decodeOrNull
|
||||
import org.meshtastic.core.model.util.toOneLiner
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioSessionContext
|
||||
import org.meshtastic.core.repository.TelemetryPacketHandler
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.low_battery_message
|
||||
import org.meshtastic.core.resources.low_battery_title
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
@@ -49,7 +44,7 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
class TelemetryPacketHandlerImpl(
|
||||
private val nodeManager: NodeManager,
|
||||
private val connectionManager: Lazy<MeshConnectionManager>,
|
||||
private val notificationManager: NotificationManager,
|
||||
private val serviceNotifications: MeshNotificationManager,
|
||||
private val radioInterfaceService: RadioInterfaceService,
|
||||
private val scope: ServiceScope,
|
||||
) : TelemetryPacketHandler {
|
||||
@@ -97,45 +92,27 @@ class TelemetryPacketHandlerImpl(
|
||||
val updatedNode = nodeManager.nodeDBbyNodeNum[fromNum] ?: return
|
||||
if (fromNum != myNodeNum && !updatedNode.isFavorite) return
|
||||
|
||||
if (
|
||||
(metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED &&
|
||||
(metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD
|
||||
) {
|
||||
// A reading without a level says nothing about the battery, so it neither warns nor clears.
|
||||
val batteryLevel = metrics.battery_level ?: return
|
||||
val hasBattery = (metrics.voltage ?: 0f) > 0f
|
||||
if (hasBattery && batteryLevel <= BATTERY_PERCENT_LOW_THRESHOLD) {
|
||||
radioInterfaceService.launchSessionWork(scope, session) {
|
||||
if (shouldBatteryNotificationShow(fromNum, telemetry, myNodeNum)) {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getStringSuspend(Res.string.low_battery_title, updatedNode.user.short_name),
|
||||
message =
|
||||
getStringSuspend(
|
||||
Res.string.low_battery_message,
|
||||
updatedNode.user.long_name,
|
||||
updatedNode.deviceMetrics.battery_level ?: 0,
|
||||
),
|
||||
category = Notification.Category.Battery,
|
||||
),
|
||||
)
|
||||
val firstLowReading = batteryMutex.withLock { notifiedNodes.add(fromNum) }
|
||||
if (firstLowReading) {
|
||||
serviceNotifications.showLowBatteryNotification(updatedNode, isRemote)
|
||||
} else {
|
||||
serviceNotifications.updateLowBatteryNotification(updatedNode, isRemote)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
radioInterfaceService.launchSessionWork(scope, session) {
|
||||
batteryMutex.withLock { notifiedNodes.remove(fromNum) }
|
||||
notificationManager.cancel(updatedNode.num)
|
||||
serviceNotifications.cancelLowBatteryNotification(updatedNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UnusedParameter")
|
||||
private suspend fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean {
|
||||
batteryMutex.withLock {
|
||||
if (fromNum in notifiedNodes) return false
|
||||
notifiedNodes.add(fromNum)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BATTERY_PERCENT_UNSUPPORTED = 0.0
|
||||
private const val BATTERY_PERCENT_LOW_THRESHOLD = 20
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -30,8 +30,8 @@ import org.meshtastic.core.model.util.isOtaStatusNotification
|
||||
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
|
||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.MqttManager
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioSessionContext
|
||||
@@ -64,7 +64,7 @@ class FromRadioPacketHandlerImplTest {
|
||||
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
|
||||
private val mqttManager: MqttManager = mock(MockMode.autofill)
|
||||
private val packetHandler: PacketHandler = mock(MockMode.autofill)
|
||||
private val notificationManager: NotificationManager = mock(MockMode.autofill)
|
||||
private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill)
|
||||
private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill)
|
||||
private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
|
||||
private val xmodemManager: XModemManager = mock(MockMode.autofill)
|
||||
@@ -107,7 +107,7 @@ class FromRadioPacketHandlerImplTest {
|
||||
lazy { xmodemManager },
|
||||
mqttManager,
|
||||
packetHandler,
|
||||
notificationManager,
|
||||
serviceNotifications,
|
||||
lockdownCoordinator,
|
||||
firmwareUpdateStatusRepository,
|
||||
radioInterfaceService,
|
||||
@@ -328,7 +328,7 @@ class FromRadioPacketHandlerImplTest {
|
||||
@Test
|
||||
fun `platform-suppressed client notification skips modal state but preserves system delivery`() {
|
||||
val notification = protectedPositionAdvisory(replyId = 100, time = 1_000)
|
||||
every { notificationManager.suppressClientNotificationModal(notification) } returns true
|
||||
every { serviceNotifications.suppressClientNotificationModal(notification) } returns true
|
||||
|
||||
handle(FromRadio.Builder().also { wb -> wb.clientNotification = notification }.build())
|
||||
|
||||
@@ -339,7 +339,7 @@ class FromRadioPacketHandlerImplTest {
|
||||
@Test
|
||||
fun `default platform policy preserves modal and system delivery for exact advisory`() {
|
||||
val notification = protectedPositionAdvisory(replyId = 200, time = 2_000)
|
||||
every { notificationManager.suppressClientNotificationModal(notification) } returns false
|
||||
every { serviceNotifications.suppressClientNotificationModal(notification) } returns false
|
||||
|
||||
handle(FromRadio.Builder().also { wb -> wb.clientNotification = notification }.build())
|
||||
|
||||
@@ -355,7 +355,7 @@ class FromRadioPacketHandlerImplTest {
|
||||
handle(FromRadio.Builder().also { wb -> wb.clientNotification = notification }.build())
|
||||
|
||||
verify(mode = VerifyMode.exactly(0)) { serviceRepository.setClientNotification(any()) }
|
||||
verifySuspend(mode = VerifyMode.exactly(0)) { notificationManager.dispatchClientNotification(any(), any()) }
|
||||
verifySuspend(mode = VerifyMode.exactly(0)) { serviceNotifications.showClientNotification(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -366,7 +366,7 @@ class FromRadioPacketHandlerImplTest {
|
||||
handle(FromRadio.Builder().also { wb -> wb.clientNotification = notification }.build())
|
||||
|
||||
verify { serviceRepository.setClientNotification(notification) }
|
||||
verifySuspend(mode = VerifyMode.exactly(0)) { notificationManager.dispatchClientNotification(any(), any()) }
|
||||
verifySuspend(mode = VerifyMode.exactly(0)) { serviceNotifications.showClientNotification(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+113
-4
@@ -54,7 +54,6 @@ import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.MessageFilter
|
||||
import org.meshtastic.core.repository.NeighborInfoHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
@@ -96,7 +95,6 @@ class MeshDataHandlerTest {
|
||||
private val packetHandler: PacketHandler = mock(MockMode.autofill)
|
||||
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
|
||||
private val packetRepository: PacketRepository = mock(MockMode.autofill)
|
||||
private val notificationManager: NotificationManager = mock(MockMode.autofill)
|
||||
private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill)
|
||||
private val analytics: PlatformAnalytics = mock(MockMode.autofill)
|
||||
private val dataMapper: MeshDataMapper = mock(MockMode.autofill)
|
||||
@@ -156,7 +154,6 @@ class MeshDataHandlerTest {
|
||||
packetHandler = packetHandler,
|
||||
serviceStateWriter = serviceRepository,
|
||||
packetRepository = lazy { packetRepository },
|
||||
notificationManager = notificationManager,
|
||||
serviceNotifications = serviceNotifications,
|
||||
analytics = analytics,
|
||||
dataMapper = dataMapper,
|
||||
@@ -684,7 +681,47 @@ class MeshDataHandlerTest {
|
||||
|
||||
// Still stored, so it can reappear in the invitations list if the user later deletes the channel.
|
||||
assertEquals(1, meshBeaconRepository.offers.value.size)
|
||||
verifySuspend(mode = dev.mokkery.verify.VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = dev.mokkery.verify.VerifyMode.not) {
|
||||
serviceNotifications.showMeshBeaconNotification(any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mesh beacon offering a channel the radio lacks notifies with the offer`() = testScope.runTest {
|
||||
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet.Builder().build())
|
||||
val beacon =
|
||||
MeshBeacon.Builder()
|
||||
.also { wb ->
|
||||
wb.message = "Join us"
|
||||
wb.offer_channel = ChannelSettings.Builder().also { wb -> wb.name = "PartyNet" }.build()
|
||||
}
|
||||
.build()
|
||||
val packet =
|
||||
MeshPacket.Builder()
|
||||
.also { wb ->
|
||||
wb.from = 456
|
||||
wb.decoded =
|
||||
Data.Builder()
|
||||
.also { wb ->
|
||||
wb.portnum = PortNum.MESH_BEACON_APP
|
||||
wb.payload = beacon.encode().toByteString()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
every { dataMapper.toDataPacket(packet) } returns
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
bytes = beacon.encode().toByteString(),
|
||||
dataType = PortNum.MESH_BEACON_APP.value,
|
||||
)
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend {
|
||||
serviceNotifications.showMeshBeaconNotification(meshBeaconRepository.offers.value.single())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Store-and-Forward handling ---
|
||||
@@ -1987,6 +2024,78 @@ class MeshDataHandlerTest {
|
||||
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `received waypoint notifies on the waypoint path`() = testScope.runTest {
|
||||
handler.waypointMessageFormatter = { name -> "Waypoint received: $name" }
|
||||
every { packetRepository.getWaypoints() } returns flowOf(emptyList())
|
||||
every { nodeManager.getNodeById(any()) } returns
|
||||
Node(num = 999, user = User.Builder().also { wb -> wb.long_name = "Hawk Ridge" }.build())
|
||||
val packet =
|
||||
waypointPacket(
|
||||
txId = 506,
|
||||
from = 999,
|
||||
waypoint =
|
||||
Waypoint.Builder()
|
||||
.also { wb ->
|
||||
wb.id = 42
|
||||
wb.name = "Camp"
|
||||
wb.expire = Int.MAX_VALUE
|
||||
}
|
||||
.build(),
|
||||
)
|
||||
stubWaypointPersistDependencies(506)
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend {
|
||||
serviceNotifications.updateWaypointNotification(
|
||||
any(),
|
||||
"Hawk Ridge",
|
||||
"Waypoint received: Camp",
|
||||
42,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `critical alert notifies on the alert path with its conversation`() = testScope.runTest {
|
||||
val payload = "Fire at camp".encodeToByteArray().toByteString()
|
||||
val packet =
|
||||
MeshPacket.Builder()
|
||||
.also { wb ->
|
||||
wb.id = 507
|
||||
wb.from = 456
|
||||
wb.decoded =
|
||||
Data.Builder()
|
||||
.also { wb ->
|
||||
wb.portnum = PortNum.ALERT_APP
|
||||
wb.payload = payload
|
||||
}
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
every { dataMapper.toDataPacket(packet) } returns
|
||||
DataPacket(
|
||||
id = 507,
|
||||
from = "!remote",
|
||||
to = NodeAddress.ID_BROADCAST,
|
||||
bytes = payload,
|
||||
dataType = PortNum.ALERT_APP.value,
|
||||
)
|
||||
everySuspend { packetRepository.findPacketsWithId(507) } returns emptyList()
|
||||
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
|
||||
every { messageFilter.shouldFilter(any(), any()) } returns false
|
||||
every { nodeManager.getNodeById("!remote") } returns
|
||||
Node(num = 456, user = User.Builder().also { wb -> wb.long_name = "Remote User" }.build())
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { serviceNotifications.showAlertNotification("0^all", "Remote User", "Fire at camp") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `waypoint locked to someone other than the sender is dropped`() = testScope.runTest {
|
||||
// Pre-existing inbound-payload rule: a node can only lock a waypoint to itself. Nothing stored here — the
|
||||
|
||||
+3
-3
@@ -29,8 +29,8 @@ import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.common.di.asServiceScope
|
||||
import org.meshtastic.core.model.MyNodeInfo
|
||||
import org.meshtastic.core.repository.ConnectionIdentity
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
@@ -49,7 +49,7 @@ import kotlin.test.assertTrue
|
||||
class NodeManagerConnectionIdentityTest {
|
||||
|
||||
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||
private val notificationManager: NotificationManager = mock(MockMode.autofill)
|
||||
private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill)
|
||||
private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
|
||||
private val testScope = TestScope()
|
||||
|
||||
@@ -59,7 +59,7 @@ class NodeManagerConnectionIdentityTest {
|
||||
fun setUp() {
|
||||
everySuspend { nodeRepository.getNodeDbSnapshot() } returns emptyMap()
|
||||
nodeManager =
|
||||
NodeManagerImpl(nodeRepository, notificationManager, radioInterfaceService, testScope.asServiceScope())
|
||||
NodeManagerImpl(nodeRepository, serviceNotifications, radioInterfaceService, testScope.asServiceScope())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+74
-78
@@ -43,9 +43,8 @@ import org.meshtastic.core.common.util.crc32
|
||||
import org.meshtastic.core.model.MyNodeInfo
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.NodeAddress
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioSessionContext
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
@@ -70,7 +69,7 @@ import org.meshtastic.proto.Position as ProtoPosition
|
||||
class NodeManagerImplTest {
|
||||
|
||||
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||
private val notificationManager: NotificationManager = mock(MockMode.autofill)
|
||||
private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill)
|
||||
private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
|
||||
private val testScope = TestScope()
|
||||
|
||||
@@ -79,7 +78,7 @@ class NodeManagerImplTest {
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
nodeManager =
|
||||
NodeManagerImpl(nodeRepository, notificationManager, radioInterfaceService, testScope.asServiceScope())
|
||||
NodeManagerImpl(nodeRepository, serviceNotifications, radioInterfaceService, testScope.asServiceScope())
|
||||
// Override the compose-resources formatter so notification dispatch is deterministic in the
|
||||
// plain-JVM test env (getStringSuspend does not resolve here). Tests that assert "no dispatch"
|
||||
// still hold: the override only changes the title, not whether dispatch fires.
|
||||
@@ -1142,7 +1141,7 @@ class NodeManagerImplTest {
|
||||
assertNull(nodeManager.nodeDBbyNodeNum[retiredNum])
|
||||
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { nodeRepository.insertMetadata(any(), any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1194,7 +1193,7 @@ class NodeManagerImplTest {
|
||||
assertNull(nodeManager.nodeDBbyNodeNum[retiredNum])
|
||||
assertEquals("Canonical", nodeManager.nodeDBbyNodeNum[canonicalNum]?.user?.long_name)
|
||||
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1228,7 +1227,7 @@ class NodeManagerImplTest {
|
||||
assertEquals(replacementKey, replacement.publicKey)
|
||||
assertEquals("Replacement", replacement.user.long_name)
|
||||
verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.exactly(1)) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.exactly(1)) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1266,7 +1265,7 @@ class NodeManagerImplTest {
|
||||
|
||||
assertNull(nodeManager.nodeDBbyNodeNum[retiredNum])
|
||||
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1322,7 +1321,7 @@ class NodeManagerImplTest {
|
||||
assertEquals(validPk, canonical.publicKey)
|
||||
verifyNoRepositoryDeletion()
|
||||
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
// 2. Placeholder duplicate
|
||||
@@ -1384,7 +1383,7 @@ class NodeManagerImplTest {
|
||||
assertEquals("Migrated", canonical.user.long_name)
|
||||
verifyNoRepositoryDeletion()
|
||||
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
// 3. Different-key conflict
|
||||
@@ -1420,7 +1419,7 @@ class NodeManagerImplTest {
|
||||
assertEquals("Other", otherNode.user.long_name)
|
||||
verifyNoRepositoryDeletion()
|
||||
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
// 4. Multiple same-key matches
|
||||
@@ -1454,7 +1453,7 @@ class NodeManagerImplTest {
|
||||
assertEquals("Incoming", nodeManager.nodeDBbyNodeNum[fromNum]?.user?.long_name)
|
||||
verifyNoRepositoryDeletion()
|
||||
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1484,7 +1483,7 @@ class NodeManagerImplTest {
|
||||
assertEquals(validPk, incoming.publicKey)
|
||||
verifyNoRepositoryDeletion()
|
||||
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1540,7 +1539,7 @@ class NodeManagerImplTest {
|
||||
assertEquals(456, incoming.position.longitude_i)
|
||||
verifyNoRepositoryDeletion()
|
||||
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
// 5. Local authoritative renumber
|
||||
@@ -1573,7 +1572,7 @@ class NodeManagerImplTest {
|
||||
assertEquals(validPk, localNode.publicKey)
|
||||
verifyNoRepositoryDeletion()
|
||||
verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
// 6. New-node notification admission
|
||||
@@ -1601,7 +1600,7 @@ class NodeManagerImplTest {
|
||||
assertNotNull(result)
|
||||
assertEquals("Replayed Node", result.user.long_name)
|
||||
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1631,7 +1630,7 @@ class NodeManagerImplTest {
|
||||
|
||||
assertEquals("Retried Baseline Node", nodeManager.nodeDBbyNodeNum[replayedNodeNum]?.user?.long_name)
|
||||
assertEquals(1234, nodeManager.myNodeNum.value)
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
assertEquals(2, reductionCount, "the forced CAS mismatch must execute the reducer twice")
|
||||
}
|
||||
|
||||
@@ -1651,8 +1650,10 @@ class NodeManagerImplTest {
|
||||
.build()
|
||||
enableDbWrites()
|
||||
|
||||
val captured = mutableListOf<Notification>()
|
||||
everySuspend { notificationManager.dispatch(capture(captured)) } returns true
|
||||
val captured = mutableListOf<Node>()
|
||||
val titles = mutableListOf<String>()
|
||||
everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(captured), capture(titles)) } returns
|
||||
Unit
|
||||
|
||||
nodeManager.handleReceivedUser(newNodeNum, newUser)
|
||||
testScope.advanceUntilIdle()
|
||||
@@ -1663,13 +1664,11 @@ class NodeManagerImplTest {
|
||||
assertEquals(newPk, result.publicKey)
|
||||
verifyNoRepositoryDeletion()
|
||||
verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) }
|
||||
// Strengthen: capture the dispatched Notification and verify payload + routing fields, not just the call count.
|
||||
// Strengthen: capture the posted node and title, not just the call count.
|
||||
assertEquals(1, captured.size)
|
||||
val n = captured.first()
|
||||
assertEquals(newNodeNum, n.id)
|
||||
assertEquals("New Node", n.message)
|
||||
assertEquals(Notification.Category.NodeEvent, n.category)
|
||||
assertEquals("meshtastic://meshtastic/nodes/$newNodeNum", n.deepLinkUri)
|
||||
assertEquals(newNodeNum, captured.single().num)
|
||||
assertEquals("New Node", captured.single().user.long_name)
|
||||
assertEquals("New node seen: NEW", titles.single())
|
||||
}
|
||||
|
||||
// 7. Invalid / malformed keys (null, empty, ERROR) are treated as NoMatch
|
||||
@@ -1852,7 +1851,7 @@ class NodeManagerImplTest {
|
||||
testScope.advanceUntilIdle()
|
||||
|
||||
// No notification fired for any of the three outcomes.
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
// 11. byId consistency after stale removal — when stale and canonical share a user ID,
|
||||
@@ -2443,17 +2442,7 @@ class NodeManagerImplTest {
|
||||
nodeManager.handleReceivedUser(nodeNum, user)
|
||||
testScope.advanceUntilIdle()
|
||||
|
||||
verifySuspend {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = "New node seen: TST",
|
||||
message = "Test User",
|
||||
category = Notification.Category.NodeEvent,
|
||||
id = nodeNum,
|
||||
deepLinkUri = "meshtastic://meshtastic/nodes/$nodeNum",
|
||||
),
|
||||
)
|
||||
}
|
||||
verifySuspend { serviceNotifications.showNewNodeSeenNotification(any(), "New node seen: TST") }
|
||||
}
|
||||
|
||||
// 22. Incoming at the canonical num reconciles in memory only and preserves its placeholder history.
|
||||
@@ -2516,7 +2505,7 @@ class NodeManagerImplTest {
|
||||
assertEquals(4, canonical.channel)
|
||||
verifyNoRepositoryDeletion()
|
||||
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
// 23. Incoming canonical absent from index entirely creates from packet, removes noncanonical, no notify.
|
||||
@@ -2550,7 +2539,7 @@ class NodeManagerImplTest {
|
||||
assertEquals(canonicalKey, canonical.publicKey)
|
||||
verifyNoRepositoryDeletion()
|
||||
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
// 24. Neither num canonical preserves both with no side effects.
|
||||
@@ -2590,7 +2579,7 @@ class NodeManagerImplTest {
|
||||
assertEquals("Bravo", bravo.user.long_name)
|
||||
verifyNoRepositoryDeletion()
|
||||
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
// 25. Different established valid key at the canonical num is preserved as a conflict.
|
||||
@@ -2630,7 +2619,7 @@ class NodeManagerImplTest {
|
||||
assertEquals(canonicalKey, atNoncanonical.publicKey)
|
||||
verifyNoRepositoryDeletion()
|
||||
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
// 26. Local classification and the node index share one atomic CAS state.
|
||||
@@ -2666,7 +2655,7 @@ class NodeManagerImplTest {
|
||||
assertEquals(newLocalNum, nodeManager.myNodeNum.value)
|
||||
verifyNoRepositoryDeletion()
|
||||
verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -2696,7 +2685,7 @@ class NodeManagerImplTest {
|
||||
assertNull(nodeManager.nodeDBbyNodeNum[num])
|
||||
assertFalse(nodeManager.isNodeDbReady.value)
|
||||
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -2771,8 +2760,8 @@ class NodeManagerImplTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(VerifyMode.atLeast(1)) { notificationManager.cancel(num1) }
|
||||
verify(VerifyMode.atLeast(1)) { notificationManager.cancel(num2) }
|
||||
verify(VerifyMode.atLeast(1)) { serviceNotifications.cancelNewNodeNotification(num1) }
|
||||
verify(VerifyMode.atLeast(1)) { serviceNotifications.cancelNewNodeNotification(num2) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -2782,7 +2771,7 @@ class NodeManagerImplTest {
|
||||
nodeManager.handleReceivedUser(num, userWithKey(key, "Retiring", "RT"), manuallyVerified = false)
|
||||
advanceUntilIdle()
|
||||
var nodePresentAtCancellation: Boolean? = null
|
||||
every { notificationManager.cancel(num) } calls
|
||||
every { serviceNotifications.cancelNewNodeNotification(num) } calls
|
||||
{
|
||||
nodePresentAtCancellation = num in nodeManager.nodeDBbyNodeNum
|
||||
}
|
||||
@@ -2791,7 +2780,7 @@ class NodeManagerImplTest {
|
||||
|
||||
assertEquals(false, nodePresentAtCancellation, "retirement must commit before cancellation side effects")
|
||||
assertNull(nodeManager.nodeDBbyNodeNum[num])
|
||||
verify(VerifyMode.exactly(1)) { notificationManager.cancel(num) }
|
||||
verify(VerifyMode.exactly(1)) { serviceNotifications.cancelNewNodeNotification(num) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -2801,8 +2790,8 @@ class NodeManagerImplTest {
|
||||
val key = ByteArray(32) { 0x03 }.toByteString()
|
||||
val titleStarted = CompletableDeferred<Unit>()
|
||||
val releaseTitle = CompletableDeferred<Unit>()
|
||||
val dispatched = mutableListOf<Notification>()
|
||||
everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
|
||||
val dispatched = mutableListOf<Node>()
|
||||
everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(dispatched), any()) } returns Unit
|
||||
nodeManager.notificationTitleFormatter = { shortName ->
|
||||
titleStarted.complete(Unit)
|
||||
releaseTitle.await()
|
||||
@@ -2815,7 +2804,7 @@ class NodeManagerImplTest {
|
||||
releaseTitle.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(dispatched.none { it.id == num && it.message == "NewNode" })
|
||||
assertTrue(dispatched.none { it.num == num && it.user.long_name == "NewNode" })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -2825,8 +2814,8 @@ class NodeManagerImplTest {
|
||||
val key = ByteArray(32) { 0x04 }.toByteString()
|
||||
val titleStarted = CompletableDeferred<Unit>()
|
||||
val releaseTitle = CompletableDeferred<Unit>()
|
||||
val dispatched = mutableListOf<Notification>()
|
||||
everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
|
||||
val dispatched = mutableListOf<Node>()
|
||||
everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(dispatched), any()) } returns Unit
|
||||
nodeManager.notificationTitleFormatter = { shortName ->
|
||||
titleStarted.complete(Unit)
|
||||
releaseTitle.await()
|
||||
@@ -2839,7 +2828,7 @@ class NodeManagerImplTest {
|
||||
releaseTitle.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(dispatched.none { it.id == num && it.message == "NewNode" })
|
||||
assertTrue(dispatched.none { it.num == num && it.user.long_name == "NewNode" })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -2850,8 +2839,8 @@ class NodeManagerImplTest {
|
||||
val key2 = ByteArray(32) { 0x06 }.toByteString()
|
||||
val titleStarted = CompletableDeferred<Unit>()
|
||||
val releaseTitle = CompletableDeferred<Unit>()
|
||||
val dispatched = mutableListOf<Notification>()
|
||||
everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
|
||||
val dispatched = mutableListOf<Node>()
|
||||
everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(dispatched), any()) } returns Unit
|
||||
nodeManager.notificationTitleFormatter = { shortName ->
|
||||
titleStarted.complete(Unit)
|
||||
releaseTitle.await()
|
||||
@@ -2866,7 +2855,7 @@ class NodeManagerImplTest {
|
||||
|
||||
val node = nodeManager.nodeDBbyNodeNum[num]
|
||||
assertEquals("Other", node?.user?.long_name)
|
||||
assertTrue(dispatched.none { it.message == "First" }, "notification for 'First' must be suppressed")
|
||||
assertTrue(dispatched.none { it.user.long_name == "First" }, "notification for 'First' must be suppressed")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -2878,14 +2867,15 @@ class NodeManagerImplTest {
|
||||
advanceUntilIdle()
|
||||
nodeManager.applyTrustedIdentityMigrations(listOf(oldNum))
|
||||
advanceUntilIdle()
|
||||
val replayDispatches = mutableListOf<Notification>()
|
||||
everySuspend { notificationManager.dispatch(capture(replayDispatches)) } returns true
|
||||
val replayDispatches = mutableListOf<Node>()
|
||||
everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(replayDispatches), any()) } returns
|
||||
Unit
|
||||
// Now replay: the canonical number (crc32(key)) has NOT appeared yet
|
||||
nodeManager.handleReceivedUser(oldNum, userWithKey(key, "Replay", "RP"), manuallyVerified = false)
|
||||
advanceUntilIdle()
|
||||
// The replayed node should NOT appear in nodeDBbyNodeNum at oldNum
|
||||
assertNull(nodeManager.nodeDBbyNodeNum[oldNum])
|
||||
assertTrue(replayDispatches.none { it.id == oldNum })
|
||||
assertTrue(replayDispatches.none { it.num == oldNum })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -2902,7 +2892,9 @@ class NodeManagerImplTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(nodeManager.nodeDBbyNodeNum[num])
|
||||
verifySuspend(mode = VerifyMode.exactly(0)) { notificationManager.dispatch(any()) }
|
||||
verifySuspend(mode = VerifyMode.exactly(0)) {
|
||||
serviceNotifications.showNewNodeSeenNotification(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -2917,8 +2909,8 @@ class NodeManagerImplTest {
|
||||
advanceUntilIdle()
|
||||
// Capture dispatches to verify the reuse notification specifically,
|
||||
// not the initial sighting from the setup above.
|
||||
val dispatched = mutableListOf<Notification>()
|
||||
everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
|
||||
val dispatched = mutableListOf<Node>()
|
||||
everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(dispatched), any()) } returns Unit
|
||||
nodeManager.setNodeDbReady(true)
|
||||
// Replay with different key — should be allowed as legitimate reuse
|
||||
nodeManager.handleReceivedUser(num, userWithKey(newKey, "New", "NW"), manuallyVerified = false)
|
||||
@@ -2928,7 +2920,7 @@ class NodeManagerImplTest {
|
||||
assertEquals("New", newNode.user.long_name)
|
||||
assertEquals(
|
||||
1,
|
||||
dispatched.count { it.id == num && it.message == "New" },
|
||||
dispatched.count { it.num == num && it.user.long_name == "New" },
|
||||
"exactly one notification for the reuse at $num",
|
||||
)
|
||||
}
|
||||
@@ -2978,15 +2970,16 @@ class NodeManagerImplTest {
|
||||
nodeManager.applyTrustedIdentityMigrations(listOf(oldNum))
|
||||
advanceUntilIdle()
|
||||
// Early replay of old number User packet — should be suppressed
|
||||
val dispatchedBefore = mutableListOf<Notification>()
|
||||
everySuspend { notificationManager.dispatch(capture(dispatchedBefore)) } returns true
|
||||
val dispatchedBefore = mutableListOf<Node>()
|
||||
everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(dispatchedBefore), any()) } returns
|
||||
Unit
|
||||
nodeManager.handleReceivedUser(oldNum, userWithKey(key, "Replay", "RP"), manuallyVerified = false)
|
||||
advanceUntilIdle()
|
||||
// The old number should NOT be in nodeDB
|
||||
assertNull(nodeManager.nodeDBbyNodeNum[oldNum])
|
||||
// No notification should have been dispatched for the old number replay
|
||||
// (the notification dispatch was captured; check none is for oldNum)
|
||||
val oldNumNotifications = dispatchedBefore.filter { it.id == oldNum }
|
||||
val oldNumNotifications = dispatchedBefore.filter { it.num == oldNum }
|
||||
assertTrue(oldNumNotifications.isEmpty(), "No notification should be dispatched for retired oldNum")
|
||||
}
|
||||
|
||||
@@ -2996,11 +2989,11 @@ class NodeManagerImplTest {
|
||||
val num = 9000000000.toInt()
|
||||
val key = ByteArray(32) { 0x0b }.toByteString()
|
||||
nodeManager.setMyNodeNum(1230588578)
|
||||
val dispatched = mutableListOf<Notification>()
|
||||
everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
|
||||
val dispatched = mutableListOf<Node>()
|
||||
everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(dispatched), any()) } returns Unit
|
||||
nodeManager.handleReceivedUser(num, userWithKey(key, "Genuine Remote", "GR"), manuallyVerified = false)
|
||||
advanceUntilIdle()
|
||||
val numNotifications = dispatched.filter { it.id == num }
|
||||
val numNotifications = dispatched.filter { it.num == num }
|
||||
assertEquals(1, numNotifications.size, "Exactly one notification for genuine new node")
|
||||
}
|
||||
|
||||
@@ -3129,13 +3122,13 @@ class NodeManagerImplTest {
|
||||
)
|
||||
|
||||
// Stale same-key replay still suppressed, no notification dispatched for the retired old number.
|
||||
val dispatched = mutableListOf<Notification>()
|
||||
everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
|
||||
val dispatched = mutableListOf<Node>()
|
||||
everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(dispatched), any()) } returns Unit
|
||||
nodeManager.handleReceivedUser(num, userWithKey(validPk, "Replay", "RP"), manuallyVerified = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(nodeManager.nodeDBbyNodeNum[num])
|
||||
assertTrue(dispatched.none { it.id == num }, "no notification for retired-number replay")
|
||||
assertTrue(dispatched.none { it.num == num }, "no notification for retired-number replay")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -3250,20 +3243,23 @@ class NodeManagerImplTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
// Same-key replay still suppressed after the repeated migration.
|
||||
val suppressedDispatches = mutableListOf<Notification>()
|
||||
everySuspend { notificationManager.dispatch(capture(suppressedDispatches)) } returns true
|
||||
val suppressedDispatches = mutableListOf<Node>()
|
||||
everySuspend {
|
||||
serviceNotifications.showNewNodeSeenNotification(capture(suppressedDispatches), any())
|
||||
} returns Unit
|
||||
nodeManager.handleReceivedUser(num, userWithKey(oldKey, "Replay", "RP"), manuallyVerified = false)
|
||||
advanceUntilIdle()
|
||||
assertNull(
|
||||
nodeManager.nodeDBbyNodeNum[num],
|
||||
"same-key replay after repeated migration must stay suppressed",
|
||||
)
|
||||
assertTrue(suppressedDispatches.none { it.id == num })
|
||||
assertTrue(suppressedDispatches.none { it.num == num })
|
||||
|
||||
// Distinct valid unrepresented key is still accepted as a legitimate reuse, clearing retirement + hint and
|
||||
// emitting exactly one replacement notification.
|
||||
val reuseDispatches = mutableListOf<Notification>()
|
||||
everySuspend { notificationManager.dispatch(capture(reuseDispatches)) } returns true
|
||||
val reuseDispatches = mutableListOf<Node>()
|
||||
everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(reuseDispatches), any()) } returns
|
||||
Unit
|
||||
nodeManager.handleReceivedUser(num, userWithKey(reuseKey, "Replacement", "NP"), manuallyVerified = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
@@ -3271,6 +3267,6 @@ class NodeManagerImplTest {
|
||||
assertNotNull(reused)
|
||||
assertEquals("Replacement", reused.user.long_name)
|
||||
assertEquals(reuseKey, reused.publicKey)
|
||||
assertEquals(1, reuseDispatches.count { it.id == num && it.message == "Replacement" })
|
||||
assertEquals(1, reuseDispatches.count { it.num == num && it.user.long_name == "Replacement" })
|
||||
}
|
||||
}
|
||||
+88
-4
@@ -24,6 +24,8 @@ import dev.mokkery.everySuspend
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import dev.mokkery.verify.VerifyMode
|
||||
import dev.mokkery.verifySuspend
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
@@ -35,8 +37,8 @@ import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.NodeAddress
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioSessionContext
|
||||
import org.meshtastic.core.repository.RadioSessionLease
|
||||
@@ -58,7 +60,7 @@ class TelemetryPacketHandlerImplTest {
|
||||
|
||||
private val nodeManager = mock<NodeManager>(MockMode.autofill)
|
||||
private val connectionManager = mock<MeshConnectionManager>(MockMode.autofill)
|
||||
private val notificationManager = mock<NotificationManager>(MockMode.autofill)
|
||||
private val serviceNotifications = mock<MeshNotificationManager>(MockMode.autofill)
|
||||
private val radioInterfaceService = mock<RadioInterfaceService>(MockMode.autofill)
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
@@ -91,7 +93,7 @@ class TelemetryPacketHandlerImplTest {
|
||||
TelemetryPacketHandlerImpl(
|
||||
nodeManager = nodeManager,
|
||||
connectionManager = lazy { connectionManager },
|
||||
notificationManager = notificationManager,
|
||||
serviceNotifications = serviceNotifications,
|
||||
radioInterfaceService = radioInterfaceService,
|
||||
scope = testScope.asServiceScope(),
|
||||
)
|
||||
@@ -337,6 +339,88 @@ class TelemetryPacketHandlerImplTest {
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum, radioSession)
|
||||
advanceUntilIdle()
|
||||
|
||||
// No dispatch call — battery is healthy
|
||||
verifySuspend(VerifyMode.not) { serviceNotifications.showLowBatteryNotification(any(), any()) }
|
||||
verify { serviceNotifications.cancelLowBatteryNotification(Node(num = myNodeNum)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `low local battery posts on the local low-battery path`() = testScope.runTest {
|
||||
handler.handleTelemetry(
|
||||
makeTelemetryPacket(myNodeNum, lowBatteryTelemetry()),
|
||||
makeDataPacket(myNodeNum),
|
||||
myNodeNum,
|
||||
radioSession,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { serviceNotifications.showLowBatteryNotification(Node(num = myNodeNum), isRemote = false) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `later low readings refresh the warning instead of alerting again`() = testScope.runTest {
|
||||
repeat(2) {
|
||||
handler.handleTelemetry(
|
||||
makeTelemetryPacket(myNodeNum, lowBatteryTelemetry()),
|
||||
makeDataPacket(myNodeNum),
|
||||
myNodeNum,
|
||||
radioSession,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
}
|
||||
|
||||
verifySuspend(VerifyMode.exactly(1)) { serviceNotifications.showLowBatteryNotification(any(), any()) }
|
||||
verifySuspend(VerifyMode.exactly(1)) { serviceNotifications.updateLowBatteryNotification(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reading with voltage but no battery level neither warns nor clears`() = testScope.runTest {
|
||||
val noLevel =
|
||||
Telemetry.Builder()
|
||||
.also { wb ->
|
||||
wb.time = 1700000000
|
||||
wb.device_metrics = DeviceMetrics.Builder().also { wb -> wb.voltage = 3.4f }.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
handler.handleTelemetry(
|
||||
makeTelemetryPacket(myNodeNum, noLevel),
|
||||
makeDataPacket(myNodeNum),
|
||||
myNodeNum,
|
||||
radioSession,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend(VerifyMode.not) { serviceNotifications.showLowBatteryNotification(any(), any()) }
|
||||
verify(VerifyMode.not) { serviceNotifications.cancelLowBatteryNotification(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `low battery on a remote favorite posts on the remote low-battery path`() = testScope.runTest {
|
||||
val favorite = Node(num = remoteNodeNum, isFavorite = true)
|
||||
every { nodeManager.nodeDBbyNodeNum } returns
|
||||
mapOf(myNodeNum to Node(num = myNodeNum), remoteNodeNum to favorite)
|
||||
|
||||
handler.handleTelemetry(
|
||||
makeTelemetryPacket(remoteNodeNum, lowBatteryTelemetry()),
|
||||
makeDataPacket(remoteNodeNum),
|
||||
myNodeNum,
|
||||
radioSession,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { serviceNotifications.showLowBatteryNotification(favorite, isRemote = true) }
|
||||
}
|
||||
|
||||
private fun lowBatteryTelemetry() = Telemetry.Builder()
|
||||
.also { wb ->
|
||||
wb.time = 1700000000
|
||||
wb.device_metrics =
|
||||
DeviceMetrics.Builder()
|
||||
.also { wb ->
|
||||
wb.battery_level = 10
|
||||
wb.voltage = 3.4f
|
||||
}
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
+15
@@ -20,13 +20,28 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.meshtastic.core.resources.UiText
|
||||
|
||||
data class FirmwareUpdateStatus(val isOtaUpdateActive: Boolean = false, val isAwaitingOtaStatus: Boolean = false)
|
||||
|
||||
/**
|
||||
* What a running firmware update is doing, for surfaces outside the firmware screen. [percent] is null when unknown.
|
||||
*/
|
||||
data class FirmwareUpdateProgress(val message: UiText, val percent: Int?)
|
||||
|
||||
class FirmwareUpdateStatusRepository {
|
||||
private val _status = MutableStateFlow(FirmwareUpdateStatus())
|
||||
val status: StateFlow<FirmwareUpdateStatus> = _status.asStateFlow()
|
||||
|
||||
private val _progress = MutableStateFlow<FirmwareUpdateProgress?>(null)
|
||||
|
||||
/** Null whenever no update is transferring, including while the flow waits on the user. */
|
||||
val progress: StateFlow<FirmwareUpdateProgress?> = _progress.asStateFlow()
|
||||
|
||||
fun publishProgress(progress: FirmwareUpdateProgress?) {
|
||||
_progress.value = progress
|
||||
}
|
||||
|
||||
fun beginOtaUpdate() {
|
||||
_status.value = FirmwareUpdateStatus(isOtaUpdateActive = true)
|
||||
}
|
||||
|
||||
+38
-12
@@ -17,6 +17,8 @@
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.FirmwareUpdateNotice
|
||||
import org.meshtastic.core.model.MeshBeaconOffer
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.Telemetry
|
||||
@@ -24,10 +26,9 @@ import org.meshtastic.proto.Telemetry
|
||||
const val SERVICE_NOTIFY_ID = 101
|
||||
|
||||
/**
|
||||
* Mesh-domain notification builder. Provides high-level operations for the message arrival, waypoint, reaction, new
|
||||
* node, low-battery, and client notification flows specific to this app. Implementations are expected to render the
|
||||
* platform notification themselves; the generic dispatch primitive is [NotificationManager] (which posts/cancels opaque
|
||||
* [Notification] records and is *not* domain-aware).
|
||||
* The one notification API for shared code: every notification the app posts or cancels goes through here, and each
|
||||
* platform renders it with its own channels, styles and tap targets. [NotificationManager] is the desktop's dispatch
|
||||
* primitive underneath its implementation, not a second way in.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
interface MeshNotificationManager {
|
||||
@@ -63,13 +64,42 @@ interface MeshNotificationManager {
|
||||
isSilent: Boolean = false,
|
||||
)
|
||||
|
||||
fun showAlertNotification(contactKey: String, name: String, alert: String)
|
||||
suspend fun showAlertNotification(contactKey: String, name: String, alert: String)
|
||||
|
||||
fun showNewNodeSeenNotification(node: Node)
|
||||
suspend fun showMeshBeaconNotification(offer: MeshBeaconOffer)
|
||||
|
||||
fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean)
|
||||
/** [title] arrives resolved: the caller revalidates [node]'s identity right before posting, with no suspension. */
|
||||
suspend fun showNewNodeSeenNotification(node: Node, title: String)
|
||||
|
||||
fun showClientNotification(clientNotification: ClientNotification)
|
||||
fun cancelNewNodeNotification(nodeNum: Int)
|
||||
|
||||
/** Posts the low-battery warning for [node], alerting once. */
|
||||
suspend fun showLowBatteryNotification(node: Node, isRemote: Boolean)
|
||||
|
||||
/** Refreshes a still-showing low-battery warning with [node]'s current level; never re-posts a dismissed one. */
|
||||
suspend fun updateLowBatteryNotification(node: Node, isRemote: Boolean)
|
||||
|
||||
fun cancelLowBatteryNotification(node: Node)
|
||||
|
||||
/**
|
||||
* [title] and [severity] come from the notification's kind, which shared code classifies once for every platform.
|
||||
*/
|
||||
suspend fun showClientNotification(
|
||||
clientNotification: ClientNotification,
|
||||
title: String,
|
||||
severity: Notification.Type,
|
||||
)
|
||||
|
||||
fun clearClientNotification(clientNotification: ClientNotification)
|
||||
|
||||
/** True when the platform presents [clientNotification] natively, so the in-app modal must not show it too. */
|
||||
fun suppressClientNotificationModal(clientNotification: ClientNotification): Boolean = false
|
||||
|
||||
/** Returns true only when the platform accepted the notification, so the caller can record it as shown. */
|
||||
suspend fun showFirmwareUpdateNotification(notice: FirmwareUpdateNotice): Boolean
|
||||
|
||||
/** Returns true only when the platform accepted the notification. [title] and [message] arrive resolved. */
|
||||
suspend fun showReconnectBlockedNotification(title: String, message: String): Boolean
|
||||
|
||||
/**
|
||||
* Suspending because Android rebuilds the group summary here, and the summary's labels come from string resources —
|
||||
@@ -84,8 +114,4 @@ interface MeshNotificationManager {
|
||||
* to dismissing the conversation, which also resolves the spinner.
|
||||
*/
|
||||
suspend fun refreshConversationAfterReply(contactKey: String) = cancelMessageNotification(contactKey)
|
||||
|
||||
fun cancelLowBatteryNotification(node: Node)
|
||||
|
||||
fun clearClientNotification(notification: ClientNotification)
|
||||
}
|
||||
+5
-9
@@ -16,21 +16,14 @@
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
/** A notification as the desktop dispatch primitive ([NotificationManager]) sees it. */
|
||||
data class Notification(
|
||||
val title: String,
|
||||
val message: String,
|
||||
val type: Type = Type.Info,
|
||||
val category: Category = Category.Message,
|
||||
val contactKey: String? = null,
|
||||
val isSilent: Boolean = false,
|
||||
val group: String? = null,
|
||||
val id: Int? = null,
|
||||
/**
|
||||
* Optional deep-link URI invoked when the user taps the notification. Platform implementations are responsible for
|
||||
* converting this into the appropriate intent / activation action. When null, tapping the notification has no
|
||||
* effect.
|
||||
*/
|
||||
val deepLinkUri: String? = null,
|
||||
) {
|
||||
enum class Type {
|
||||
None,
|
||||
@@ -46,7 +39,10 @@ data class Notification(
|
||||
Alert,
|
||||
Service,
|
||||
|
||||
/** Advisory Mesh Beacon invitations from other meshes — low-importance, its own channel. */
|
||||
/** Advisory Mesh Beacon invitations from other meshes. */
|
||||
MeshBeacon,
|
||||
|
||||
/** Notices from the radio's firmware (ClientNotification). */
|
||||
Client,
|
||||
}
|
||||
}
|
||||
+3
-16
@@ -16,14 +16,10 @@
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
|
||||
/**
|
||||
* Platform-agnostic notification dispatch primitive. Posts opaque [Notification] records, cancels by id, or wipes all
|
||||
* active notifications. Intended as the lowest layer of the notification stack.
|
||||
*
|
||||
* Domain-specific notification builders (mesh message arrivals, low-battery alerts, etc.) live in
|
||||
* [MeshNotificationManager], which composes over this dispatcher.
|
||||
* Dispatch primitive underneath a platform's [MeshNotificationManager]: posts opaque [Notification] records, cancels by
|
||||
* id, or wipes all active notifications. Shared code never calls it directly; the desktop implementation composes over
|
||||
* it, while Android renders its notifications natively.
|
||||
*/
|
||||
interface NotificationManager {
|
||||
/**
|
||||
@@ -33,15 +29,6 @@ interface NotificationManager {
|
||||
*/
|
||||
suspend fun dispatch(notification: Notification): Boolean
|
||||
|
||||
/** Platform hook for ClientNotifications that should use a native presentation instead of the global modal. */
|
||||
fun suppressClientNotificationModal(notification: ClientNotification): Boolean = false
|
||||
|
||||
/** Platform-specific ClientNotification delivery; defaults to the ordinary notification path. */
|
||||
suspend fun dispatchClientNotification(
|
||||
notification: Notification,
|
||||
clientNotification: ClientNotification,
|
||||
): Boolean = dispatch(notification)
|
||||
|
||||
fun cancel(id: Int)
|
||||
|
||||
fun cancelAll()
|
||||
|
||||
@@ -1076,15 +1076,28 @@
|
||||
<!-- MESHTASTIC -->
|
||||
<string name="meshtastic">Meshtastic</string>
|
||||
<string name="meshtastic_alerts_notifications">Alert notifications</string>
|
||||
<string name="meshtastic_alerts_notifications_description">Critical alerts sent by nodes on the mesh.</string>
|
||||
<string name="meshtastic_app_name">Meshtastic</string>
|
||||
<string name="meshtastic_broadcast_notifications">Broadcast message notifications</string>
|
||||
<string name="meshtastic_broadcast_notifications_description">Messages sent to your channels.</string>
|
||||
<string name="meshtastic_client_notifications">Radio notifications</string>
|
||||
<string name="meshtastic_client_notifications_description">Notices from your radio, such as key verification requests and security warnings.</string>
|
||||
<string name="meshtastic_device_status_notifications">Update and connection notifications</string>
|
||||
<string name="meshtastic_device_status_notifications_description">Firmware updates for your radio, and problems reconnecting to it.</string>
|
||||
<string name="meshtastic_low_battery_notifications">Low battery notifications</string>
|
||||
<string name="meshtastic_low_battery_notifications_description">Your radio's battery is running low.</string>
|
||||
<string name="meshtastic_low_battery_temporary_remote_notifications">Low battery notifications (favorite nodes)</string>
|
||||
<string name="meshtastic_low_battery_temporary_remote_notifications_description">A favorite node's battery is running low.</string>
|
||||
<string name="meshtastic_mesh_beacon_notifications">Mesh invitation notifications</string>
|
||||
<string name="meshtastic_mesh_beacon_notifications_description">Invitations to join a nearby mesh.</string>
|
||||
<string name="meshtastic_messages_notifications">Direct message notifications</string>
|
||||
<string name="meshtastic_messages_notifications_description">Messages sent directly to you.</string>
|
||||
<string name="meshtastic_new_nodes_notifications">New node notifications</string>
|
||||
<string name="meshtastic_new_nodes_notifications_description">Nodes heard for the first time.</string>
|
||||
<string name="meshtastic_service_notifications">Service notifications</string>
|
||||
<string name="meshtastic_service_notifications_description">Shows the connection to your radio while Meshtastic runs in the background.</string>
|
||||
<string name="meshtastic_waypoints_notifications">Waypoint notifications</string>
|
||||
<string name="meshtastic_waypoints_notifications_description">Waypoints shared on the mesh, and geofence crossings.</string>
|
||||
<!-- MESSAGE -->
|
||||
<string name="message">Message</string>
|
||||
<string name="message_delivery_status">Message delivery status</string>
|
||||
@@ -1303,10 +1316,13 @@
|
||||
<string name="not_now">Not now</string>
|
||||
<string name="note">Note</string>
|
||||
<string name="notes">Notes</string>
|
||||
<!-- NOTIFICATION -->
|
||||
<string name="notification_group_mesh">Mesh</string>
|
||||
<string name="notification_permission_blocked_notice">Notifications are turned off and Android will not ask again. Turn them on in app settings to hear about new messages and alerts.</string>
|
||||
<string name="notification_permission_denied_notice">Without notifications, Meshtastic cannot alert you to new messages, new nodes, or a low battery while the app is in the background.</string>
|
||||
<string name="notification_permission_rationale">Notifications are how Meshtastic reaches you when the app is not open: new messages, newly discovered nodes, and a radio running low on battery. Nothing else changes if you decline.</string>
|
||||
<string name="notification_permissions_description">Meshtastic uses notifications to keep you updated on new messages and other important events. You can update your notification permissions at any time from settings.</string>
|
||||
<string name="notification_reaction_to">%1$s to “%2$s”</string>
|
||||
<!-- NOTIFICATIONS -->
|
||||
<string name="notifications_for_channel_and_direct_messages">Notifications for channel and direct messages.</string>
|
||||
<string name="notifications_for_low_battery_alerts">Notifications for low battery alerts for the connected device.</string>
|
||||
|
||||
@@ -19,7 +19,7 @@ package org.meshtastic.app
|
||||
import android.app.Activity
|
||||
|
||||
/**
|
||||
* Test-only stub for the real `MainActivity` in the `:androidApp` module. `AndroidNotificationManager` resolves the
|
||||
* Test-only stub for the real `MainActivity` in the `:androidApp` module. `MeshNotificationManagerImpl` resolves the
|
||||
* activity by FQN via `Class.forName(...)` to avoid pulling `:androidApp` into `:core:service` as a Gradle dependency.
|
||||
* This stub lets unit tests exercise the deep-link `PendingIntent` construction path without that dependency.
|
||||
*/
|
||||
|
||||
-414
@@ -1,414 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.notificationId
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.DuplicatedPublicKey
|
||||
import org.meshtastic.proto.KeyVerificationFinal
|
||||
import org.meshtastic.proto.KeyVerificationNumberInform
|
||||
import org.meshtastic.proto.KeyVerificationNumberRequest
|
||||
import org.meshtastic.proto.LogRecord
|
||||
import org.meshtastic.proto.LowEntropyKey
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@Config(sdk = [34])
|
||||
class AndroidNotificationManagerTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var systemNotificationManager: NotificationManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
systemNotificationManager = context.getSystemService(NotificationManager::class.java)!!
|
||||
clearManagedChannels()
|
||||
systemNotificationManager.cancelAll()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
clearManagedChannels()
|
||||
systemNotificationManager.cancelAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeLegacyCategoryChannels deletes legacy channels and keeps canonical channels`() {
|
||||
createChannel("NodeEvent")
|
||||
createChannel(NotificationChannels.NEW_NODES)
|
||||
|
||||
systemNotificationManager.removeLegacyCategoryChannels()
|
||||
|
||||
assertNull(systemNotificationManager.getNotificationChannel("NodeEvent"))
|
||||
assertNotNull(systemNotificationManager.getNotificationChannel(NotificationChannels.NEW_NODES))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dispatch removes legacy node channel and creates canonical node channel`() = runTest {
|
||||
createChannel("NodeEvent")
|
||||
|
||||
val manager = AndroidNotificationManager(context)
|
||||
manager.dispatch(Notification(title = "Node", message = "Seen", category = Notification.Category.NodeEvent))
|
||||
|
||||
assertNull(systemNotificationManager.getNotificationChannel("NodeEvent"))
|
||||
assertNotNull(systemNotificationManager.getNotificationChannel(NotificationChannels.NEW_NODES))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dispatch routes node event notifications to canonical new nodes channel`() = runTest {
|
||||
val manager = AndroidNotificationManager(context)
|
||||
|
||||
manager.dispatch(Notification(title = "Node", message = "Seen", category = Notification.Category.NodeEvent))
|
||||
|
||||
val posted = shadowOf(systemNotificationManager).allNotifications.last()
|
||||
assertEquals(NotificationChannels.NEW_NODES, posted.channelId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dispatch reports false when its notification channel is disabled`() = runTest {
|
||||
createChannel(NotificationChannels.NEW_NODES, NotificationManager.IMPORTANCE_NONE)
|
||||
val manager = AndroidNotificationManager(context)
|
||||
|
||||
val dispatched =
|
||||
manager.dispatch(Notification(title = "Node", message = "Seen", category = Notification.Category.NodeEvent))
|
||||
|
||||
assertFalse(dispatched)
|
||||
assertEquals(0, shadowOf(systemNotificationManager).allNotifications.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeLegacyCategoryChannels removes all known legacy category channels`() {
|
||||
NotificationChannels.LEGACY_CATEGORY_IDS.forEach(::createChannel)
|
||||
|
||||
systemNotificationManager.removeLegacyCategoryChannels()
|
||||
|
||||
NotificationChannels.LEGACY_CATEGORY_IDS.forEach { legacyId ->
|
||||
assertNull(systemNotificationManager.getNotificationChannel(legacyId))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeLegacyCategoryChannels is idempotent`() {
|
||||
createChannel("NodeEvent")
|
||||
|
||||
systemNotificationManager.removeLegacyCategoryChannels()
|
||||
systemNotificationManager.removeLegacyCategoryChannels()
|
||||
|
||||
assertNull(systemNotificationManager.getNotificationChannel("NodeEvent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dispatch routes all categories to canonical channels`() = runTest {
|
||||
val manager = AndroidNotificationManager(context)
|
||||
|
||||
assertDispatchesToChannel(manager, Notification.Category.Message, NotificationChannels.MESSAGES)
|
||||
assertDispatchesToChannel(manager, Notification.Category.NodeEvent, NotificationChannels.NEW_NODES)
|
||||
assertDispatchesToChannel(manager, Notification.Category.Battery, NotificationChannels.LOW_BATTERY)
|
||||
assertDispatchesToChannel(manager, Notification.Category.Alert, NotificationChannels.ALERTS)
|
||||
assertDispatchesToChannel(manager, Notification.Category.Service, NotificationChannels.SERVICE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dispatch attaches deep-link PendingIntent when deepLinkUri is set`() = runTest {
|
||||
registerStubMainActivity()
|
||||
val manager = AndroidNotificationManager(context)
|
||||
val deepLink = "meshtastic://meshtastic/nodes/1234"
|
||||
|
||||
manager.dispatch(
|
||||
Notification(
|
||||
title = "New node",
|
||||
message = "Long Name",
|
||||
category = Notification.Category.NodeEvent,
|
||||
id = 1234,
|
||||
deepLinkUri = deepLink,
|
||||
),
|
||||
)
|
||||
|
||||
val posted = shadowOf(systemNotificationManager).allNotifications.last()
|
||||
val pendingIntent =
|
||||
requireNotNull(posted.contentIntent) { "Expected contentIntent to be set when deepLinkUri is provided" }
|
||||
val shadowPendingIntent = shadowOf(pendingIntent)
|
||||
val savedIntent = shadowPendingIntent.savedIntent
|
||||
assertEquals(android.content.Intent.ACTION_VIEW, savedIntent.action)
|
||||
assertEquals(deepLink, savedIntent.data?.toString())
|
||||
assertEquals("org.meshtastic.app.MainActivity", savedIntent.component?.className)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dispatch leaves contentIntent unset when deepLinkUri is null`() = runTest {
|
||||
val manager = AndroidNotificationManager(context)
|
||||
|
||||
manager.dispatch(Notification(title = "Plain", message = "No tap", category = Notification.Category.NodeEvent))
|
||||
|
||||
val posted = shadowOf(systemNotificationManager).allNotifications.last()
|
||||
assertNull(posted.contentIntent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dispatch uses provided notification id as system id`() = runTest {
|
||||
val manager = AndroidNotificationManager(context)
|
||||
val explicitId = 4242
|
||||
|
||||
manager.dispatch(
|
||||
Notification(
|
||||
title = "With id",
|
||||
message = "explicit",
|
||||
category = Notification.Category.NodeEvent,
|
||||
id = explicitId,
|
||||
),
|
||||
)
|
||||
|
||||
// Cancellation by the same id should remove the posted notification.
|
||||
assertEquals(1, shadowOf(systemNotificationManager).allNotifications.size)
|
||||
manager.cancel(explicitId)
|
||||
assertEquals(0, shadowOf(systemNotificationManager).allNotifications.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `client notification identity cancels the notification posted with that identity`() = runTest {
|
||||
val manager = AndroidNotificationManager(context)
|
||||
val clientNotification =
|
||||
ClientNotification.Builder()
|
||||
.also { wb ->
|
||||
wb.message = "Protected position advisory"
|
||||
wb.reply_id = 123
|
||||
}
|
||||
.build()
|
||||
val id = clientNotification.notificationId()
|
||||
|
||||
manager.dispatch(
|
||||
Notification(
|
||||
title = "Client notification",
|
||||
message = clientNotification.message,
|
||||
category = Notification.Category.Alert,
|
||||
id = id,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(1, shadowOf(systemNotificationManager).allNotifications.size)
|
||||
manager.cancel(clientNotification.notificationId())
|
||||
assertEquals(0, shadowOf(systemNotificationManager).allNotifications.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact protected position advisory is recognized`() {
|
||||
val manager = AndroidNotificationManager(context)
|
||||
|
||||
assertTrue(manager.suppressClientNotificationModal(protectedPositionAdvisory(replyId = 123, time = 1_000)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `protected position advisory predicate rejects every near miss`() {
|
||||
val manager = AndroidNotificationManager(context)
|
||||
val advisory = protectedPositionAdvisory(replyId = 123, time = 1_000)
|
||||
val nearMisses =
|
||||
listOf(
|
||||
advisory.newBuilder().also { wb -> wb.message = "Location sharing is disabled" }.build(),
|
||||
advisory.newBuilder().also { wb -> wb.level = LogRecord.Level.INFO }.build(),
|
||||
advisory.newBuilder().also { wb -> wb.reply_id = 0 }.build(),
|
||||
advisory.newBuilder().also { wb -> wb.reply_id = null }.build(),
|
||||
advisory
|
||||
.newBuilder()
|
||||
.also { wb -> wb.key_verification_number_inform = KeyVerificationNumberInform.Builder().build() }
|
||||
.build(),
|
||||
advisory
|
||||
.newBuilder()
|
||||
.also { wb -> wb.key_verification_number_request = KeyVerificationNumberRequest.Builder().build() }
|
||||
.build(),
|
||||
advisory
|
||||
.newBuilder()
|
||||
.also { wb -> wb.key_verification_final = KeyVerificationFinal.Builder().build() }
|
||||
.build(),
|
||||
advisory
|
||||
.newBuilder()
|
||||
.also { wb -> wb.duplicated_public_key = DuplicatedPublicKey.Builder().build() }
|
||||
.build(),
|
||||
advisory.newBuilder().also { wb -> wb.low_entropy_key = LowEntropyKey.Builder().build() }.build(),
|
||||
advisory.newBuilder().also { wb -> wb.message = "Rebooting to WiFi OTA" }.build(),
|
||||
)
|
||||
|
||||
nearMisses.forEach { notification ->
|
||||
assertFalse(manager.suppressClientNotificationModal(notification), notification.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `protected position advisories replace one stable only-alert-once system notification`() = runTest {
|
||||
val manager = AndroidNotificationManager(context)
|
||||
val first = protectedPositionAdvisory(replyId = 123, time = 1_000)
|
||||
val second = protectedPositionAdvisory(replyId = 456, time = 2_000)
|
||||
|
||||
listOf(first, second).forEach { advisory ->
|
||||
manager.dispatchClientNotification(
|
||||
Notification(
|
||||
title = "Client notification",
|
||||
message = advisory.message,
|
||||
category = Notification.Category.Alert,
|
||||
id = advisory.notificationId(),
|
||||
),
|
||||
advisory,
|
||||
)
|
||||
}
|
||||
|
||||
val posted = shadowOf(systemNotificationManager).allNotifications.single()
|
||||
assertTrue(posted.flags and android.app.Notification.FLAG_ONLY_ALERT_ONCE != 0)
|
||||
val active = systemNotificationManager.activeNotifications.single()
|
||||
assertEquals(first.notificationId(), active.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated generic client notifications with the same message coalesce into one tray entry`() = runTest {
|
||||
val manager = AndroidNotificationManager(context)
|
||||
// A near-miss of the protected-position predicate (message differs slightly), so it takes the plain
|
||||
// dispatch path — but reply_id/time still change on every firmware reply, exactly like the real advisory.
|
||||
val first =
|
||||
ClientNotification.Builder()
|
||||
.also { wb ->
|
||||
wb.message = "Location sharing is disabled"
|
||||
wb.reply_id = 100
|
||||
wb.time = 1_000
|
||||
}
|
||||
.build()
|
||||
val second =
|
||||
ClientNotification.Builder()
|
||||
.also { wb ->
|
||||
wb.message = "Location sharing is disabled"
|
||||
wb.reply_id = 200
|
||||
wb.time = 2_000
|
||||
}
|
||||
.build()
|
||||
|
||||
listOf(first, second).forEach { cn ->
|
||||
manager.dispatchClientNotification(
|
||||
Notification(
|
||||
title = "Client notification",
|
||||
message = cn.message,
|
||||
category = Notification.Category.Alert,
|
||||
id = cn.notificationId(),
|
||||
),
|
||||
cn,
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(1, shadowOf(systemNotificationManager).allNotifications.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generic client notification does not enable only-alert-once`() = runTest {
|
||||
val manager = AndroidNotificationManager(context)
|
||||
val clientNotification =
|
||||
ClientNotification.Builder()
|
||||
.also { wb ->
|
||||
wb.message = "Generic warning"
|
||||
wb.reply_id = 123
|
||||
}
|
||||
.build()
|
||||
|
||||
manager.dispatchClientNotification(
|
||||
Notification(
|
||||
title = "Client notification",
|
||||
message = clientNotification.message,
|
||||
category = Notification.Category.Alert,
|
||||
id = clientNotification.notificationId(),
|
||||
),
|
||||
clientNotification,
|
||||
)
|
||||
|
||||
val posted = shadowOf(systemNotificationManager).allNotifications.single()
|
||||
assertEquals(0, posted.flags and android.app.Notification.FLAG_ONLY_ALERT_ONCE)
|
||||
}
|
||||
|
||||
private suspend fun assertDispatchesToChannel(
|
||||
manager: AndroidNotificationManager,
|
||||
category: Notification.Category,
|
||||
expectedChannelId: String,
|
||||
) {
|
||||
systemNotificationManager.cancelAll()
|
||||
manager.dispatch(
|
||||
Notification(title = "Title-${category.name}", message = "Message-${category.name}", category = category),
|
||||
)
|
||||
|
||||
val posted = shadowOf(systemNotificationManager).allNotifications.last()
|
||||
assertEquals(expectedChannelId, posted.channelId)
|
||||
}
|
||||
|
||||
private fun createChannel(id: String, importance: Int = NotificationManager.IMPORTANCE_DEFAULT) {
|
||||
systemNotificationManager.createNotificationChannel(NotificationChannel(id, id, importance))
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a stub `org.meshtastic.app.MainActivity` with the Robolectric `PackageManager` so that
|
||||
* `TaskStackBuilder.addNextIntentWithParentStack` does not throw `NameNotFoundException` when resolving the
|
||||
* activity that hosts deep-link intents. The real activity lives in `:androidApp`, which is intentionally not on
|
||||
* `:core:service`'s test classpath.
|
||||
*/
|
||||
private fun registerStubMainActivity() {
|
||||
val componentName = android.content.ComponentName(context, "org.meshtastic.app.MainActivity")
|
||||
val activityInfo =
|
||||
android.content.pm.ActivityInfo().apply {
|
||||
name = componentName.className
|
||||
packageName = componentName.packageName
|
||||
exported = true
|
||||
}
|
||||
shadowOf(context.packageManager).addOrUpdateActivity(activityInfo)
|
||||
}
|
||||
|
||||
private fun clearManagedChannels() {
|
||||
val channelIds =
|
||||
NotificationChannels.LEGACY_CATEGORY_IDS +
|
||||
listOf(
|
||||
NotificationChannels.SERVICE,
|
||||
NotificationChannels.MESSAGES,
|
||||
NotificationChannels.BROADCASTS,
|
||||
NotificationChannels.WAYPOINTS,
|
||||
NotificationChannels.ALERTS,
|
||||
NotificationChannels.NEW_NODES,
|
||||
NotificationChannels.LOW_BATTERY,
|
||||
NotificationChannels.LOW_BATTERY_REMOTE,
|
||||
NotificationChannels.CLIENT,
|
||||
)
|
||||
|
||||
channelIds.forEach { channelId -> systemNotificationManager.deleteNotificationChannel(channelId) }
|
||||
}
|
||||
|
||||
private fun protectedPositionAdvisory(replyId: Int, time: Int) = ClientNotification.Builder()
|
||||
.also { wb ->
|
||||
wb.message = "Location sharing is disabled on this channel"
|
||||
wb.reply_id = replyId
|
||||
wb.time = time
|
||||
wb.level = LogRecord.Level.WARNING
|
||||
}
|
||||
.build()
|
||||
}
|
||||
+7
-22
@@ -39,9 +39,9 @@ import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.MeshPrefs
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.testing.FakeMeshNotificationManager
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
@@ -101,10 +101,8 @@ class BootCompleteReceiverTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(shadowOf(application).allStartedServices.isEmpty(), "must not start a service that cannot connect")
|
||||
assertEquals(1, recordingNotifications.dispatched.size)
|
||||
assertEquals(Notification.Type.Warning, recordingNotifications.dispatched.single().type)
|
||||
assertTrue(recordingNotifications.dispatched.single().deepLinkUri?.contains("connections") == true)
|
||||
assertTrue(recordingNotifications.dispatched.single().message.contains("Bluetooth"))
|
||||
val (_, message) = recordingNotifications.reconnectBlocked.single()
|
||||
assertTrue(message.contains("Bluetooth"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -120,7 +118,7 @@ class BootCompleteReceiverTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, shadowOf(application).allStartedServices.size)
|
||||
assertTrue(recordingNotifications.dispatched.isEmpty())
|
||||
assertTrue(recordingNotifications.reconnectBlocked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -205,7 +203,7 @@ class BootCompleteReceiverTest {
|
||||
modules(
|
||||
module {
|
||||
single { meshPrefs }
|
||||
single<NotificationManager> { recordingNotifications }
|
||||
single<MeshNotificationManager> { recordingNotifications }
|
||||
single {
|
||||
CoroutineDispatchers(io = ioDispatcher, main = defaultDispatcher, default = defaultDispatcher)
|
||||
}
|
||||
@@ -215,20 +213,7 @@ class BootCompleteReceiverTest {
|
||||
}
|
||||
|
||||
/** Captures what the receiver tried to tell the user when it declined to start the service. */
|
||||
private val recordingNotifications = RecordingNotificationManager()
|
||||
|
||||
private class RecordingNotificationManager : NotificationManager {
|
||||
val dispatched = mutableListOf<Notification>()
|
||||
|
||||
override suspend fun dispatch(notification: Notification): Boolean {
|
||||
dispatched += notification
|
||||
return true
|
||||
}
|
||||
|
||||
override fun cancel(id: Int) = Unit
|
||||
|
||||
override fun cancelAll() = Unit
|
||||
}
|
||||
private val recordingNotifications = FakeMeshNotificationManager()
|
||||
|
||||
/**
|
||||
* Grants BLUETOOTH_CONNECT unless told otherwise. Robolectric denies runtime permissions by default, and a boot
|
||||
|
||||
+19
-1
@@ -42,6 +42,7 @@ import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.Message
|
||||
import org.meshtastic.core.model.MyNodeInfo
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
@@ -116,6 +117,7 @@ class MeshNotificationManagerImplConversationTest {
|
||||
},
|
||||
radioConfigRepository = lazy { radioConfigRepository },
|
||||
radioOperationLock = RadioOperationLock(),
|
||||
firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository(),
|
||||
scope = scope.asServiceScope(),
|
||||
)
|
||||
|
||||
@@ -238,6 +240,22 @@ class MeshNotificationManagerImplConversationTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `conversation actions work from a watch without opening the phone`() = runWithRenderScope { scope ->
|
||||
val manager = createManager(scope).also { it.initChannels() }
|
||||
mockHistory(message("hello", read = false, receivedTime = 1_000))
|
||||
|
||||
manager.updateMessageNotification("0^all", "Hawk Ridge", "hello", isBroadcast = true, channelName = "LongFast")
|
||||
advanceUntilIdle()
|
||||
|
||||
val actions = activeByTag("message").single().notification.actions.orEmpty()
|
||||
val reply = actions.single { it.semanticAction == Notification.Action.SEMANTIC_ACTION_REPLY }
|
||||
assertTrue(reply.allowGeneratedReplies, "Smart Reply suggestions on a watch need generated replies allowed")
|
||||
val thumbsUp = actions.single { it.semanticAction == Notification.Action.SEMANTIC_ACTION_THUMBS_UP }
|
||||
assertEquals(false, thumbsUp.extras.getBoolean("android.support.action.showsUserInterface", true))
|
||||
assertTrue(actions.any { it.semanticAction == Notification.Action.SEMANTIC_ACTION_MARK_AS_READ })
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(sdk = [29])
|
||||
fun `conversation notifications post on Android 10`() = runWithRenderScope { scope ->
|
||||
@@ -313,7 +331,7 @@ class MeshNotificationManagerImplConversationTest {
|
||||
val manager = createManager(scope).also { it.initChannels() }
|
||||
// SERVICE_NOTIFY_ID is 101; a node whose num is also 101 used to overwrite the foreground notification.
|
||||
manager.updateServiceStateNotification(ConnectionState.Connected, telemetry = null)
|
||||
manager.showOrUpdateLowBatteryNotification(Node(num = 101), isRemote = false)
|
||||
manager.showLowBatteryNotification(Node(num = 101), isRemote = false)
|
||||
runUntilSettled {
|
||||
systemNotificationManager.activeNotifications.any { it.id == 101 && it.tag == null } &&
|
||||
activeByTag("low_battery").any { it.id == 101 }
|
||||
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.meshtastic.core.common.di.asServiceScope
|
||||
import org.meshtastic.core.common.state.RadioOperationLock
|
||||
import org.meshtastic.core.model.FirmwareUpdateDestination
|
||||
import org.meshtastic.core.model.FirmwareUpdateNotice
|
||||
import org.meshtastic.core.model.MeshBeaconOffer
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
|
||||
import org.meshtastic.core.repository.notificationId
|
||||
import org.meshtastic.core.testing.runWithRenderScope
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.DuplicatedPublicKey
|
||||
import org.meshtastic.proto.KeyVerificationFinal
|
||||
import org.meshtastic.proto.KeyVerificationNumberInform
|
||||
import org.meshtastic.proto.KeyVerificationNumberRequest
|
||||
import org.meshtastic.proto.LogRecord
|
||||
import org.meshtastic.proto.LowEntropyKey
|
||||
import org.meshtastic.proto.MeshBeacon
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.meshtastic.core.repository.Notification as MeshNotification
|
||||
|
||||
/** Every notification that is not a conversation: its channel, tray slot, tap target and alerting flags. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@Config(sdk = [34])
|
||||
class MeshNotificationManagerImplRoutingTest {
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
private val systemNotificationManager = context.getSystemService(NotificationManager::class.java)!!
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val main = ComponentName(context, "org.meshtastic.app.MainActivity")
|
||||
val activityInfo =
|
||||
ActivityInfo().apply {
|
||||
name = main.className
|
||||
packageName = main.packageName
|
||||
exported = true
|
||||
}
|
||||
shadowOf(context.packageManager).addOrUpdateActivity(activityInfo)
|
||||
systemNotificationManager.cancelAll()
|
||||
NotificationChannelSpec.entries.forEach { systemNotificationManager.deleteNotificationChannel(it.id) }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
systemNotificationManager.cancelAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a received waypoint posts on the waypoint channel and opens the map at it`() = runWithRenderScope { scope ->
|
||||
createManager(scope).updateWaypointNotification("0^all", "Hawk Ridge", "Camp", waypointId = 42)
|
||||
|
||||
val posted = activeByTag("waypoint").single().notification
|
||||
assertEquals(NotificationChannels.WAYPOINTS, posted.channelId)
|
||||
assertNull(posted.group, "a waypoint is not a conversation and must stay out of the messages summary")
|
||||
assertEquals("meshtastic://meshtastic/map?waypointId=42", tapTarget(posted))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a critical alert posts as an alarm that opens its conversation`() = runWithRenderScope { scope ->
|
||||
createManager(scope).showAlertNotification("0!abcd1234", "Hawk Ridge", "Fire at camp")
|
||||
|
||||
val posted = activeByTag("alert").single().notification
|
||||
assertEquals(NotificationChannels.ALERTS, posted.channelId)
|
||||
assertEquals(Notification.CATEGORY_ALARM, posted.category)
|
||||
assertEquals("meshtastic://meshtastic/messages/0!abcd1234", tapTarget(posted))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mesh invitation posts on its own channel and opens discovery`() = runWithRenderScope { scope ->
|
||||
val offer =
|
||||
MeshBeaconOffer(fromNodeNum = 7, beacon = MeshBeacon.Builder().also { wb -> wb.message = "Join" }.build())
|
||||
|
||||
createManager(scope).showMeshBeaconNotification(offer)
|
||||
|
||||
val posted = activeByTag("mesh_beacon").single()
|
||||
assertEquals(7, posted.id)
|
||||
assertEquals(NotificationChannels.MESH_BEACON, posted.notification.channelId)
|
||||
assertEquals("meshtastic://meshtastic/discovery", tapTarget(posted.notification))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new node posts under its own tag and is cancelled by node number`() = runWithRenderScope { scope ->
|
||||
val manager = createManager(scope)
|
||||
|
||||
manager.showNewNodeSeenNotification(Node(num = 101), "New node seen: N101")
|
||||
|
||||
val posted = activeByTag("new_node").single()
|
||||
assertEquals(101, posted.id)
|
||||
assertEquals(NotificationChannels.NEW_NODES, posted.notification.channelId)
|
||||
assertEquals("New node seen: N101", posted.notification.extras.getString(Notification.EXTRA_TITLE))
|
||||
assertEquals("meshtastic://meshtastic/nodes/101", tapTarget(posted.notification))
|
||||
|
||||
manager.cancelNewNodeNotification(101)
|
||||
assertTrue(activeByTag("new_node").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `low battery posts on the channel for whose battery it is`() = runWithRenderScope { scope ->
|
||||
val manager = createManager(scope)
|
||||
|
||||
manager.showLowBatteryNotification(Node(num = 1), isRemote = false)
|
||||
manager.showLowBatteryNotification(Node(num = 2), isRemote = true)
|
||||
|
||||
val byNode = activeByTag("low_battery").associateBy { it.id }
|
||||
assertEquals(NotificationChannels.LOW_BATTERY, byNode.getValue(1).notification.channelId)
|
||||
assertEquals(NotificationChannels.LOW_BATTERY_REMOTE, byNode.getValue(2).notification.channelId)
|
||||
assertEquals("meshtastic://meshtastic/nodes/2", tapTarget(byNode.getValue(2).notification))
|
||||
// Ongoing notifications never bridge to a watch.
|
||||
assertEquals(0, byNode.getValue(1).notification.flags and Notification.FLAG_ONGOING_EVENT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a low-battery refresh never brings back a dismissed warning`() = runWithRenderScope { scope ->
|
||||
val manager = createManager(scope)
|
||||
val node = Node(num = 3)
|
||||
|
||||
manager.showLowBatteryNotification(node, isRemote = false)
|
||||
manager.cancelLowBatteryNotification(node)
|
||||
manager.updateLowBatteryNotification(node, isRemote = false)
|
||||
|
||||
assertTrue(activeByTag("low_battery").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a warning still building when the battery recovers is never posted`() = runWithRenderScope { scope ->
|
||||
val manager = createManager(scope)
|
||||
val node = Node(num = 4)
|
||||
manager.beforeLowBatteryPost = { manager.cancelLowBatteryNotification(node) }
|
||||
|
||||
manager.showLowBatteryNotification(node, isRemote = false)
|
||||
|
||||
assertTrue(activeByTag("low_battery").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `radio notices post on their own channel and clear by identity`() = runWithRenderScope { scope ->
|
||||
val manager = createManager(scope)
|
||||
val notice = ClientNotification.Builder().also { wb -> wb.message = "Generic warning" }.build()
|
||||
|
||||
manager.showClientNotification(notice, "Radio notice", MeshNotification.Type.Warning)
|
||||
|
||||
val posted = activeByTag("client").single()
|
||||
assertEquals(notice.notificationId(), posted.id)
|
||||
assertEquals(NotificationChannels.CLIENT, posted.notification.channelId)
|
||||
assertEquals(0, posted.notification.flags and Notification.FLAG_ONLY_ALERT_ONCE)
|
||||
|
||||
manager.clearClientNotification(notice)
|
||||
assertTrue(activeByTag("client").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated position advisories share one tray slot and alert once`() = runWithRenderScope { scope ->
|
||||
val manager = createManager(scope)
|
||||
val first = protectedPositionAdvisory(replyId = 123, time = 1_000)
|
||||
val second = protectedPositionAdvisory(replyId = 456, time = 2_000)
|
||||
|
||||
listOf(first, second).forEach { manager.showClientNotification(it, "Radio notice", MeshNotification.Type.Info) }
|
||||
|
||||
val posted = activeByTag("client").single()
|
||||
assertTrue(posted.notification.flags and Notification.FLAG_ONLY_ALERT_ONCE != 0)
|
||||
assertTrue(manager.suppressClientNotificationModal(first))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the exact position advisory skips the in-app modal`() = runWithRenderScope { scope ->
|
||||
val manager = createManager(scope)
|
||||
val advisory = protectedPositionAdvisory(replyId = 123, time = 1_000)
|
||||
val nearMisses =
|
||||
listOf(
|
||||
advisory.newBuilder().also { wb -> wb.message = "Location sharing is disabled" }.build(),
|
||||
advisory.newBuilder().also { wb -> wb.level = LogRecord.Level.INFO }.build(),
|
||||
advisory.newBuilder().also { wb -> wb.reply_id = 0 }.build(),
|
||||
advisory.newBuilder().also { wb -> wb.reply_id = null }.build(),
|
||||
advisory
|
||||
.newBuilder()
|
||||
.also { wb -> wb.key_verification_number_inform = KeyVerificationNumberInform.Builder().build() }
|
||||
.build(),
|
||||
advisory
|
||||
.newBuilder()
|
||||
.also { wb -> wb.key_verification_number_request = KeyVerificationNumberRequest.Builder().build() }
|
||||
.build(),
|
||||
advisory
|
||||
.newBuilder()
|
||||
.also { wb -> wb.key_verification_final = KeyVerificationFinal.Builder().build() }
|
||||
.build(),
|
||||
advisory
|
||||
.newBuilder()
|
||||
.also { wb -> wb.duplicated_public_key = DuplicatedPublicKey.Builder().build() }
|
||||
.build(),
|
||||
advisory.newBuilder().also { wb -> wb.low_entropy_key = LowEntropyKey.Builder().build() }.build(),
|
||||
advisory.newBuilder().also { wb -> wb.message = "Rebooting to WiFi OTA" }.build(),
|
||||
)
|
||||
|
||||
assertTrue(manager.suppressClientNotificationModal(advisory))
|
||||
nearMisses.forEach { assertFalse(manager.suppressClientNotificationModal(it), it.toString()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a firmware update opens in-app firmware updates whatever its destination`() = runWithRenderScope { scope ->
|
||||
val accepted = createManager(scope).showFirmwareUpdateNotice(FirmwareUpdateDestination.MeshtasticFlasher)
|
||||
|
||||
assertTrue(accepted)
|
||||
val posted = activeByTag("firmware_update").single().notification
|
||||
assertEquals(NotificationChannels.DEVICE_STATUS, posted.channelId)
|
||||
assertEquals("meshtastic://meshtastic/firmware/update", tapTarget(posted))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a firmware update on a blocked channel reports that it was not shown`() = runWithRenderScope { scope ->
|
||||
val manager = createManager(scope)
|
||||
manager.ensureChannels()
|
||||
systemNotificationManager.createNotificationChannel(
|
||||
NotificationChannel(NotificationChannels.DEVICE_STATUS, "blocked", NotificationManager.IMPORTANCE_NONE),
|
||||
)
|
||||
|
||||
assertFalse(manager.showFirmwareUpdateNotice(FirmwareUpdateDestination.AndroidUpdate))
|
||||
assertTrue(activeByTag("firmware_update").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reconnect-blocked warning replaces itself and opens connections`() = runWithRenderScope { scope ->
|
||||
val manager = createManager(scope)
|
||||
|
||||
repeat(2) { manager.showReconnectBlockedNotification("Meshtastic can't reconnect", "Bluetooth is off") }
|
||||
|
||||
val posted = activeByTag("reconnect_blocked").single().notification
|
||||
assertEquals(NotificationChannels.DEVICE_STATUS, posted.channelId)
|
||||
assertEquals("meshtastic://meshtastic/connections", tapTarget(posted))
|
||||
}
|
||||
|
||||
private suspend fun MeshNotificationManagerImpl.showFirmwareUpdateNotice(destination: FirmwareUpdateDestination) =
|
||||
showFirmwareUpdateNotification(
|
||||
FirmwareUpdateNotice(
|
||||
notificationKey = "node|2.8.0",
|
||||
currentVersion = "2.7.0",
|
||||
stableVersion = "2.8.0",
|
||||
destination = destination,
|
||||
),
|
||||
)
|
||||
|
||||
private fun createManager(scope: CoroutineScope) = MeshNotificationManagerImpl(
|
||||
context = context,
|
||||
packetRepository = lazy { error("Not used in this test") },
|
||||
nodeRepository = lazy { error("Not used in this test") },
|
||||
conversationShortcutPublisher = lazy { error("Not used in this test") },
|
||||
radioConfigRepository = lazy { error("Not used in this test") },
|
||||
radioOperationLock = RadioOperationLock(),
|
||||
firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository(),
|
||||
scope = scope.asServiceScope(),
|
||||
)
|
||||
|
||||
private fun activeByTag(tag: String): List<StatusBarNotification> =
|
||||
systemNotificationManager.activeNotifications.filter { it.tag == tag }
|
||||
|
||||
private fun tapTarget(notification: Notification): String? =
|
||||
shadowOf(notification.contentIntent).savedIntent.data?.toString()
|
||||
|
||||
private fun protectedPositionAdvisory(replyId: Int, time: Int) = ClientNotification.Builder()
|
||||
.also { wb ->
|
||||
wb.message = PROTECTED_POSITION_ADVISORY_MESSAGE
|
||||
wb.reply_id = replyId
|
||||
wb.time = time
|
||||
wb.level = LogRecord.Level.WARNING
|
||||
}
|
||||
.build()
|
||||
}
|
||||
+107
-34
@@ -20,6 +20,7 @@ import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import dev.mokkery.MockMode
|
||||
@@ -27,8 +28,10 @@ import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.mock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -37,11 +40,15 @@ import org.meshtastic.core.common.di.asServiceScope
|
||||
import org.meshtastic.core.common.state.RadioOperationLock
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.MyNodeInfo
|
||||
import org.meshtastic.core.repository.FirmwareUpdateProgress
|
||||
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.UiText
|
||||
import org.meshtastic.core.resources.disconnected
|
||||
import org.meshtastic.core.resources.firmware_update_in_progress
|
||||
import org.meshtastic.core.resources.getString
|
||||
import org.meshtastic.core.resources.local_stats_nodes
|
||||
import org.meshtastic.core.testing.runUntilSettled
|
||||
@@ -61,6 +68,7 @@ class MeshNotificationManagerImplTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var systemNotificationManager: NotificationManager
|
||||
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||
private val firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
@@ -78,30 +86,76 @@ class MeshNotificationManagerImplTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initChannels removes legacy categories and creates canonical channels`() = runWithRenderScope { renderScope ->
|
||||
NotificationChannels.LEGACY_CATEGORY_IDS.forEach(::createChannel)
|
||||
val notifications = createManager(renderScope)
|
||||
notifications.initChannels()
|
||||
fun `initChannels removes legacy categories and has the service channel ready at once`() =
|
||||
runWithRenderScope { renderScope ->
|
||||
NotificationChannels.LEGACY_CATEGORY_IDS.forEach(::createChannel)
|
||||
val notifications = createManager(renderScope)
|
||||
notifications.initChannels()
|
||||
|
||||
NotificationChannels.LEGACY_CATEGORY_IDS.forEach { legacyId ->
|
||||
assertNull(systemNotificationManager.getNotificationChannel(legacyId))
|
||||
NotificationChannels.LEGACY_CATEGORY_IDS.forEach { legacyId ->
|
||||
assertNull(systemNotificationManager.getNotificationChannel(legacyId))
|
||||
}
|
||||
// The foreground-service notification is posted right after initChannels returns, and the platform (not
|
||||
// Robolectric) rejects a channel whose group does not exist yet.
|
||||
val service = assertNotNull(systemNotificationManager.getNotificationChannel(NotificationChannels.SERVICE))
|
||||
assertEquals(NotificationChannelGroupSpec.Device.id, service.group)
|
||||
assertNotNull(systemNotificationManager.getNotificationChannelGroup(NotificationChannelGroupSpec.Device.id))
|
||||
}
|
||||
|
||||
val canonicalChannelIds =
|
||||
listOf(
|
||||
NotificationChannels.SERVICE,
|
||||
NotificationChannels.MESSAGES,
|
||||
NotificationChannels.BROADCASTS,
|
||||
NotificationChannels.WAYPOINTS,
|
||||
NotificationChannels.ALERTS,
|
||||
NotificationChannels.NEW_NODES,
|
||||
NotificationChannels.LOW_BATTERY,
|
||||
NotificationChannels.LOW_BATTERY_REMOTE,
|
||||
NotificationChannels.CLIENT,
|
||||
)
|
||||
/**
|
||||
* Pins every channel's importance and group. Importance is fixed when a channel is first created, so changing one
|
||||
* here only reaches new installs, and lowering one below what shipped also lowers it on unmodified existing
|
||||
* installs: an edit to this table is a product decision, not a refactor.
|
||||
*/
|
||||
@Test
|
||||
fun `every channel is created with its importance, group and description`() = runWithRenderScope { renderScope ->
|
||||
// Labels load on real threads; under virtual time the label timeout would fire at once and fall back.
|
||||
withContext(Dispatchers.Default) { createManager(renderScope).ensureChannels() }
|
||||
|
||||
canonicalChannelIds.forEach { channelId ->
|
||||
assertNotNull(systemNotificationManager.getNotificationChannel(channelId))
|
||||
NotificationChannelSpec.entries.forEach { spec ->
|
||||
val (importance, group) =
|
||||
when (spec) {
|
||||
NotificationChannelSpec.Service ->
|
||||
NotificationManager.IMPORTANCE_LOW to NotificationChannelGroupSpec.Device
|
||||
|
||||
NotificationChannelSpec.DirectMessages ->
|
||||
NotificationManager.IMPORTANCE_HIGH to NotificationChannelGroupSpec.Messages
|
||||
|
||||
NotificationChannelSpec.Broadcasts ->
|
||||
NotificationManager.IMPORTANCE_DEFAULT to NotificationChannelGroupSpec.Messages
|
||||
|
||||
NotificationChannelSpec.Waypoints ->
|
||||
NotificationManager.IMPORTANCE_DEFAULT to NotificationChannelGroupSpec.Messages
|
||||
|
||||
NotificationChannelSpec.Alerts ->
|
||||
NotificationManager.IMPORTANCE_HIGH to NotificationChannelGroupSpec.Messages
|
||||
|
||||
NotificationChannelSpec.NewNodes ->
|
||||
NotificationManager.IMPORTANCE_DEFAULT to NotificationChannelGroupSpec.Mesh
|
||||
|
||||
NotificationChannelSpec.MeshBeacon ->
|
||||
NotificationManager.IMPORTANCE_LOW to NotificationChannelGroupSpec.Mesh
|
||||
|
||||
NotificationChannelSpec.LowBatteryRemote ->
|
||||
NotificationManager.IMPORTANCE_DEFAULT to NotificationChannelGroupSpec.Mesh
|
||||
|
||||
NotificationChannelSpec.LowBattery ->
|
||||
NotificationManager.IMPORTANCE_DEFAULT to NotificationChannelGroupSpec.Device
|
||||
|
||||
NotificationChannelSpec.Client ->
|
||||
NotificationManager.IMPORTANCE_HIGH to NotificationChannelGroupSpec.Device
|
||||
|
||||
NotificationChannelSpec.DeviceStatus ->
|
||||
NotificationManager.IMPORTANCE_DEFAULT to NotificationChannelGroupSpec.Device
|
||||
}
|
||||
val channel = assertNotNull(systemNotificationManager.getNotificationChannel(spec.id), spec.name)
|
||||
assertEquals(importance, channel.importance, spec.name)
|
||||
assertEquals(group.id, channel.group, spec.name)
|
||||
assertEquals(getString(spec.nameRes), channel.name.toString(), spec.name)
|
||||
assertEquals(getString(spec.descriptionRes), channel.description, spec.name)
|
||||
}
|
||||
NotificationChannelGroupSpec.entries.forEach { group ->
|
||||
assertNotNull(systemNotificationManager.getNotificationChannelGroup(group.id), group.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +215,33 @@ class MeshNotificationManagerImplTest {
|
||||
assertNotNull(activeServiceNotification())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running flash makes the service notification a promotable progress notification`() =
|
||||
runWithRenderScope { renderScope ->
|
||||
val notifications = createManager(renderScope)
|
||||
notifications.initChannels()
|
||||
notifications.updateServiceStateNotification(ConnectionState.Disconnected, populatedTelemetry())
|
||||
runUntilSettled { activeServiceNotification() != null }
|
||||
|
||||
firmwareUpdateStatusRepository.publishProgress(
|
||||
FirmwareUpdateProgress(UiText.DynamicString("Writing firmware"), percent = 42),
|
||||
)
|
||||
runUntilSettled { serviceExtras()?.getInt(Notification.EXTRA_PROGRESS) == 42 }
|
||||
|
||||
val posted = assertNotNull(activeServiceNotification()).notification
|
||||
assertEquals(getString(Res.string.firmware_update_in_progress), serviceTitle())
|
||||
assertEquals("Writing firmware", serviceExtras()?.getCharSequence(Notification.EXTRA_TEXT)?.toString())
|
||||
assertTrue(NotificationCompat.isRequestPromotedOngoing(posted))
|
||||
assertEquals("42%", posted.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT))
|
||||
|
||||
firmwareUpdateStatusRepository.publishProgress(null)
|
||||
runUntilSettled {
|
||||
activeServiceNotification()?.notification?.let { !NotificationCompat.isRequestPromotedOngoing(it) } ==
|
||||
true
|
||||
}
|
||||
assertEquals(getString(Res.string.disconnected), serviceTitle())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `service state seeds local stats before the local node row is available`() = runWithRenderScope { renderScope ->
|
||||
val stats =
|
||||
@@ -190,6 +271,7 @@ class MeshNotificationManagerImplTest {
|
||||
conversationShortcutPublisher = lazy { error("Not used in this test") },
|
||||
radioConfigRepository = lazy { error("Not used in this test") },
|
||||
radioOperationLock = RadioOperationLock(),
|
||||
firmwareUpdateStatusRepository = firmwareUpdateStatusRepository,
|
||||
scope = scope.asServiceScope(),
|
||||
)
|
||||
|
||||
@@ -209,6 +291,10 @@ class MeshNotificationManagerImplTest {
|
||||
private fun activeServiceNotification() =
|
||||
systemNotificationManager.activeNotifications.singleOrNull { it.id == SERVICE_NOTIFY_ID }
|
||||
|
||||
private fun serviceExtras() = activeServiceNotification()?.notification?.extras
|
||||
|
||||
private fun serviceTitle() = serviceExtras()?.getCharSequence(Notification.EXTRA_TITLE)?.toString()
|
||||
|
||||
private fun createChannel(id: String) {
|
||||
systemNotificationManager.createNotificationChannel(
|
||||
NotificationChannel(id, id, NotificationManager.IMPORTANCE_DEFAULT),
|
||||
@@ -216,20 +302,7 @@ class MeshNotificationManagerImplTest {
|
||||
}
|
||||
|
||||
private fun clearManagedChannels() {
|
||||
val channelIds =
|
||||
NotificationChannels.LEGACY_CATEGORY_IDS +
|
||||
listOf(
|
||||
NotificationChannels.SERVICE,
|
||||
NotificationChannels.MESSAGES,
|
||||
NotificationChannels.BROADCASTS,
|
||||
NotificationChannels.WAYPOINTS,
|
||||
NotificationChannels.ALERTS,
|
||||
NotificationChannels.NEW_NODES,
|
||||
NotificationChannels.LOW_BATTERY,
|
||||
NotificationChannels.LOW_BATTERY_REMOTE,
|
||||
NotificationChannels.CLIENT,
|
||||
)
|
||||
|
||||
val channelIds = NotificationChannels.LEGACY_CATEGORY_IDS + NotificationChannelSpec.entries.map { it.id }
|
||||
channelIds.forEach { channelId -> systemNotificationManager.deleteNotificationChannel(channelId) }
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verifySuspend
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.RadioController
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ReactionReceiverTest {
|
||||
|
||||
private val radioController: RadioController = mock(MockMode.autofill)
|
||||
private val notificationManager: MeshNotificationManager = mock(MockMode.autofill)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
startKoin {
|
||||
modules(
|
||||
module {
|
||||
single { radioController }
|
||||
single { notificationManager }
|
||||
// Unconfined so the receiver's launched coroutine completes before onReceive returns
|
||||
single {
|
||||
CoroutineDispatchers(
|
||||
io = Dispatchers.Unconfined,
|
||||
main = Dispatchers.Unconfined,
|
||||
default = Dispatchers.Unconfined,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
stopKoin()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a thumbs-up from the notification is sent and shown in the conversation`() {
|
||||
val contactKey = "0!12345678"
|
||||
val intent =
|
||||
Intent(ReactionReceiver.REACT_ACTION)
|
||||
.putExtra(ReactionReceiver.EXTRA_CONTACT_KEY, contactKey)
|
||||
.putExtra(ReactionReceiver.EXTRA_REPLY_ID, 42)
|
||||
.putExtra(ReactionReceiver.EXTRA_EMOJI, "👍")
|
||||
|
||||
ReactionReceiver().onReceive(ApplicationProvider.getApplicationContext(), intent)
|
||||
|
||||
verifySuspend { radioController.sendReaction("👍", 42, contactKey) }
|
||||
verifySuspend { notificationManager.refreshConversationAfterReply(contactKey) }
|
||||
}
|
||||
}
|
||||
-215
@@ -1,215 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.PendingIntent
|
||||
import android.app.TaskStackBuilder
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.resources.R.drawable
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.meshtastic_alerts_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_low_battery_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_mesh_beacon_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_messages_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_new_nodes_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_service_notifications
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import android.app.NotificationManager as SystemNotificationManager
|
||||
|
||||
@Single
|
||||
class AndroidNotificationManager(private val context: Context) : NotificationManager {
|
||||
|
||||
private val notificationManager =
|
||||
checkNotNull(context.getSystemService<SystemNotificationManager>()) { "NotificationManager not found" }
|
||||
|
||||
private data class ChannelConfig(val id: String, val importance: Int)
|
||||
|
||||
/**
|
||||
* Tracks whether notification channels have been created.
|
||||
*
|
||||
* Channels are **not** created in the constructor because this singleton is instantiated by Koin during
|
||||
* [org.meshtastic.core.service.MeshService.onCreate] on the main thread, and channel names come from string
|
||||
* resources. Instead, channels are lazily ensured before the first [dispatch] call. Note that
|
||||
* [MeshNotificationManagerImpl.initChannels] already creates a superset of these channels when the orchestrator
|
||||
* starts, so this lazy path is only a safety net for notifications dispatched before orchestrator initialization.
|
||||
*
|
||||
* The mutex is load-bearing: resolving the names suspends, so without it two concurrent [dispatch] calls could both
|
||||
* pass the flag check and post before the channels exist.
|
||||
*/
|
||||
private var channelsInitialized = false
|
||||
private val channelInitMutex = Mutex()
|
||||
|
||||
private suspend fun ensureChannelsInitialized() = channelInitMutex.withLock {
|
||||
if (channelsInitialized) return@withLock
|
||||
channelsInitialized = true
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channels =
|
||||
listOf(
|
||||
createChannel(Notification.Category.Message, Res.string.meshtastic_messages_notifications),
|
||||
createChannel(Notification.Category.NodeEvent, Res.string.meshtastic_new_nodes_notifications),
|
||||
createChannel(
|
||||
Notification.Category.MeshBeacon,
|
||||
Res.string.meshtastic_mesh_beacon_notifications,
|
||||
),
|
||||
createChannel(Notification.Category.Battery, Res.string.meshtastic_low_battery_notifications),
|
||||
createChannel(Notification.Category.Alert, Res.string.meshtastic_alerts_notifications),
|
||||
createChannel(Notification.Category.Service, Res.string.meshtastic_service_notifications),
|
||||
)
|
||||
notificationManager.createNotificationChannels(channels)
|
||||
notificationManager.removeLegacyCategoryChannels()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createChannel(
|
||||
category: Notification.Category,
|
||||
nameRes: org.jetbrains.compose.resources.StringResource,
|
||||
): NotificationChannel {
|
||||
val channelConfig = category.channelConfig()
|
||||
return NotificationChannel(channelConfig.id, getStringSuspend(nameRes), channelConfig.importance)
|
||||
}
|
||||
|
||||
// Keep category-to-channel mapping aligned with MeshNotificationManagerImpl.NotificationType IDs.
|
||||
private fun Notification.Category.channelConfig(): ChannelConfig = when (this) {
|
||||
Notification.Category.Message ->
|
||||
ChannelConfig(
|
||||
id = NotificationChannels.MESSAGES,
|
||||
importance = SystemNotificationManager.IMPORTANCE_HIGH,
|
||||
)
|
||||
|
||||
Notification.Category.NodeEvent ->
|
||||
ChannelConfig(
|
||||
id = NotificationChannels.NEW_NODES,
|
||||
importance = SystemNotificationManager.IMPORTANCE_DEFAULT,
|
||||
)
|
||||
|
||||
Notification.Category.MeshBeacon ->
|
||||
ChannelConfig(
|
||||
id = NotificationChannels.MESH_BEACON,
|
||||
importance = SystemNotificationManager.IMPORTANCE_LOW,
|
||||
)
|
||||
|
||||
Notification.Category.Battery ->
|
||||
ChannelConfig(
|
||||
id = NotificationChannels.LOW_BATTERY,
|
||||
importance = SystemNotificationManager.IMPORTANCE_DEFAULT,
|
||||
)
|
||||
|
||||
Notification.Category.Alert ->
|
||||
ChannelConfig(id = NotificationChannels.ALERTS, importance = SystemNotificationManager.IMPORTANCE_HIGH)
|
||||
|
||||
Notification.Category.Service ->
|
||||
ChannelConfig(id = NotificationChannels.SERVICE, importance = SystemNotificationManager.IMPORTANCE_MIN)
|
||||
}
|
||||
|
||||
override suspend fun dispatch(notification: Notification): Boolean = dispatch(notification, onlyAlertOnce = false)
|
||||
|
||||
override fun suppressClientNotificationModal(notification: ClientNotification): Boolean =
|
||||
notification.isProtectedPositionAdvisory()
|
||||
|
||||
// The advisory's id already comes from ClientNotification.notificationId(), which is stable across repeats (see
|
||||
// its kdoc) — so it lands in the same tray slot without a dedicated tag or fixed id; only onlyAlertOnce is needed
|
||||
// to stop it from re-alerting on every update.
|
||||
override suspend fun dispatchClientNotification(
|
||||
notification: Notification,
|
||||
clientNotification: ClientNotification,
|
||||
): Boolean = dispatch(notification, onlyAlertOnce = clientNotification.isProtectedPositionAdvisory())
|
||||
|
||||
private suspend fun dispatch(notification: Notification, onlyAlertOnce: Boolean): Boolean {
|
||||
ensureChannelsInitialized()
|
||||
val channelId = notification.category.channelConfig().id
|
||||
if (!canPostNotifications(channelId)) return false
|
||||
val id = notification.id ?: notification.hashCode()
|
||||
val builder =
|
||||
NotificationCompat.Builder(context, channelId)
|
||||
.setContentTitle(notification.title)
|
||||
.setContentText(notification.message)
|
||||
.setSmallIcon(drawable.meshtastic_ic_notification)
|
||||
.setAutoCancel(true)
|
||||
.setSilent(notification.isSilent)
|
||||
|
||||
notification.group?.let { builder.setGroup(it) }
|
||||
if (onlyAlertOnce) builder.setOnlyAlertOnce(true)
|
||||
|
||||
if (notification.type == Notification.Type.Error) {
|
||||
builder.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
}
|
||||
|
||||
notification.deepLinkUri?.let { uri -> builder.setContentIntent(createDeepLinkPendingIntent(uri, id)) }
|
||||
|
||||
return try {
|
||||
notificationManager.notify(id, builder.build())
|
||||
true
|
||||
} catch (_: SecurityException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun canPostNotifications(channelId: String): Boolean =
|
||||
NotificationManagerCompat.from(context).areNotificationsEnabled() &&
|
||||
(
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||
notificationManager.getNotificationChannel(channelId)?.importance !=
|
||||
SystemNotificationManager.IMPORTANCE_NONE
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds a [PendingIntent] that launches [MainActivity] with the given deep-link URI as [Intent.ACTION_VIEW], so
|
||||
* the existing deep-link plumbing (`UIViewModel.handleDeepLink` → `DeepLinkRouter` → `MultiBackstack`) can
|
||||
* synthesize the proper backstack and surface the target screen.
|
||||
*
|
||||
* Uses [Class.forName] to avoid pulling the `:androidApp` module into `:core:service` as a Gradle dep.
|
||||
*/
|
||||
private fun createDeepLinkPendingIntent(uri: String, requestCode: Int): PendingIntent {
|
||||
val deepLinkIntent =
|
||||
Intent(Intent.ACTION_VIEW, uri.toUri(), context, Class.forName(MAIN_ACTIVITY_CLASS)).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
return TaskStackBuilder.create(context).run {
|
||||
addNextIntentWithParentStack(deepLinkIntent)
|
||||
getPendingIntent(requestCode, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)!!
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel(id: Int) {
|
||||
notificationManager.cancel(id)
|
||||
}
|
||||
|
||||
override fun cancelAll() {
|
||||
notificationManager.cancelAll()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Fully-qualified name of the host activity that handles `meshtastic://` deep-link intents. Kept as a string to
|
||||
* avoid creating a module dependency from `:core:service` back onto `:androidApp`.
|
||||
*/
|
||||
const val MAIN_ACTIVITY_CLASS = "org.meshtastic.app.MainActivity"
|
||||
}
|
||||
}
|
||||
+5
-20
@@ -39,9 +39,8 @@ import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.meshtastic.core.common.util.safeCatchingAll
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.MeshPrefs
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.boot_reconnect_blocked_message
|
||||
import org.meshtastic.core.resources.boot_reconnect_blocked_title
|
||||
@@ -54,7 +53,7 @@ class BootCompleteReceiver :
|
||||
|
||||
private val meshPrefs: MeshPrefs by inject()
|
||||
private val dispatchers: CoroutineDispatchers by inject()
|
||||
private val notificationManager: NotificationManager by inject()
|
||||
private val serviceNotifications: MeshNotificationManager by inject()
|
||||
private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchers.default) }
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
@@ -121,8 +120,8 @@ class BootCompleteReceiver :
|
||||
* Posts the one thing the user can act on: a notification naming the missing permission and opening the Connections
|
||||
* screen, where the recovery card now lives.
|
||||
*
|
||||
* Best-effort by design. If POST_NOTIFICATIONS is also denied the dispatch simply returns false — there is no
|
||||
* surface left to reach an absent user through, and failing loudly here would help nobody.
|
||||
* Best-effort by design. If POST_NOTIFICATIONS is also denied the post simply returns false — there is no surface
|
||||
* left to reach an absent user through, and failing loudly here would help nobody.
|
||||
*/
|
||||
private suspend fun notifyBluetoothPermissionMissing() {
|
||||
// Untranslated fallbacks rather than no notification, and a hard bound on the wait. A boot broadcast runs
|
||||
@@ -133,16 +132,7 @@ class BootCompleteReceiver :
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = title,
|
||||
message = message,
|
||||
type = Notification.Type.Warning,
|
||||
category = Notification.Category.Service,
|
||||
id = BLE_PERMISSION_NOTIFICATION_ID,
|
||||
deepLinkUri = CONNECTIONS_DEEP_LINK,
|
||||
),
|
||||
)
|
||||
serviceNotifications.showReconnectBlockedNotification(title, message)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
@@ -161,11 +151,6 @@ class BootCompleteReceiver :
|
||||
/** A broadcast has seconds, not indefinite time; fall back to untranslated text rather than stall. */
|
||||
const val STRING_RESOLVE_TIMEOUT_MILLIS = 2_000L
|
||||
|
||||
/** Stable id so a second boot replaces the notice rather than stacking another copy. */
|
||||
const val BLE_PERMISSION_NOTIFICATION_ID = 0x81E9
|
||||
|
||||
const val CONNECTIONS_DEEP_LINK = "meshtastic://meshtastic/connections"
|
||||
|
||||
const val UNTRANSLATED_BLOCKED_TITLE = "Meshtastic can't reconnect"
|
||||
const val UNTRANSLATED_BLOCKED_MESSAGE =
|
||||
"Nearby devices permission is off, so your radio cannot be reached over Bluetooth. " +
|
||||
|
||||
+303
-260
@@ -17,16 +17,13 @@
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.TaskStackBuilder
|
||||
import android.content.ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioAttributes
|
||||
import android.media.RingtoneManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.content.LocusIdCompat
|
||||
@@ -39,39 +36,47 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.di.ServiceScope
|
||||
import org.meshtastic.core.common.state.RadioOperation
|
||||
import org.meshtastic.core.common.state.RadioOperationLock
|
||||
import org.meshtastic.core.common.util.MetricFormatter
|
||||
import org.meshtastic.core.common.util.NumberFormatter
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.safeCatching
|
||||
import org.meshtastic.core.model.Channel
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.FirmwareUpdateNotice
|
||||
import org.meshtastic.core.model.MeshBeaconOffer
|
||||
import org.meshtastic.core.model.Message
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.NodeAddress
|
||||
import org.meshtastic.core.model.noiseFloorOrNull
|
||||
import org.meshtastic.core.model.util.formatUptime
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
import org.meshtastic.core.repository.FirmwareUpdateProgress
|
||||
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
|
||||
import org.meshtastic.core.repository.notificationId
|
||||
import org.meshtastic.core.resources.R.drawable
|
||||
import org.meshtastic.core.resources.R.raw
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.channel
|
||||
import org.meshtastic.core.resources.client_notification
|
||||
import org.meshtastic.core.resources.connected
|
||||
import org.meshtastic.core.resources.connecting
|
||||
import org.meshtastic.core.resources.device_sleeping
|
||||
import org.meshtastic.core.resources.disconnected
|
||||
import org.meshtastic.core.resources.discovery_scan_in_progress
|
||||
import org.meshtastic.core.resources.firmware_update_available
|
||||
import org.meshtastic.core.resources.firmware_update_in_progress
|
||||
import org.meshtastic.core.resources.getString
|
||||
import org.meshtastic.core.resources.firmware_update_notification_android
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.local_stats_bad
|
||||
import org.meshtastic.core.resources.local_stats_battery
|
||||
@@ -88,17 +93,11 @@ import org.meshtastic.core.resources.local_stats_utilization
|
||||
import org.meshtastic.core.resources.low_battery_message
|
||||
import org.meshtastic.core.resources.low_battery_title
|
||||
import org.meshtastic.core.resources.mark_as_read
|
||||
import org.meshtastic.core.resources.meshtastic_alerts_notifications
|
||||
import org.meshtastic.core.resources.mesh_beacon_notification_body
|
||||
import org.meshtastic.core.resources.mesh_beacon_notification_title
|
||||
import org.meshtastic.core.resources.meshtastic_app_name
|
||||
import org.meshtastic.core.resources.meshtastic_broadcast_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_low_battery_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_low_battery_temporary_remote_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_messages_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_new_nodes_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_service_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_waypoints_notifications
|
||||
import org.meshtastic.core.resources.new_node_seen
|
||||
import org.meshtastic.core.resources.no_local_stats
|
||||
import org.meshtastic.core.resources.notification_reaction_to
|
||||
import org.meshtastic.core.resources.powered
|
||||
import org.meshtastic.core.resources.reply
|
||||
import org.meshtastic.core.resources.unknown_username
|
||||
@@ -112,6 +111,8 @@ import org.meshtastic.proto.LocalStats
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import org.meshtastic.core.repository.Notification as MeshNotification
|
||||
|
||||
/**
|
||||
* Manages the creation and display of all app notifications.
|
||||
@@ -128,6 +129,7 @@ class MeshNotificationManagerImpl(
|
||||
private val conversationShortcutPublisher: Lazy<ConversationShortcutPublisher>,
|
||||
private val radioConfigRepository: Lazy<RadioConfigRepository>,
|
||||
private val radioOperationLock: RadioOperationLock,
|
||||
private val firmwareUpdateStatusRepository: FirmwareUpdateStatusRepository,
|
||||
private val scope: ServiceScope,
|
||||
) : MeshNotificationManager {
|
||||
|
||||
@@ -137,9 +139,6 @@ class MeshNotificationManagerImpl(
|
||||
companion object {
|
||||
const val MAX_BATTERY_LEVEL = 100
|
||||
|
||||
// Meshtastic brand accent (Green 500, see .skills/design-standards) — used as the notification accent color
|
||||
// (small-icon tint) and the notification LED color.
|
||||
private val NOTIFICATION_COLOR = 0xFF67EA94.toInt()
|
||||
private const val MAX_HISTORY_MESSAGES = 10
|
||||
private const val MIN_CONTEXT_MESSAGES = 3
|
||||
private const val SNIPPET_LENGTH = 30
|
||||
@@ -168,6 +167,12 @@ class MeshNotificationManagerImpl(
|
||||
private const val TAG_NEW_NODE = "new_node"
|
||||
private const val TAG_LOW_BATTERY = "low_battery"
|
||||
private const val TAG_CLIENT = "client"
|
||||
private const val TAG_MESH_BEACON = "mesh_beacon"
|
||||
private const val TAG_FIRMWARE_UPDATE = "firmware_update"
|
||||
private const val TAG_RECONNECT_BLOCKED = "reconnect_blocked"
|
||||
private const val RECONNECT_BLOCKED_ID = 1
|
||||
private val CHANNEL_LABEL_TIMEOUT = 2.seconds
|
||||
private const val MAIN_ACTIVITY_CLASS = "org.meshtastic.app.MainActivity"
|
||||
}
|
||||
|
||||
private data class ServiceNotificationSnapshot(
|
||||
@@ -177,9 +182,11 @@ class MeshNotificationManagerImpl(
|
||||
val previousMessage: String?,
|
||||
val nextUpdateAt: Long,
|
||||
val activeOperations: Set<RadioOperation> = emptySet(),
|
||||
val firmwareProgress: FirmwareUpdateProgress? = null,
|
||||
)
|
||||
|
||||
private data class RenderedServiceNotification(val notification: Notification, val message: String)
|
||||
/** [message] is the stats text to fall back on later; a firmware-progress render keeps the previous one. */
|
||||
private data class RenderedServiceNotification(val notification: Notification, val message: String?)
|
||||
|
||||
/**
|
||||
* Caches generated avatar icons keyed by (person id + short name + colors) so a conversation rebuild does not
|
||||
@@ -200,169 +207,84 @@ class MeshNotificationManagerImpl(
|
||||
PersonIconFactory.createLabel(shortName, backgroundColor, foregroundColor, rounded = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed class to define the properties of each notification channel. This centralizes channel configuration and
|
||||
* makes it type-safe.
|
||||
*/
|
||||
private sealed class NotificationType(
|
||||
val channelId: String,
|
||||
val channelNameRes: StringResource,
|
||||
val importance: Int,
|
||||
) {
|
||||
object ServiceState :
|
||||
NotificationType(
|
||||
NotificationChannels.SERVICE,
|
||||
Res.string.meshtastic_service_notifications,
|
||||
NotificationManager.IMPORTANCE_MIN,
|
||||
)
|
||||
|
||||
object DirectMessage :
|
||||
NotificationType(
|
||||
NotificationChannels.MESSAGES,
|
||||
Res.string.meshtastic_messages_notifications,
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
)
|
||||
|
||||
object BroadcastMessage :
|
||||
NotificationType(
|
||||
NotificationChannels.BROADCASTS,
|
||||
Res.string.meshtastic_broadcast_notifications,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
)
|
||||
|
||||
object Waypoint :
|
||||
NotificationType(
|
||||
NotificationChannels.WAYPOINTS,
|
||||
Res.string.meshtastic_waypoints_notifications,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
)
|
||||
|
||||
object Alert :
|
||||
NotificationType(
|
||||
NotificationChannels.ALERTS,
|
||||
Res.string.meshtastic_alerts_notifications,
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
)
|
||||
|
||||
object NewNode :
|
||||
NotificationType(
|
||||
NotificationChannels.NEW_NODES,
|
||||
Res.string.meshtastic_new_nodes_notifications,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
)
|
||||
|
||||
object LowBatteryLocal :
|
||||
NotificationType(
|
||||
NotificationChannels.LOW_BATTERY,
|
||||
Res.string.meshtastic_low_battery_notifications,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
)
|
||||
|
||||
object LowBatteryRemote :
|
||||
NotificationType(
|
||||
NotificationChannels.LOW_BATTERY_REMOTE,
|
||||
Res.string.meshtastic_low_battery_temporary_remote_notifications,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
)
|
||||
|
||||
object Client :
|
||||
NotificationType(
|
||||
NotificationChannels.CLIENT,
|
||||
Res.string.client_notification,
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
)
|
||||
|
||||
companion object {
|
||||
// A list of all types for easy initialization.
|
||||
fun allTypes() = listOf(
|
||||
ServiceState,
|
||||
DirectMessage,
|
||||
BroadcastMessage,
|
||||
Waypoint,
|
||||
Alert,
|
||||
NewNode,
|
||||
LowBatteryLocal,
|
||||
LowBatteryRemote,
|
||||
Client,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearNotifications() {
|
||||
notificationManager.cancelAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates all necessary notification channels on devices running Android O or newer. This should be called once
|
||||
* when the service is created.
|
||||
*
|
||||
* Deliberately blocking (Main-thread, one-time cost): the orchestrator posts the foreground-service notification
|
||||
* synchronously right after this returns, so channels must exist before then — do not lazy-gate this into the
|
||||
* suspend notify paths. Blocking [getString] is safe here only because Main is not a Dispatchers.Default worker.
|
||||
* Guarantees the foreground-service channel synchronously, because the orchestrator posts the service notification
|
||||
* right after this returns, then creates the rest off the calling thread. Every post awaits [ensureChannels].
|
||||
*/
|
||||
override fun initChannels() {
|
||||
notificationManager.removeLegacyCategoryChannels()
|
||||
NotificationType.allTypes().forEach { type -> createNotificationChannel(type) }
|
||||
ensureServiceChannel()
|
||||
scope.launch { ensureChannels() }
|
||||
}
|
||||
|
||||
private fun createNotificationChannel(type: NotificationType) {
|
||||
if (notificationManager.getNotificationChannel(type.channelId) != null) return
|
||||
/** Creates the service channel under the app label if it is missing; [ensureChannels] later gives it its name. */
|
||||
private fun ensureServiceChannel() {
|
||||
if (notificationManager.getNotificationChannel(NotificationChannelSpec.Service.id) != null) return
|
||||
// The platform throws for a channel whose group does not exist yet, and this runs before ensureChannels.
|
||||
notificationManager.createNotificationChannelGroup(
|
||||
NotificationChannelSpec.Service.group.toGroup(applicationLabel),
|
||||
)
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannelSpec.Service.toChannel(context, name = applicationLabel, description = ""),
|
||||
)
|
||||
}
|
||||
|
||||
val channelName = getString(type.channelNameRes)
|
||||
val channel =
|
||||
NotificationChannel(type.channelId, channelName, type.importance).apply {
|
||||
lightColor = NOTIFICATION_COLOR
|
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC // Default, can be overridden
|
||||
private val channelsMutex = Mutex()
|
||||
private var channelsReady = false
|
||||
|
||||
// Type-specific configurations
|
||||
when (type) {
|
||||
NotificationType.ServiceState -> {
|
||||
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
}
|
||||
private class ChannelLabels(
|
||||
val groups: Map<NotificationChannelGroupSpec, String>,
|
||||
val names: Map<NotificationChannelSpec, String>,
|
||||
val descriptions: Map<NotificationChannelSpec, String>,
|
||||
)
|
||||
|
||||
NotificationType.DirectMessage,
|
||||
NotificationType.BroadcastMessage,
|
||||
NotificationType.Waypoint,
|
||||
NotificationType.NewNode,
|
||||
NotificationType.LowBatteryLocal,
|
||||
NotificationType.LowBatteryRemote,
|
||||
-> {
|
||||
setShowBadge(true)
|
||||
setSound(
|
||||
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION),
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build(),
|
||||
)
|
||||
if (type == NotificationType.LowBatteryRemote) enableVibration(true)
|
||||
}
|
||||
private suspend fun resolveChannelLabels() = ChannelLabels(
|
||||
groups = NotificationChannelGroupSpec.entries.associateWith { getStringSuspend(it.nameRes) },
|
||||
names = NotificationChannelSpec.entries.associateWith { getStringSuspend(it.nameRes) },
|
||||
descriptions = NotificationChannelSpec.entries.associateWith { getStringSuspend(it.descriptionRes) },
|
||||
)
|
||||
|
||||
NotificationType.Alert -> {
|
||||
setShowBadge(true)
|
||||
enableLights(true)
|
||||
enableVibration(true)
|
||||
setBypassDnd(true)
|
||||
val alertSoundUri =
|
||||
"${SCHEME_ANDROID_RESOURCE}://${context.packageName}/${raw.meshtastic_alert}".toUri()
|
||||
setSound(
|
||||
alertSoundUri,
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ALARM) // More appropriate for an alert
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
NotificationType.Client -> {
|
||||
setShowBadge(true)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Creates every channel group and channel, once per process. Re-creating an existing channel only refreshes its
|
||||
* name, description and (if it had none) group, so this also carries a locale change and the grouping onto installs
|
||||
* whose channels predate them.
|
||||
*
|
||||
* Label loading is bounded: a boot broadcast posts through here with seconds to live, and a channel under the app
|
||||
* label beats no notification. Such a pass does not count as done, so the next post puts the real labels on.
|
||||
*/
|
||||
internal suspend fun ensureChannels() = channelsMutex.withLock {
|
||||
if (channelsReady) return@withLock
|
||||
val labels =
|
||||
withTimeoutOrNull(CHANNEL_LABEL_TIMEOUT) { safeCatching { resolveChannelLabels() }.getOrNull() }
|
||||
val groups =
|
||||
NotificationChannelGroupSpec.entries.map { it.toGroup(labels?.groups?.get(it) ?: applicationLabel) }
|
||||
notificationManager.createNotificationChannelGroups(groups)
|
||||
val channels =
|
||||
NotificationChannelSpec.entries.map { spec ->
|
||||
spec.toChannel(
|
||||
context,
|
||||
name = labels?.names?.get(spec) ?: applicationLabel,
|
||||
description = labels?.descriptions?.get(spec).orEmpty(),
|
||||
)
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
notificationManager.createNotificationChannels(channels)
|
||||
channelsReady = labels != null
|
||||
}
|
||||
|
||||
/** Whether a post on [spec] would reach the user: app notifications on, and the channel not blocked. */
|
||||
private fun canPost(spec: NotificationChannelSpec): Boolean =
|
||||
NotificationManagerCompat.from(context).areNotificationsEnabled() &&
|
||||
notificationManager.getNotificationChannel(spec.id)?.importance != NotificationManager.IMPORTANCE_NONE
|
||||
|
||||
private val serviceNotificationLock = Any()
|
||||
private val lowBatteryLock = Any()
|
||||
|
||||
/** Per node, how many times its low-battery warning was cancelled; guarded by [lowBatteryLock]. */
|
||||
private val lowBatteryCancellations = mutableMapOf<Int, Int>()
|
||||
private val applicationLabel: String by lazy {
|
||||
context.applicationInfo.loadLabel(context.packageManager).toString().ifBlank { context.packageName }
|
||||
}
|
||||
@@ -386,6 +308,14 @@ class MeshNotificationManagerImpl(
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
firmwareUpdateStatusRepository.progress.collect { progress ->
|
||||
synchronized(serviceNotificationLock) {
|
||||
val last = serviceNotificationSnapshots.replayCache.lastOrNull() ?: return@synchronized
|
||||
serviceNotificationSnapshots.tryEmit(last.copy(firmwareProgress = progress))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -394,6 +324,7 @@ class MeshNotificationManagerImpl(
|
||||
* Multiplatform resource loading.
|
||||
*/
|
||||
fun getServiceNotification(): Notification {
|
||||
ensureServiceChannel()
|
||||
val cached = synchronized(serviceNotificationLock) { cachedServiceNotification }
|
||||
return cached ?: createServiceStateNotification(name = applicationLabel, message = null, nextUpdateAt = 0)
|
||||
}
|
||||
@@ -468,10 +399,20 @@ class MeshNotificationManagerImpl(
|
||||
previousMessage = cachedMessage,
|
||||
nextUpdateAt = nextStatsUpdateMillis,
|
||||
activeOperations = radioOperationLock.activeOperations,
|
||||
firmwareProgress = firmwareUpdateStatusRepository.progress.value,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun renderServiceNotification(snapshot: ServiceNotificationSnapshot): RenderedServiceNotification {
|
||||
snapshot.firmwareProgress?.let { progress ->
|
||||
val notification =
|
||||
createFirmwareProgressNotification(
|
||||
title = getStringSuspend(Res.string.firmware_update_in_progress),
|
||||
text = progress.message.resolve(),
|
||||
percent = progress.percent,
|
||||
)
|
||||
return RenderedServiceNotification(notification, message = snapshot.previousMessage)
|
||||
}
|
||||
// A held operation outranks the connection state. During a firmware update the device is deliberately
|
||||
// deselected, so the state alone would report "Disconnected" over a flash that is running perfectly.
|
||||
val title =
|
||||
@@ -532,6 +473,7 @@ class MeshNotificationManagerImpl(
|
||||
waypointId: Int,
|
||||
isSilent: Boolean,
|
||||
) {
|
||||
ensureChannels()
|
||||
val notification = createWaypointNotification(name, message, waypointId, isSilent)
|
||||
notificationManager.notify(TAG_WAYPOINT, contactKey.hashCode(), notification)
|
||||
}
|
||||
@@ -543,6 +485,7 @@ class MeshNotificationManagerImpl(
|
||||
conversationName: String,
|
||||
isSilent: Boolean = false,
|
||||
) {
|
||||
ensureChannels()
|
||||
// Publish (or refresh) a long-lived conversation shortcut before the notification references it, so Android can
|
||||
// rank the notification in the shade's Conversations section and expose it to Android Auto/Wear. A channel name
|
||||
// labels a broadcast conversation; a direct message is labelled by the other participant's name.
|
||||
@@ -645,7 +588,7 @@ class MeshNotificationManagerImpl(
|
||||
}
|
||||
|
||||
val summaryNotification =
|
||||
commonBuilder(NotificationType.DirectMessage)
|
||||
commonBuilder(NotificationChannelSpec.DirectMessages)
|
||||
.setSmallIcon(drawable.meshtastic_ic_notification)
|
||||
.setStyle(messagingStyle)
|
||||
.setGroup(GROUP_KEY_MESSAGES)
|
||||
@@ -659,26 +602,125 @@ class MeshNotificationManagerImpl(
|
||||
notificationManager.notify(TAG_MESSAGE_SUMMARY, SUMMARY_ID, summaryNotification)
|
||||
}
|
||||
|
||||
override fun showAlertNotification(contactKey: String, name: String, alert: String) {
|
||||
val notification = createAlertNotification(contactKey, name, alert)
|
||||
// Use a consistent, unique ID for each alert source.
|
||||
notificationManager.notify(TAG_ALERT, name.hashCode(), notification)
|
||||
override suspend fun showAlertNotification(contactKey: String, name: String, alert: String) {
|
||||
ensureChannels()
|
||||
notificationManager.notify(TAG_ALERT, contactKey.hashCode(), createAlertNotification(contactKey, name, alert))
|
||||
}
|
||||
|
||||
override fun showNewNodeSeenNotification(node: Node) {
|
||||
val notification = createNewNodeSeenNotification(node.user.short_name, node.user.long_name, node.num)
|
||||
notificationManager.notify(TAG_NEW_NODE, node.num, notification)
|
||||
}
|
||||
|
||||
override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {
|
||||
val notification = createLowBatteryNotification(node, isRemote)
|
||||
notificationManager.notify(TAG_LOW_BATTERY, node.num, notification)
|
||||
}
|
||||
|
||||
override fun showClientNotification(clientNotification: ClientNotification) {
|
||||
override suspend fun showMeshBeaconNotification(offer: MeshBeaconOffer) {
|
||||
ensureChannels()
|
||||
val title = getStringSuspend(Res.string.mesh_beacon_notification_title)
|
||||
val message = offer.message.ifBlank { getStringSuspend(Res.string.mesh_beacon_notification_body) }
|
||||
val notification =
|
||||
createClientNotification(getString(Res.string.client_notification), clientNotification.message)
|
||||
notificationManager.notify(TAG_CLIENT, clientNotification.toString().hashCode(), notification)
|
||||
commonBuilder(NotificationChannelSpec.MeshBeacon, createDeepLinkIntent("discovery", offer.fromNodeNum))
|
||||
.setCategory(Notification.CATEGORY_RECOMMENDATION)
|
||||
.setAutoCancel(true)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
.build()
|
||||
notificationManager.notify(TAG_MESH_BEACON, offer.fromNodeNum, notification)
|
||||
}
|
||||
|
||||
override suspend fun showNewNodeSeenNotification(node: Node, title: String) {
|
||||
ensureChannels()
|
||||
notificationManager.notify(TAG_NEW_NODE, node.num, createNewNodeSeenNotification(title, node))
|
||||
}
|
||||
|
||||
override fun cancelNewNodeNotification(nodeNum: Int) = notificationManager.cancel(TAG_NEW_NODE, nodeNum)
|
||||
|
||||
override suspend fun showLowBatteryNotification(node: Node, isRemote: Boolean) =
|
||||
postLowBattery(node, isRemote, onlyIfShowing = false)
|
||||
|
||||
override suspend fun updateLowBatteryNotification(node: Node, isRemote: Boolean) =
|
||||
postLowBattery(node, isRemote, onlyIfShowing = true)
|
||||
|
||||
/**
|
||||
* Recovery cancels from separate work, so a post that was already resolving its text when the battery recovered
|
||||
* must not land afterwards: it notes the node's cancellation count before building and posts only if it is
|
||||
* unchanged.
|
||||
*/
|
||||
private suspend fun postLowBattery(node: Node, isRemote: Boolean, onlyIfShowing: Boolean) {
|
||||
val cancellationsBefore = synchronized(lowBatteryLock) { lowBatteryCancellations[node.num] ?: 0 }
|
||||
ensureChannels()
|
||||
val notification = createLowBatteryNotification(node, isRemote)
|
||||
beforeLowBatteryPost?.invoke()
|
||||
synchronized(lowBatteryLock) {
|
||||
if ((lowBatteryCancellations[node.num] ?: 0) != cancellationsBefore) return
|
||||
val showing = notificationManager.activeNotifications.any { it.tag == TAG_LOW_BATTERY && it.id == node.num }
|
||||
if (!onlyIfShowing || showing) notificationManager.notify(TAG_LOW_BATTERY, node.num, notification)
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam run between building a low-battery notification and posting it, to race a recovery in. */
|
||||
internal var beforeLowBatteryPost: (() -> Unit)? = null
|
||||
|
||||
override suspend fun showClientNotification(
|
||||
clientNotification: ClientNotification,
|
||||
title: String,
|
||||
severity: MeshNotification.Type,
|
||||
) {
|
||||
ensureChannels()
|
||||
val message = clientNotification.message
|
||||
val warning = severity == MeshNotification.Type.Warning || severity == MeshNotification.Type.Error
|
||||
val notification =
|
||||
commonBuilder(NotificationChannelSpec.Client)
|
||||
.setCategory(if (warning) Notification.CATEGORY_ERROR else Notification.CATEGORY_STATUS)
|
||||
.setAutoCancel(true)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
// The firmware repeats this advisory on every position request; the tray slot is stable across
|
||||
// repeats (see notificationId), so only the first one should sound.
|
||||
.setOnlyAlertOnce(clientNotification.isProtectedPositionAdvisory())
|
||||
.build()
|
||||
notificationManager.notify(TAG_CLIENT, clientNotification.notificationId(), notification)
|
||||
}
|
||||
|
||||
override fun clearClientNotification(clientNotification: ClientNotification) =
|
||||
notificationManager.cancel(TAG_CLIENT, clientNotification.notificationId())
|
||||
|
||||
override fun suppressClientNotificationModal(clientNotification: ClientNotification): Boolean =
|
||||
clientNotification.isProtectedPositionAdvisory()
|
||||
|
||||
override suspend fun showFirmwareUpdateNotification(notice: FirmwareUpdateNotice): Boolean {
|
||||
ensureChannels()
|
||||
if (!canPost(NotificationChannelSpec.DeviceStatus)) return false
|
||||
val message =
|
||||
getStringSuspend(
|
||||
Res.string.firmware_update_notification_android,
|
||||
notice.currentVersion,
|
||||
notice.stableVersion,
|
||||
)
|
||||
val id = notice.notificationKey.hashCode()
|
||||
val notification =
|
||||
commonBuilder(NotificationChannelSpec.DeviceStatus, createDeepLinkIntent("firmware/update", id))
|
||||
.setCategory(Notification.CATEGORY_RECOMMENDATION)
|
||||
.setAutoCancel(true)
|
||||
.setContentTitle(getStringSuspend(Res.string.firmware_update_available))
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
.build()
|
||||
notificationManager.notify(TAG_FIRMWARE_UPDATE, id, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun showReconnectBlockedNotification(title: String, message: String): Boolean {
|
||||
ensureChannels()
|
||||
if (!canPost(NotificationChannelSpec.DeviceStatus)) return false
|
||||
val notification =
|
||||
commonBuilder(
|
||||
NotificationChannelSpec.DeviceStatus,
|
||||
createDeepLinkIntent("connections", RECONNECT_BLOCKED_ID),
|
||||
)
|
||||
.setCategory(Notification.CATEGORY_ERROR)
|
||||
.setAutoCancel(true)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
.build()
|
||||
notificationManager.notify(TAG_RECONNECT_BLOCKED, RECONNECT_BLOCKED_ID, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun cancelMessageNotification(contactKey: String) {
|
||||
@@ -716,18 +758,17 @@ class MeshNotificationManagerImpl(
|
||||
showConversationNotification(contactKey, isBroadcast, channelName, conversationName, isSilent = true)
|
||||
}
|
||||
|
||||
override fun cancelLowBatteryNotification(node: Node) = notificationManager.cancel(TAG_LOW_BATTERY, node.num)
|
||||
|
||||
override fun clearClientNotification(notification: ClientNotification) =
|
||||
notificationManager.cancel(TAG_CLIENT, notification.toString().hashCode())
|
||||
override fun cancelLowBatteryNotification(node: Node) = synchronized(lowBatteryLock) {
|
||||
lowBatteryCancellations[node.num] = (lowBatteryCancellations[node.num] ?: 0) + 1
|
||||
notificationManager.cancel(TAG_LOW_BATTERY, node.num)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Notification Creation
|
||||
private fun createServiceStateNotification(name: String, message: String?, nextUpdateAt: Long?): Notification {
|
||||
val builder =
|
||||
commonBuilder(NotificationType.ServiceState)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
commonBuilder(NotificationChannelSpec.Service)
|
||||
.setCategory(Notification.CATEGORY_SERVICE)
|
||||
.setOngoing(true)
|
||||
// Android 12+ may defer FGS notifications ~10s; show immediately so the user watching a
|
||||
@@ -753,6 +794,27 @@ class MeshNotificationManagerImpl(
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* A flash is a start-to-end journey the user may background, so the service notification asks to be promoted to a
|
||||
* Live Update while one runs. The platform grants it only on a channel above MIN and with
|
||||
* POST_PROMOTED_NOTIFICATIONS; elsewhere this is an ordinary ongoing progress notification.
|
||||
*/
|
||||
private fun createFirmwareProgressNotification(title: String, text: String, percent: Int?): Notification {
|
||||
val style = NotificationCompat.ProgressStyle().setProgressIndeterminate(percent == null)
|
||||
percent?.let { style.setProgress(it) }
|
||||
return commonBuilder(NotificationChannelSpec.Service)
|
||||
.setCategory(Notification.CATEGORY_PROGRESS)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setStyle(style)
|
||||
.setRequestPromotedOngoing(true)
|
||||
.apply { percent?.let { setShortCriticalText(MetricFormatter.percent(it)) } }
|
||||
.build()
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private suspend fun createConversationNotification(
|
||||
contactKey: String,
|
||||
@@ -761,7 +823,7 @@ class MeshNotificationManagerImpl(
|
||||
history: List<Message>,
|
||||
isSilent: Boolean = false,
|
||||
): Notification {
|
||||
val type = if (isBroadcast) NotificationType.BroadcastMessage else NotificationType.DirectMessage
|
||||
val type = if (isBroadcast) NotificationChannelSpec.Broadcasts else NotificationChannelSpec.DirectMessages
|
||||
val builder = commonBuilder(type, createOpenMessageIntent(contactKey))
|
||||
|
||||
if (isSilent) {
|
||||
@@ -813,7 +875,7 @@ class MeshNotificationManagerImpl(
|
||||
|
||||
val text =
|
||||
msg.originalMessage?.let { original ->
|
||||
"↩️ \"${original.node.user.short_name}: ${original.text.take(SNIPPET_LENGTH)}...\": ${msg.text}"
|
||||
"↩️ \"${original.node.user.short_name}: ${snippet(original.text)}\": ${msg.text}"
|
||||
} ?: msg.text
|
||||
|
||||
if (msg.read && (anyUnread || index != lastIndex)) {
|
||||
@@ -839,7 +901,7 @@ class MeshNotificationManagerImpl(
|
||||
)
|
||||
.build()
|
||||
style.addMessage(
|
||||
"${reaction.emoji} to \"${msg.text.take(SNIPPET_LENGTH)}...\"",
|
||||
getStringSuspend(Res.string.notification_reaction_to, reaction.emoji, snippet(msg.text)),
|
||||
reaction.timestamp,
|
||||
reactor,
|
||||
)
|
||||
@@ -901,11 +963,13 @@ class MeshNotificationManagerImpl(
|
||||
val style = NotificationCompat.MessagingStyle(person).addMessage(message, nowMillis, person)
|
||||
|
||||
val builder =
|
||||
commonBuilder(NotificationType.Waypoint, createOpenWaypointIntent(waypointId))
|
||||
commonBuilder(
|
||||
NotificationChannelSpec.Waypoints,
|
||||
createDeepLinkIntent("map?waypointId=$waypointId", waypointId),
|
||||
)
|
||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||
.setAutoCancel(true)
|
||||
.setStyle(style)
|
||||
.setGroup(GROUP_KEY_MESSAGES)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setWhen(nowMillis)
|
||||
.setShowWhen(true)
|
||||
@@ -921,38 +985,36 @@ class MeshNotificationManagerImpl(
|
||||
val person = Person.Builder().setName(name).build()
|
||||
val style = NotificationCompat.MessagingStyle(person).addMessage(alert, nowMillis, person)
|
||||
|
||||
return commonBuilder(NotificationType.Alert, createOpenMessageIntent(contactKey))
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
return commonBuilder(NotificationChannelSpec.Alerts, createOpenMessageIntent(contactKey))
|
||||
.setCategory(Notification.CATEGORY_ALARM)
|
||||
.setAutoCancel(true)
|
||||
.setStyle(style)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createNewNodeSeenNotification(name: String, message: String, nodeNum: Int): Notification {
|
||||
val title = getString(Res.string.new_node_seen, name)
|
||||
val builder =
|
||||
commonBuilder(NotificationType.NewNode, createOpenNodeDetailIntent(nodeNum))
|
||||
.setCategory(Notification.CATEGORY_STATUS)
|
||||
.setAutoCancel(true)
|
||||
.setContentTitle(title)
|
||||
.setWhen(nowMillis)
|
||||
.setShowWhen(true)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
|
||||
return builder.build()
|
||||
private fun createNewNodeSeenNotification(title: String, node: Node): Notification {
|
||||
val message = node.user.long_name
|
||||
return commonBuilder(NotificationChannelSpec.NewNodes, createOpenNodeDetailIntent(node.num))
|
||||
.setCategory(Notification.CATEGORY_STATUS)
|
||||
.setAutoCancel(true)
|
||||
.setContentTitle(title)
|
||||
.setWhen(nowMillis)
|
||||
.setShowWhen(true)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createLowBatteryNotification(node: Node, isRemote: Boolean): Notification {
|
||||
val type = if (isRemote) NotificationType.LowBatteryRemote else NotificationType.LowBatteryLocal
|
||||
val title = getString(Res.string.low_battery_title, node.user.short_name)
|
||||
private suspend fun createLowBatteryNotification(node: Node, isRemote: Boolean): Notification {
|
||||
val type = if (isRemote) NotificationChannelSpec.LowBatteryRemote else NotificationChannelSpec.LowBattery
|
||||
val title = getStringSuspend(Res.string.low_battery_title, node.user.short_name)
|
||||
val batteryLevel = node.deviceMetrics.battery_level ?: 0
|
||||
val message = getString(Res.string.low_battery_message, node.user.long_name, batteryLevel)
|
||||
val message = getStringSuspend(Res.string.low_battery_message, node.user.long_name, batteryLevel)
|
||||
|
||||
// Not ongoing: an ongoing notification never bridges to a watch, and recovery cancels this one anyway.
|
||||
return commonBuilder(type, createOpenNodeDetailIntent(node.num))
|
||||
.setCategory(Notification.CATEGORY_STATUS)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setProgress(MAX_BATTERY_LEVEL, batteryLevel, false)
|
||||
.setContentTitle(title)
|
||||
@@ -963,39 +1025,38 @@ class MeshNotificationManagerImpl(
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createClientNotification(name: String, message: String): Notification =
|
||||
commonBuilder(NotificationType.Client)
|
||||
.setCategory(Notification.CATEGORY_ERROR)
|
||||
.setAutoCancel(true)
|
||||
.setContentTitle(name)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
.build()
|
||||
|
||||
// endregion
|
||||
|
||||
// region Helper/Builder Methods
|
||||
private val openAppIntent: PendingIntent by lazy {
|
||||
val intent =
|
||||
Intent(context, Class.forName("org.meshtastic.app.MainActivity")).apply {
|
||||
Intent(context, Class.forName(MAIN_ACTIVITY_CLASS)).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
|
||||
private fun createOpenMessageIntent(contactKey: String): PendingIntent {
|
||||
val deepLinkUri = "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()
|
||||
/**
|
||||
* Opens `meshtastic://meshtastic/[path]` in MainActivity, whose deep-link router synthesizes the backstack. Each
|
||||
* link is its own PendingIntent because the URI takes part in intent equality; [requestCode] only has to be stable.
|
||||
*/
|
||||
private fun createDeepLinkIntent(path: String, requestCode: Int): PendingIntent {
|
||||
val deepLinkIntent =
|
||||
Intent(Intent.ACTION_VIEW, deepLinkUri, context, Class.forName("org.meshtastic.app.MainActivity")).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
|
||||
Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/$path".toUri(), context, Class.forName(MAIN_ACTIVITY_CLASS))
|
||||
.apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP }
|
||||
return TaskStackBuilder.create(context).run {
|
||||
addNextIntentWithParentStack(deepLinkIntent)
|
||||
getPendingIntent(contactKey.hashCode(), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
checkNotNull(
|
||||
getPendingIntent(requestCode, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createOpenMessageIntent(contactKey: String) =
|
||||
createDeepLinkIntent("messages/$contactKey", contactKey.hashCode())
|
||||
|
||||
private fun createOpenNodeDetailIntent(nodeNum: Int) = createDeepLinkIntent("nodes/$nodeNum", nodeNum)
|
||||
|
||||
/**
|
||||
* Bubble target: [org.meshtastic.app.BubbleActivity], not the launcher activity. A bubble's activity must be
|
||||
* resizeable, embeddable and document-launched, and making the launcher activity document-launched would change how
|
||||
@@ -1027,32 +1088,6 @@ class MeshNotificationManagerImpl(
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createOpenWaypointIntent(waypointId: Int): PendingIntent {
|
||||
val deepLinkUri = "$DEEP_LINK_BASE_URI/map?waypointId=$waypointId".toUri()
|
||||
val deepLinkIntent =
|
||||
Intent(Intent.ACTION_VIEW, deepLinkUri, context, Class.forName("org.meshtastic.app.MainActivity")).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
|
||||
return TaskStackBuilder.create(context).run {
|
||||
addNextIntentWithParentStack(deepLinkIntent)
|
||||
getPendingIntent(waypointId, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createOpenNodeDetailIntent(nodeNum: Int): PendingIntent {
|
||||
val deepLinkUri = "$DEEP_LINK_BASE_URI/nodes/$nodeNum".toUri()
|
||||
val deepLinkIntent =
|
||||
Intent(Intent.ACTION_VIEW, deepLinkUri, context, Class.forName("org.meshtastic.app.MainActivity")).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
|
||||
return TaskStackBuilder.create(context).run {
|
||||
addNextIntentWithParentStack(deepLinkIntent)
|
||||
getPendingIntent(nodeNum, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createReplyAction(contactKey: String): NotificationCompat.Action {
|
||||
val replyLabel = getStringSuspend(Res.string.reply)
|
||||
val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY).setLabel(replyLabel).build()
|
||||
@@ -1075,6 +1110,8 @@ class MeshNotificationManagerImpl(
|
||||
// Required for Android Auto to drive reply hands-free without opening any UI.
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||
.setShowsUserInterface(false)
|
||||
// Offers Smart Reply suggestions when the notification is bridged to a Wear OS watch.
|
||||
.setAllowGeneratedReplies(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -1124,16 +1161,22 @@ class MeshNotificationManagerImpl(
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_add, label, pendingIntent).build()
|
||||
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_add, label, pendingIntent)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_THUMBS_UP)
|
||||
.setShowsUserInterface(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun snippet(text: String): String =
|
||||
if (text.length > SNIPPET_LENGTH) text.take(SNIPPET_LENGTH).trimEnd() + "…" else text
|
||||
|
||||
private fun commonBuilder(
|
||||
type: NotificationType,
|
||||
type: NotificationChannelSpec,
|
||||
contentIntent: PendingIntent? = null,
|
||||
): NotificationCompat.Builder {
|
||||
val smallIcon = drawable.meshtastic_ic_notification
|
||||
|
||||
return NotificationCompat.Builder(context, type.channelId)
|
||||
return NotificationCompat.Builder(context, type.id)
|
||||
.setSmallIcon(smallIcon)
|
||||
.setColor(NOTIFICATION_COLOR)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationChannelGroup
|
||||
import android.app.NotificationManager
|
||||
import android.content.ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.RingtoneManager
|
||||
import androidx.core.net.toUri
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.core.resources.R.raw
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.device
|
||||
import org.meshtastic.core.resources.meshtastic_alerts_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_alerts_notifications_description
|
||||
import org.meshtastic.core.resources.meshtastic_broadcast_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_broadcast_notifications_description
|
||||
import org.meshtastic.core.resources.meshtastic_client_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_client_notifications_description
|
||||
import org.meshtastic.core.resources.meshtastic_device_status_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_device_status_notifications_description
|
||||
import org.meshtastic.core.resources.meshtastic_low_battery_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_low_battery_notifications_description
|
||||
import org.meshtastic.core.resources.meshtastic_low_battery_temporary_remote_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_low_battery_temporary_remote_notifications_description
|
||||
import org.meshtastic.core.resources.meshtastic_mesh_beacon_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_mesh_beacon_notifications_description
|
||||
import org.meshtastic.core.resources.meshtastic_messages_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_messages_notifications_description
|
||||
import org.meshtastic.core.resources.meshtastic_new_nodes_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_new_nodes_notifications_description
|
||||
import org.meshtastic.core.resources.meshtastic_service_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_service_notifications_description
|
||||
import org.meshtastic.core.resources.meshtastic_waypoints_notifications
|
||||
import org.meshtastic.core.resources.meshtastic_waypoints_notifications_description
|
||||
import org.meshtastic.core.resources.messages
|
||||
import org.meshtastic.core.resources.notification_group_mesh
|
||||
|
||||
/** Meshtastic brand accent (Green 500, see .skills/design-standards): the small-icon tint and the LED colour. */
|
||||
internal val NOTIFICATION_COLOR = 0xFF67EA94.toInt()
|
||||
|
||||
/**
|
||||
* Every notification channel the app owns. The platform lets an app change a channel's name, description and (once)
|
||||
* group after creation; importance, sound, vibration and lights are fixed the first time the channel is created, so an
|
||||
* importance here must never drop below what an earlier release created for the same id.
|
||||
*/
|
||||
internal enum class NotificationChannelSpec(
|
||||
val id: String,
|
||||
val nameRes: StringResource,
|
||||
val descriptionRes: StringResource,
|
||||
val group: NotificationChannelGroupSpec,
|
||||
val importance: Int,
|
||||
) {
|
||||
// A foreground-service channel below LOW is raised to LOW by the platform on first use anyway.
|
||||
Service(
|
||||
NotificationChannels.SERVICE,
|
||||
Res.string.meshtastic_service_notifications,
|
||||
Res.string.meshtastic_service_notifications_description,
|
||||
NotificationChannelGroupSpec.Device,
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
DirectMessages(
|
||||
NotificationChannels.MESSAGES,
|
||||
Res.string.meshtastic_messages_notifications,
|
||||
Res.string.meshtastic_messages_notifications_description,
|
||||
NotificationChannelGroupSpec.Messages,
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
),
|
||||
Broadcasts(
|
||||
NotificationChannels.BROADCASTS,
|
||||
Res.string.meshtastic_broadcast_notifications,
|
||||
Res.string.meshtastic_broadcast_notifications_description,
|
||||
NotificationChannelGroupSpec.Messages,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
),
|
||||
Waypoints(
|
||||
NotificationChannels.WAYPOINTS,
|
||||
Res.string.meshtastic_waypoints_notifications,
|
||||
Res.string.meshtastic_waypoints_notifications_description,
|
||||
NotificationChannelGroupSpec.Messages,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
),
|
||||
Alerts(
|
||||
NotificationChannels.ALERTS,
|
||||
Res.string.meshtastic_alerts_notifications,
|
||||
Res.string.meshtastic_alerts_notifications_description,
|
||||
NotificationChannelGroupSpec.Messages,
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
),
|
||||
NewNodes(
|
||||
NotificationChannels.NEW_NODES,
|
||||
Res.string.meshtastic_new_nodes_notifications,
|
||||
Res.string.meshtastic_new_nodes_notifications_description,
|
||||
NotificationChannelGroupSpec.Mesh,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
),
|
||||
MeshBeacon(
|
||||
NotificationChannels.MESH_BEACON,
|
||||
Res.string.meshtastic_mesh_beacon_notifications,
|
||||
Res.string.meshtastic_mesh_beacon_notifications_description,
|
||||
NotificationChannelGroupSpec.Mesh,
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
LowBatteryRemote(
|
||||
NotificationChannels.LOW_BATTERY_REMOTE,
|
||||
Res.string.meshtastic_low_battery_temporary_remote_notifications,
|
||||
Res.string.meshtastic_low_battery_temporary_remote_notifications_description,
|
||||
NotificationChannelGroupSpec.Mesh,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
),
|
||||
LowBattery(
|
||||
NotificationChannels.LOW_BATTERY,
|
||||
Res.string.meshtastic_low_battery_notifications,
|
||||
Res.string.meshtastic_low_battery_notifications_description,
|
||||
NotificationChannelGroupSpec.Device,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
),
|
||||
Client(
|
||||
NotificationChannels.CLIENT,
|
||||
Res.string.meshtastic_client_notifications,
|
||||
Res.string.meshtastic_client_notifications_description,
|
||||
NotificationChannelGroupSpec.Device,
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
),
|
||||
DeviceStatus(
|
||||
NotificationChannels.DEVICE_STATUS,
|
||||
Res.string.meshtastic_device_status_notifications,
|
||||
Res.string.meshtastic_device_status_notifications_description,
|
||||
NotificationChannelGroupSpec.Device,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
),
|
||||
;
|
||||
|
||||
fun toChannel(context: Context, name: String, description: String): NotificationChannel =
|
||||
NotificationChannel(id, name, importance).also { channel ->
|
||||
channel.description = description
|
||||
channel.group = group.id
|
||||
channel.lightColor = NOTIFICATION_COLOR
|
||||
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
channel.setShowBadge(true)
|
||||
when (this) {
|
||||
Service -> {
|
||||
channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
channel.setShowBadge(false)
|
||||
}
|
||||
|
||||
Alerts -> {
|
||||
channel.enableLights(true)
|
||||
channel.enableVibration(true)
|
||||
val alertSound = "$SCHEME_ANDROID_RESOURCE://${context.packageName}/${raw.meshtastic_alert}".toUri()
|
||||
channel.setSound(alertSound, soundAttributes(AudioAttributes.USAGE_ALARM))
|
||||
}
|
||||
|
||||
LowBatteryRemote -> {
|
||||
channel.enableVibration(true)
|
||||
channel.setSound(defaultSound, soundAttributes(AudioAttributes.USAGE_NOTIFICATION))
|
||||
}
|
||||
|
||||
DirectMessages,
|
||||
Broadcasts,
|
||||
Waypoints,
|
||||
NewNodes,
|
||||
LowBattery,
|
||||
DeviceStatus,
|
||||
-> channel.setSound(defaultSound, soundAttributes(AudioAttributes.USAGE_NOTIFICATION))
|
||||
|
||||
MeshBeacon,
|
||||
Client,
|
||||
-> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val defaultSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||
|
||||
fun soundAttributes(usage: Int): AudioAttributes =
|
||||
AudioAttributes.Builder().setUsage(usage).setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build()
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class NotificationChannelGroupSpec(val id: String, val nameRes: StringResource) {
|
||||
Messages("group_messages", Res.string.messages),
|
||||
Mesh("group_mesh", Res.string.notification_group_mesh),
|
||||
Device("group_device", Res.string.device),
|
||||
;
|
||||
|
||||
fun toGroup(name: String): NotificationChannelGroup = NotificationChannelGroup(id, name)
|
||||
}
|
||||
+1
@@ -27,6 +27,7 @@ object NotificationChannels {
|
||||
const val LOW_BATTERY = "low_battery"
|
||||
const val LOW_BATTERY_REMOTE = "low_battery_remote"
|
||||
const val CLIENT = "client_notifications"
|
||||
const val DEVICE_STATUS = "device_status"
|
||||
|
||||
// Legacy enum-name channel IDs introduced by alpha channel routing.
|
||||
val LEGACY_CATEGORY_IDS = listOf("Message", "NodeEvent", "Battery", "Alert", "Service")
|
||||
|
||||
@@ -25,7 +25,9 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.meshtastic.core.common.util.safeCatching
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.RadioController
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@@ -40,6 +42,7 @@ class ReactionReceiver :
|
||||
KoinComponent {
|
||||
|
||||
private val radioController: RadioController by inject()
|
||||
private val meshServiceNotifications: MeshNotificationManager by inject()
|
||||
|
||||
private val dispatchers: CoroutineDispatchers by inject()
|
||||
|
||||
@@ -53,16 +56,20 @@ class ReactionReceiver :
|
||||
val reaction = intent.getStringExtra(EXTRA_EMOJI) ?: intent.getStringExtra(EXTRA_REACTION) ?: return
|
||||
val replyId = intent.getIntExtra(EXTRA_REPLY_ID, intent.getIntExtra(EXTRA_PACKET_ID, 0))
|
||||
|
||||
val pendingResult = goAsync()
|
||||
val pendingResult: PendingResult? = goAsync()
|
||||
scope.launch {
|
||||
try {
|
||||
radioController.sendReaction(reaction, replyId, contactKey)
|
||||
// The action opens no UI, so re-posting the conversation with the reaction is the only feedback the
|
||||
// shade or a watch gets that the tap worked.
|
||||
safeCatching { meshServiceNotifications.refreshConversationAfterReply(contactKey) }
|
||||
.onFailure { Logger.e(it) { "Refresh after reaction failed" } }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "Error sending reaction" }
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
pendingResult?.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -27,12 +27,12 @@ import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.MeshMessageProcessor
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.MeshPrefs
|
||||
import org.meshtastic.core.repository.MessagingController
|
||||
import org.meshtastic.core.repository.NodeController
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.QueryController
|
||||
@@ -74,7 +74,7 @@ class CoreServiceAndroidModule {
|
||||
meshPrefs: MeshPrefs,
|
||||
uiPrefs: UiPrefs,
|
||||
databaseManager: DatabaseManager,
|
||||
notificationManager: NotificationManager,
|
||||
serviceNotifications: MeshNotificationManager,
|
||||
messageProcessor: Lazy<MeshMessageProcessor>,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
scope: ServiceScope,
|
||||
@@ -91,7 +91,7 @@ class CoreServiceAndroidModule {
|
||||
meshPrefs = meshPrefs,
|
||||
uiPrefs = uiPrefs,
|
||||
databaseManager = databaseManager,
|
||||
notificationManager = notificationManager,
|
||||
serviceNotifications = serviceNotifications,
|
||||
messageProcessor = messageProcessor,
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
scope = scope,
|
||||
|
||||
+5
-5
@@ -41,12 +41,12 @@ import org.meshtastic.core.repository.ConnectionIdentity
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.MeshMessageProcessor
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.MeshPrefs
|
||||
import org.meshtastic.core.repository.MessagingController
|
||||
import org.meshtastic.core.repository.NodeController
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.QueryController
|
||||
@@ -109,7 +109,7 @@ class RadioControllerImpl(
|
||||
private val meshPrefs: MeshPrefs,
|
||||
uiPrefs: UiPrefs,
|
||||
private val databaseManager: DatabaseManager,
|
||||
private val notificationManager: NotificationManager,
|
||||
private val serviceNotifications: MeshNotificationManager,
|
||||
private val messageProcessor: Lazy<MeshMessageProcessor>,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
scope: CoroutineScope,
|
||||
@@ -321,7 +321,7 @@ class RadioControllerImpl(
|
||||
messageProcessor.value.clearEarlyPackets()
|
||||
databaseManager.switchActiveDatabase(deviceAddr)
|
||||
nodeManager.clear()
|
||||
notificationManager.cancelAll()
|
||||
serviceNotifications.clearNotifications()
|
||||
nodeManager.loadCachedNodeDB()
|
||||
// Commit the persisted selection last. MeshPrefs writes asynchronously, so the transport's synchronous
|
||||
// selected-address snapshot remains the rollback authority for a rapid subsequent selection.
|
||||
@@ -349,7 +349,7 @@ class RadioControllerImpl(
|
||||
attemptRollback("fail-closed connection-identity clear") { nodeManager.clearConnectionIdentity() }
|
||||
attemptRollback("fail-closed node-state clear") { nodeManager.clear() }
|
||||
attemptRollback("fail-closed early-packet clear") { messageProcessor.value.clearEarlyPackets() }
|
||||
attemptRollback("fail-closed notification clear") { notificationManager.cancelAll() }
|
||||
attemptRollback("fail-closed notification clear") { serviceNotifications.clearNotifications() }
|
||||
attemptRollback("fail-closed persisted selection") { meshPrefs.setDeviceAddress(null) }
|
||||
attemptRollback("fail-closed transport selection") {
|
||||
check(radioInterfaceService.setDeviceAddress(null)) { "Transport rejected fail-closed deselection" }
|
||||
@@ -362,7 +362,7 @@ class RadioControllerImpl(
|
||||
nodeManager.clearConnectionIdentity()
|
||||
nodeManager.clear()
|
||||
messageProcessor.value.clearEarlyPackets()
|
||||
notificationManager.cancelAll()
|
||||
serviceNotifications.clearNotifications()
|
||||
nodeManager.loadCachedNodeDB()
|
||||
}
|
||||
attemptRollback("transport selection") {
|
||||
|
||||
+3
-3
@@ -58,10 +58,10 @@ import org.meshtastic.core.repository.LocalNodeUnavailableException
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.MeshMessageProcessor
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.MeshPrefs
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketQueueRejectedException
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
@@ -104,7 +104,7 @@ class RadioControllerImplTest {
|
||||
private val meshPrefs: MeshPrefs = mock(MockMode.autofill)
|
||||
private val uiPrefs: UiPrefs = mock(MockMode.autofill)
|
||||
private val databaseManager: DatabaseManager = mock(MockMode.autofill)
|
||||
private val notificationManager: NotificationManager = mock(MockMode.autofill)
|
||||
private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill)
|
||||
private val messageProcessor: MeshMessageProcessor = mock(MockMode.autofill)
|
||||
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
|
||||
|
||||
@@ -173,7 +173,7 @@ class RadioControllerImplTest {
|
||||
meshPrefs = meshPrefs,
|
||||
uiPrefs = uiPrefs,
|
||||
databaseManager = databaseManager,
|
||||
notificationManager = notificationManager,
|
||||
serviceNotifications = serviceNotifications,
|
||||
messageProcessor = lazy { messageProcessor },
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
scope = scope,
|
||||
|
||||
+3
-3
@@ -38,10 +38,10 @@ import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.MeshMessageProcessor
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.MeshPrefs
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
@@ -71,7 +71,7 @@ class RadioControllerRestoreTest {
|
||||
private val dataHandler: MeshDataHandler = mock(MockMode.autofill)
|
||||
private val analytics: PlatformAnalytics = mock(MockMode.autofill)
|
||||
private val uiPrefs: UiPrefs = mock(MockMode.autofill)
|
||||
private val notificationManager: NotificationManager = mock(MockMode.autofill)
|
||||
private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill)
|
||||
private val messageProcessor: MeshMessageProcessor = mock(MockMode.autofill)
|
||||
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
|
||||
|
||||
@@ -99,7 +99,7 @@ class RadioControllerRestoreTest {
|
||||
meshPrefs = meshPrefs,
|
||||
uiPrefs = uiPrefs,
|
||||
databaseManager = databaseManager,
|
||||
notificationManager = notificationManager,
|
||||
serviceNotifications = serviceNotifications,
|
||||
messageProcessor = lazy { messageProcessor },
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
scope = scope,
|
||||
|
||||
+74
-15
@@ -17,19 +17,42 @@
|
||||
package org.meshtastic.core.testing
|
||||
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.FirmwareUpdateNotice
|
||||
import org.meshtastic.core.model.MeshBeaconOffer
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.Telemetry
|
||||
|
||||
/** A test double for [MeshNotificationManager] that provides a no-op implementation. */
|
||||
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
|
||||
/** Records every notification posted or cancelled. The `accepts*` flags stand in for a platform that declines. */
|
||||
@Suppress("TooManyFunctions")
|
||||
class FakeMeshNotificationManager : MeshNotificationManager {
|
||||
override fun clearNotifications() {}
|
||||
data class ClientPost(val notification: ClientNotification, val title: String, val severity: Notification.Type)
|
||||
|
||||
override fun initChannels() {}
|
||||
var acceptsFirmwareUpdate = true
|
||||
var acceptsReconnectBlocked = true
|
||||
|
||||
override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) {}
|
||||
val meshBeacons = mutableListOf<MeshBeaconOffer>()
|
||||
val newNodes = mutableListOf<Node>()
|
||||
val cancelledNewNodes = mutableListOf<Int>()
|
||||
val lowBatteryShown = mutableListOf<Node>()
|
||||
val lowBatteryUpdated = mutableListOf<Node>()
|
||||
val lowBatteryCancelled = mutableListOf<Node>()
|
||||
val clientPosts = mutableListOf<ClientPost>()
|
||||
val clearedClientNotifications = mutableListOf<ClientNotification>()
|
||||
val firmwareUpdateNotices = mutableListOf<FirmwareUpdateNotice>()
|
||||
val reconnectBlocked = mutableListOf<Pair<String, String>>()
|
||||
var clearCount = 0
|
||||
private set
|
||||
|
||||
override fun clearNotifications() {
|
||||
clearCount++
|
||||
}
|
||||
|
||||
override fun initChannels() = Unit
|
||||
|
||||
override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) = Unit
|
||||
|
||||
override suspend fun updateMessageNotification(
|
||||
contactKey: String,
|
||||
@@ -38,7 +61,7 @@ class FakeMeshNotificationManager : MeshNotificationManager {
|
||||
isBroadcast: Boolean,
|
||||
channelName: String?,
|
||||
isSilent: Boolean,
|
||||
) {}
|
||||
) = Unit
|
||||
|
||||
override suspend fun updateWaypointNotification(
|
||||
contactKey: String,
|
||||
@@ -46,7 +69,7 @@ class FakeMeshNotificationManager : MeshNotificationManager {
|
||||
message: String,
|
||||
waypointId: Int,
|
||||
isSilent: Boolean,
|
||||
) {}
|
||||
) = Unit
|
||||
|
||||
override suspend fun updateReactionNotification(
|
||||
contactKey: String,
|
||||
@@ -55,19 +78,55 @@ class FakeMeshNotificationManager : MeshNotificationManager {
|
||||
isBroadcast: Boolean,
|
||||
channelName: String?,
|
||||
isSilent: Boolean,
|
||||
) {}
|
||||
) = Unit
|
||||
|
||||
override fun showAlertNotification(contactKey: String, name: String, alert: String) {}
|
||||
override suspend fun showAlertNotification(contactKey: String, name: String, alert: String) = Unit
|
||||
|
||||
override fun showNewNodeSeenNotification(node: Node) {}
|
||||
override suspend fun showMeshBeaconNotification(offer: MeshBeaconOffer) {
|
||||
meshBeacons += offer
|
||||
}
|
||||
|
||||
override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {}
|
||||
override suspend fun showNewNodeSeenNotification(node: Node, title: String) {
|
||||
newNodes += node
|
||||
}
|
||||
|
||||
override fun showClientNotification(clientNotification: ClientNotification) {}
|
||||
override fun cancelNewNodeNotification(nodeNum: Int) {
|
||||
cancelledNewNodes += nodeNum
|
||||
}
|
||||
|
||||
override suspend fun cancelMessageNotification(contactKey: String) {}
|
||||
override suspend fun showLowBatteryNotification(node: Node, isRemote: Boolean) {
|
||||
lowBatteryShown += node
|
||||
}
|
||||
|
||||
override fun cancelLowBatteryNotification(node: Node) {}
|
||||
override suspend fun updateLowBatteryNotification(node: Node, isRemote: Boolean) {
|
||||
lowBatteryUpdated += node
|
||||
}
|
||||
|
||||
override fun clearClientNotification(notification: ClientNotification) {}
|
||||
override fun cancelLowBatteryNotification(node: Node) {
|
||||
lowBatteryCancelled += node
|
||||
}
|
||||
|
||||
override suspend fun showClientNotification(
|
||||
clientNotification: ClientNotification,
|
||||
title: String,
|
||||
severity: Notification.Type,
|
||||
) {
|
||||
clientPosts += ClientPost(clientNotification, title, severity)
|
||||
}
|
||||
|
||||
override fun clearClientNotification(clientNotification: ClientNotification) {
|
||||
clearedClientNotifications += clientNotification
|
||||
}
|
||||
|
||||
override suspend fun showFirmwareUpdateNotification(notice: FirmwareUpdateNotice): Boolean {
|
||||
if (acceptsFirmwareUpdate) firmwareUpdateNotices += notice
|
||||
return acceptsFirmwareUpdate
|
||||
}
|
||||
|
||||
override suspend fun showReconnectBlockedNotification(title: String, message: String): Boolean {
|
||||
if (acceptsReconnectBlocked) reconnectBlocked += title to message
|
||||
return acceptsReconnectBlocked
|
||||
}
|
||||
|
||||
override suspend fun cancelMessageNotification(contactKey: String) = Unit
|
||||
}
|
||||
+3
-44
@@ -46,20 +46,14 @@ import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.util.TimeConstants
|
||||
import org.meshtastic.core.repository.DeviceHardwareRepository
|
||||
import org.meshtastic.core.repository.FirmwareReleaseRepository
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.NodeRestartTracker
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.firmware_update_available
|
||||
import org.meshtastic.core.resources.firmware_update_notification_android
|
||||
import org.meshtastic.core.resources.firmware_update_notification_flasher
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
|
||||
@@ -107,7 +101,7 @@ class ConnectionsViewModel(
|
||||
private val deviceHardwareRepository: DeviceHardwareRepository,
|
||||
private val firmwareReleaseRepository: FirmwareReleaseRepository,
|
||||
private val radioPrefs: RadioPrefs,
|
||||
private val notificationManager: NotificationManager,
|
||||
private val serviceNotifications: MeshNotificationManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val scheduledFirmwareUpdateNotificationKeys = mutableSetOf<String>()
|
||||
@@ -296,42 +290,7 @@ class ConnectionsViewModel(
|
||||
}
|
||||
.filterNotNull()
|
||||
.onEach { notice ->
|
||||
val message =
|
||||
when (notice.destination) {
|
||||
org.meshtastic.core.model.FirmwareUpdateDestination.AndroidUpdate ->
|
||||
getStringSuspend(
|
||||
Res.string.firmware_update_notification_android,
|
||||
notice.currentVersion,
|
||||
notice.stableVersion,
|
||||
)
|
||||
|
||||
org.meshtastic.core.model.FirmwareUpdateDestination.MeshtasticFlasher ->
|
||||
getStringSuspend(
|
||||
Res.string.firmware_update_notification_flasher,
|
||||
notice.currentVersion,
|
||||
notice.stableVersion,
|
||||
)
|
||||
}
|
||||
if (
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
id = notice.notificationKey.hashCode(),
|
||||
title = getStringSuspend(Res.string.firmware_update_available),
|
||||
message = message,
|
||||
type = Notification.Type.Info,
|
||||
category = Notification.Category.NodeEvent,
|
||||
deepLinkUri =
|
||||
if (
|
||||
notice.destination ==
|
||||
org.meshtastic.core.model.FirmwareUpdateDestination.AndroidUpdate
|
||||
) {
|
||||
"meshtastic:///firmware/update"
|
||||
} else {
|
||||
"https://flasher.meshtastic.org"
|
||||
},
|
||||
),
|
||||
)
|
||||
) {
|
||||
if (serviceNotifications.showFirmwareUpdateNotification(notice)) {
|
||||
scheduledFirmwareUpdateNotificationKeys += notice.notificationKey
|
||||
uiPrefs.recordFirmwareUpdateNotificationKey(notice.notificationKey)
|
||||
}
|
||||
|
||||
@@ -59,15 +59,14 @@ import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
|
||||
import org.meshtastic.core.repository.LockdownCoordinator
|
||||
import org.meshtastic.core.repository.LockdownPassphraseStore
|
||||
import org.meshtastic.core.repository.MeshLogRepository
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.NodeRestartTracker
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioController
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.core.repository.notificationId
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.client_notification
|
||||
import org.meshtastic.core.resources.compromised_keys
|
||||
@@ -102,7 +101,7 @@ class UIViewModel(
|
||||
private val eventFirmwareRepository: EventFirmwareRepository,
|
||||
private val firmwareUpdateStatusRepository: FirmwareUpdateStatusRepository,
|
||||
private val uiPrefs: UiPrefs,
|
||||
private val notificationManager: NotificationManager,
|
||||
private val serviceNotifications: MeshNotificationManager,
|
||||
packetRepository: PacketRepository,
|
||||
val alertManager: AlertManager,
|
||||
val snackbarManager: SnackbarManager,
|
||||
@@ -192,7 +191,7 @@ class UIViewModel(
|
||||
|
||||
fun clearClientNotification(notification: ClientNotification) {
|
||||
serviceRepository.clearClientNotification()
|
||||
notificationManager.cancel(notification.notificationId())
|
||||
serviceNotifications.clearClientNotification(notification)
|
||||
}
|
||||
|
||||
val lockdownState = serviceRepository.lockdownState
|
||||
|
||||
+38
-46
@@ -31,7 +31,6 @@ import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.meshtastic.core.common.util.safeCatchingAll
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
@@ -39,17 +38,11 @@ import org.meshtastic.core.model.FirmwareUpdateDestination
|
||||
import org.meshtastic.core.repository.ConnectionIdentity
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NodeRestartTracker
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.firmware_update_available
|
||||
import org.meshtastic.core.resources.firmware_update_notification_android
|
||||
import org.meshtastic.core.resources.firmware_update_notification_flasher
|
||||
import org.meshtastic.core.resources.getString
|
||||
import org.meshtastic.core.testing.FakeDeviceHardwareRepository
|
||||
import org.meshtastic.core.testing.FakeFirmwareReleaseRepository
|
||||
import org.meshtastic.core.testing.FakeMeshNotificationManager
|
||||
import org.meshtastic.core.testing.FakeNodeRepository
|
||||
import org.meshtastic.core.testing.FakeRadioPrefs
|
||||
import org.meshtastic.core.testing.FakeServiceRepository
|
||||
@@ -80,26 +73,13 @@ class ConnectionsViewModelTest {
|
||||
private val deviceHardwareRepository = FakeDeviceHardwareRepository()
|
||||
private val firmwareReleaseRepository = FakeFirmwareReleaseRepository()
|
||||
private val radioPrefs = FakeRadioPrefs()
|
||||
private val dispatchedNotifications = mutableListOf<Notification>()
|
||||
private var notificationsCanBeScheduled = true
|
||||
private val notificationManager =
|
||||
object : NotificationManager {
|
||||
override suspend fun dispatch(notification: Notification): Boolean {
|
||||
if (notificationsCanBeScheduled) dispatchedNotifications += notification
|
||||
return notificationsCanBeScheduled
|
||||
}
|
||||
|
||||
override fun cancel(id: Int) = Unit
|
||||
|
||||
override fun cancelAll() = Unit
|
||||
}
|
||||
private val serviceNotifications = FakeMeshNotificationManager()
|
||||
private val postedNotices
|
||||
get() = serviceNotifications.firmwareUpdateNotices
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
warmFirmwareNotificationStrings()
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
dispatchedNotifications.clear()
|
||||
notificationsCanBeScheduled = true
|
||||
|
||||
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig.Builder().build())
|
||||
every { nodeManager.connectionIdentity } returns connectionIdentity
|
||||
@@ -121,7 +101,7 @@ class ConnectionsViewModelTest {
|
||||
deviceHardwareRepository = deviceHardwareRepository,
|
||||
firmwareReleaseRepository = firmwareReleaseRepository,
|
||||
radioPrefs = radioPrefs,
|
||||
notificationManager = notificationManager,
|
||||
serviceNotifications = serviceNotifications,
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
@@ -363,11 +343,37 @@ class ConnectionsViewModelTest {
|
||||
assertEquals("2.7.0", notice.currentVersion)
|
||||
assertEquals("2.8.0", notice.stableVersion)
|
||||
assertEquals(FirmwareUpdateDestination.AndroidUpdate, notice.destination)
|
||||
assertEquals(1, dispatchedNotifications.size)
|
||||
assertEquals(listOf(notice), postedNotices)
|
||||
assertEquals(setOf(notice.notificationKey), uiPrefs.firmwareUpdateNotificationKeys.value)
|
||||
assertEquals("Firmware update available", dispatchedNotifications.single().title)
|
||||
assertEquals(Notification.Type.Info, dispatchedNotifications.single().type)
|
||||
assertEquals("meshtastic:///firmware/update", dispatchedNotifications.single().deepLinkUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `firmware notice for flasher-only hardware is still posted`() = runTest {
|
||||
val target = "tbeam"
|
||||
deviceHardwareRepository.setHardware(
|
||||
hwModel = HardwareModel.TBEAM.value,
|
||||
target = target,
|
||||
device = DeviceHardware(architecture = "esp32", platformioTarget = target),
|
||||
)
|
||||
nodeRepository.setMyId("!local")
|
||||
nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(firmwareVersion = "2.7.0", pioEnv = target))
|
||||
nodeRepository.setOurNode(
|
||||
org.meshtastic.core.model.Node(
|
||||
num = 1,
|
||||
user = User.Builder().also { wb -> wb.hw_model = HardwareModel.TBEAM }.build(),
|
||||
),
|
||||
)
|
||||
// ESP32 over serial is not updatable in-app, so the notice's destination is the flasher.
|
||||
radioPrefs.setDevAddr("s:connected")
|
||||
firmwareReleaseRepository.setManifestTargets("v2.8.0", setOf(target))
|
||||
firmwareReleaseRepository.setStableRelease(FirmwareRelease(id = "v2.8.0"))
|
||||
serviceRepository.setConnectionState(ConnectionState.Connected)
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
val notice = assertNotNull(viewModel.firmwareUpdateNotice.value)
|
||||
assertEquals(FirmwareUpdateDestination.MeshtasticFlasher, notice.destination)
|
||||
assertEquals(listOf(notice), postedNotices)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -400,7 +406,7 @@ class ConnectionsViewModelTest {
|
||||
fun `does not persist firmware notification dedupe when scheduling is unavailable`() = runTest {
|
||||
val hardwareModel = HardwareModel.TBEAM.value
|
||||
val target = "tbeam"
|
||||
notificationsCanBeScheduled = false
|
||||
serviceNotifications.acceptsFirmwareUpdate = false
|
||||
deviceHardwareRepository.setHardware(
|
||||
hwModel = hardwareModel,
|
||||
target = target,
|
||||
@@ -422,7 +428,7 @@ class ConnectionsViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNotNull(viewModel.firmwareUpdateNotice.value)
|
||||
assertEquals(emptyList(), dispatchedNotifications)
|
||||
assertEquals(emptyList(), postedNotices)
|
||||
assertEquals(emptySet(), uiPrefs.firmwareUpdateNotificationKeys.value)
|
||||
}
|
||||
|
||||
@@ -477,7 +483,7 @@ class ConnectionsViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(null, viewModel.firmwareUpdateNotice.value)
|
||||
assertEquals(emptyList(), dispatchedNotifications)
|
||||
assertEquals(emptyList(), postedNotices)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -492,18 +498,4 @@ class ConnectionsViewModelTest {
|
||||
fun `RECONNECTING_PROGRESS_TEXT pins the cross-track literal value`() {
|
||||
assertEquals("Reconnecting\u2026", ServiceRepository.RECONNECTING_PROGRESS_TEXT)
|
||||
}
|
||||
|
||||
/**
|
||||
* From CMP 1.12 compose resources load each string once on a library-owned `Dispatchers.Default` scope, which
|
||||
* `advanceUntilIdle` cannot drain, so the notification dispatch lands after the assertions. Pre-loading keeps the
|
||||
* path inside virtual time on any CMP version; must run before `setMain`. Best-effort: a warm-up that cannot load
|
||||
* (skiko's static initializer on the desktop test classpath) must leave the suite as it was, not fail every test.
|
||||
*/
|
||||
private fun warmFirmwareNotificationStrings() {
|
||||
safeCatchingAll {
|
||||
getString(Res.string.firmware_update_available)
|
||||
getString(Res.string.firmware_update_notification_android, "", "")
|
||||
getString(Res.string.firmware_update_notification_flasher, "", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ class DesktopNotificationManager(
|
||||
Notification.Category.MeshBeacon -> prefs.nodeEventsEnabled.value
|
||||
Notification.Category.Battery -> prefs.lowBatteryEnabled.value
|
||||
Notification.Category.Alert -> true
|
||||
Notification.Category.Client -> true
|
||||
Notification.Category.Service -> true
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ class DesktopRuntimeModule {
|
||||
meshPrefs: MeshPrefs,
|
||||
uiPrefs: UiPrefs,
|
||||
databaseManager: DatabaseManager,
|
||||
notificationManager: NotificationManager,
|
||||
serviceNotifications: MeshNotificationManager,
|
||||
messageProcessor: Lazy<MeshMessageProcessor>,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
scope: ServiceScope,
|
||||
@@ -156,7 +156,7 @@ class DesktopRuntimeModule {
|
||||
meshPrefs = meshPrefs,
|
||||
uiPrefs = uiPrefs,
|
||||
databaseManager = databaseManager,
|
||||
notificationManager = notificationManager,
|
||||
serviceNotifications = serviceNotifications,
|
||||
messageProcessor = messageProcessor,
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
scope = scope,
|
||||
|
||||
+87
-73
@@ -16,50 +16,37 @@
|
||||
*/
|
||||
package org.meshtastic.desktop.notification
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.FirmwareUpdateNotice
|
||||
import org.meshtastic.core.model.MeshBeaconOffer
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.notificationId
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.desktop_notification_title
|
||||
import org.meshtastic.core.resources.getString
|
||||
import org.meshtastic.core.resources.firmware_update_available
|
||||
import org.meshtastic.core.resources.firmware_update_notification_android
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.low_battery_message
|
||||
import org.meshtastic.core.resources.low_battery_title
|
||||
import org.meshtastic.core.resources.new_node_seen
|
||||
import org.meshtastic.core.resources.mesh_beacon_notification_body
|
||||
import org.meshtastic.core.resources.mesh_beacon_notification_title
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.Telemetry
|
||||
|
||||
/**
|
||||
* Desktop implementation of [MeshNotificationManager].
|
||||
* Desktop implementation of [MeshNotificationManager]: turns each mesh event into a [Notification] record and hands it
|
||||
* to [NotificationManager], which gates it on the user's notification preferences and shows it through the OS.
|
||||
*
|
||||
* Converts mesh-layer notification events into domain [Notification] objects and dispatches them through
|
||||
* [NotificationManager], which ultimately surfaces them as Compose Desktop tray notifications.
|
||||
*
|
||||
* Android-only concepts (notification channels, foreground-service state updates) are intentionally no-ops.
|
||||
* Android-only concepts (notification channels, the foreground-service notification, updating a posted notification in
|
||||
* place) are no-ops here.
|
||||
*
|
||||
* Registered manually in `DesktopRuntimeModule` -- do **not** add `@Single` to avoid double-registration with the
|
||||
* `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule].
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class DesktopMeshNotificationManager(
|
||||
private val notificationManager: NotificationManager,
|
||||
// Bridges the non-suspend MeshNotificationManager entry points to the suspending NotificationManager.dispatch.
|
||||
// Injectable so tests can substitute a TestScope / TestDispatcher.
|
||||
@Suppress("InjectDispatcher") private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
) : MeshNotificationManager {
|
||||
|
||||
/**
|
||||
* Launches [build] on [scope] and dispatches the resulting [Notification], bridging the non-suspend entry points to
|
||||
* the suspending [NotificationManager.dispatch]. [build] runs inside the coroutine so it may call suspend resource
|
||||
* getters (e.g. [getString]).
|
||||
*/
|
||||
private fun dispatchAsync(build: suspend () -> Notification) =
|
||||
scope.launch { notificationManager.dispatch(build()) }
|
||||
class DesktopMeshNotificationManager(private val notificationManager: NotificationManager) : MeshNotificationManager {
|
||||
|
||||
override fun clearNotifications() {
|
||||
notificationManager.cancelAll()
|
||||
@@ -86,7 +73,6 @@ class DesktopMeshNotificationManager(
|
||||
title = name,
|
||||
message = message,
|
||||
category = Notification.Category.Message,
|
||||
contactKey = contactKey,
|
||||
isSilent = isSilent,
|
||||
id = contactKey.hashCode(),
|
||||
),
|
||||
@@ -100,15 +86,7 @@ class DesktopMeshNotificationManager(
|
||||
waypointId: Int,
|
||||
isSilent: Boolean,
|
||||
) {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = name,
|
||||
message = message,
|
||||
category = Notification.Category.Message,
|
||||
contactKey = contactKey,
|
||||
isSilent = isSilent,
|
||||
),
|
||||
)
|
||||
notificationManager.dispatch(Notification(title = name, message = message, isSilent = isSilent))
|
||||
}
|
||||
|
||||
override suspend fun updateReactionNotification(
|
||||
@@ -119,64 +97,100 @@ class DesktopMeshNotificationManager(
|
||||
channelName: String?,
|
||||
isSilent: Boolean,
|
||||
) {
|
||||
notificationManager.dispatch(
|
||||
Notification(title = name, message = emoji, category = Notification.Category.Message, isSilent = isSilent),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun showAlertNotification(contactKey: String, name: String, alert: String) {
|
||||
notificationManager.dispatch(
|
||||
Notification(title = name, message = alert, category = Notification.Category.Alert),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun showMeshBeaconNotification(offer: MeshBeaconOffer) {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = name,
|
||||
message = emoji,
|
||||
category = Notification.Category.Message,
|
||||
contactKey = contactKey,
|
||||
isSilent = isSilent,
|
||||
title = getStringSuspend(Res.string.mesh_beacon_notification_title),
|
||||
message = offer.message.ifBlank { getStringSuspend(Res.string.mesh_beacon_notification_body) },
|
||||
category = Notification.Category.MeshBeacon,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun showAlertNotification(contactKey: String, name: String, alert: String) {
|
||||
dispatchAsync {
|
||||
Notification(title = name, message = alert, category = Notification.Category.Alert, contactKey = contactKey)
|
||||
}
|
||||
}
|
||||
|
||||
override fun showNewNodeSeenNotification(node: Node) {
|
||||
dispatchAsync {
|
||||
override suspend fun showNewNodeSeenNotification(node: Node, title: String) {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getString(Res.string.new_node_seen, node.user.short_name),
|
||||
title = title,
|
||||
message = node.user.long_name,
|
||||
category = Notification.Category.NodeEvent,
|
||||
)
|
||||
}
|
||||
id = node.num,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {
|
||||
dispatchAsync {
|
||||
override fun cancelNewNodeNotification(nodeNum: Int) {
|
||||
notificationManager.cancel(nodeNum)
|
||||
}
|
||||
|
||||
override suspend fun showLowBatteryNotification(node: Node, isRemote: Boolean) {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getString(Res.string.low_battery_title, node.user.short_name),
|
||||
message = getString(Res.string.low_battery_message, node.user.long_name, node.batteryLevel ?: 0),
|
||||
title = getStringSuspend(Res.string.low_battery_title, node.user.short_name),
|
||||
message = getStringSuspend(Res.string.low_battery_message, node.user.long_name, node.batteryLevel ?: 0),
|
||||
category = Notification.Category.Battery,
|
||||
id = node.num,
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun showClientNotification(clientNotification: ClientNotification) {
|
||||
dispatchAsync {
|
||||
Notification(
|
||||
title = getString(Res.string.desktop_notification_title),
|
||||
message = clientNotification.message,
|
||||
category = Notification.Category.Alert,
|
||||
id = clientNotification.toString().hashCode(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancelMessageNotification(contactKey: String) {
|
||||
notificationManager.cancel(contactKey.hashCode())
|
||||
override suspend fun updateLowBatteryNotification(node: Node, isRemote: Boolean) {
|
||||
// No-op: an OS notification cannot be refreshed in place, and re-posting would alert again.
|
||||
}
|
||||
|
||||
override fun cancelLowBatteryNotification(node: Node) {
|
||||
notificationManager.cancel(node.num)
|
||||
}
|
||||
|
||||
override fun clearClientNotification(notification: ClientNotification) {
|
||||
notificationManager.cancel(notification.toString().hashCode())
|
||||
override suspend fun showClientNotification(
|
||||
clientNotification: ClientNotification,
|
||||
title: String,
|
||||
severity: Notification.Type,
|
||||
) {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = title,
|
||||
message = clientNotification.message,
|
||||
type = severity,
|
||||
category = Notification.Category.Client,
|
||||
id = clientNotification.notificationId(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun clearClientNotification(clientNotification: ClientNotification) {
|
||||
notificationManager.cancel(clientNotification.notificationId())
|
||||
}
|
||||
|
||||
override suspend fun showFirmwareUpdateNotification(notice: FirmwareUpdateNotice): Boolean =
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getStringSuspend(Res.string.firmware_update_available),
|
||||
message =
|
||||
getStringSuspend(
|
||||
Res.string.firmware_update_notification_android,
|
||||
notice.currentVersion,
|
||||
notice.stableVersion,
|
||||
),
|
||||
category = Notification.Category.Service,
|
||||
id = notice.notificationKey.hashCode(),
|
||||
),
|
||||
)
|
||||
|
||||
// The reconnect-blocked notice is about an Android runtime permission; desktop never raises it.
|
||||
override suspend fun showReconnectBlockedNotification(title: String, message: String): Boolean = false
|
||||
|
||||
override suspend fun cancelMessageNotification(contactKey: String) {
|
||||
notificationManager.cancel(contactKey.hashCode())
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -200,10 +200,17 @@ class LinuxNotificationSender(
|
||||
val category =
|
||||
when (notification.category) {
|
||||
Notification.Category.Message -> "im.received"
|
||||
|
||||
Notification.Category.Battery -> "device.warning"
|
||||
Notification.Category.Alert -> "device.error"
|
||||
|
||||
Notification.Category.Alert,
|
||||
Notification.Category.Client,
|
||||
-> "device.error"
|
||||
|
||||
Notification.Category.NodeEvent -> "network"
|
||||
|
||||
Notification.Category.MeshBeacon -> "network"
|
||||
|
||||
Notification.Category.Service -> "device"
|
||||
}
|
||||
libnotify.notify_notification_set_category(ptr, category)
|
||||
|
||||
+8
-1
@@ -60,10 +60,17 @@ class MacOSNotificationSender private constructor(private val bridge: MacNotific
|
||||
|
||||
internal fun categorySubtitle(category: Notification.Category): String = when (category) {
|
||||
Notification.Category.Message -> "Message"
|
||||
|
||||
Notification.Category.NodeEvent -> "Node Event"
|
||||
|
||||
Notification.Category.MeshBeacon -> "Mesh Invitation"
|
||||
|
||||
Notification.Category.Battery -> "Low Battery"
|
||||
Notification.Category.Alert -> "Alert"
|
||||
|
||||
Notification.Category.Alert,
|
||||
Notification.Category.Client,
|
||||
-> "Alert"
|
||||
|
||||
Notification.Category.Service -> "Service"
|
||||
}
|
||||
|
||||
|
||||
+54
-15
@@ -16,44 +16,83 @@
|
||||
*/
|
||||
package org.meshtastic.desktop.notification
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.notificationId
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DesktopMeshNotificationManagerTest {
|
||||
|
||||
/** Records everything dispatched so the async bridge can be asserted deterministically. */
|
||||
private class FakeNotificationManager : NotificationManager {
|
||||
private class FakeNotificationManager(var accepts: Boolean = true) : NotificationManager {
|
||||
val dispatched = mutableListOf<Notification>()
|
||||
val cancelled = mutableListOf<Int>()
|
||||
|
||||
override suspend fun dispatch(notification: Notification): Boolean {
|
||||
dispatched.add(notification)
|
||||
return true
|
||||
if (accepts) dispatched.add(notification)
|
||||
return accepts
|
||||
}
|
||||
|
||||
override fun cancel(id: Int) {}
|
||||
override fun cancel(id: Int) {
|
||||
cancelled.add(id)
|
||||
}
|
||||
|
||||
override fun cancelAll() {}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showAlertNotification dispatches on the injected scope`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val notificationManager = FakeNotificationManager()
|
||||
// backgroundScope inherits the UnconfinedTestDispatcher, so the launched dispatch runs eagerly and is
|
||||
// observable synchronously — no virtual-time advance or manual teardown needed.
|
||||
val manager = DesktopMeshNotificationManager(notificationManager, scope = backgroundScope)
|
||||
private val notificationManager = FakeNotificationManager()
|
||||
private val manager = DesktopMeshNotificationManager(notificationManager)
|
||||
|
||||
@Test
|
||||
fun `critical alerts dispatch in the alert category`() = runTest {
|
||||
manager.showAlertNotification(contactKey = "contact-1", name = "Alert", alert = "Something happened")
|
||||
|
||||
val dispatched = notificationManager.dispatched.single()
|
||||
assertEquals("Alert", dispatched.title)
|
||||
assertEquals("Something happened", dispatched.message)
|
||||
assertEquals(Notification.Category.Alert, dispatched.category)
|
||||
assertEquals("contact-1", dispatched.contactKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `client notifications keep their title and severity under a stable id`() = runTest {
|
||||
val clientNotification = ClientNotification.Builder().also { wb -> wb.message = "Duplicate key" }.build()
|
||||
|
||||
manager.showClientNotification(clientNotification, title = "Key conflict", severity = Notification.Type.Warning)
|
||||
manager.clearClientNotification(clientNotification)
|
||||
|
||||
val dispatched = notificationManager.dispatched.single()
|
||||
assertEquals("Key conflict", dispatched.title)
|
||||
assertEquals(Notification.Type.Warning, dispatched.type)
|
||||
assertEquals(Notification.Category.Client, dispatched.category)
|
||||
assertEquals(clientNotification.notificationId(), dispatched.id)
|
||||
assertEquals(listOf(clientNotification.notificationId()), notificationManager.cancelled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a low-battery refresh never re-posts`() = runTest {
|
||||
manager.updateLowBatteryNotification(Node(num = 7), isRemote = false)
|
||||
|
||||
assertTrue(notificationManager.dispatched.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new-node notifications cancel by node number`() = runTest {
|
||||
manager.showNewNodeSeenNotification(Node(num = 7), title = "New node seen: N7")
|
||||
manager.cancelNewNodeNotification(7)
|
||||
|
||||
assertEquals(7, notificationManager.dispatched.single().id)
|
||||
assertEquals("New node seen: N7", notificationManager.dispatched.single().title)
|
||||
assertEquals(listOf(7), notificationManager.cancelled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconnect-blocked is never shown on desktop`() = runTest {
|
||||
assertFalse(manager.showReconnectBlockedNotification("title", "message"))
|
||||
assertTrue(notificationManager.dispatched.isEmpty())
|
||||
}
|
||||
}
|
||||
+42
-2
@@ -18,7 +18,11 @@ package org.meshtastic.feature.firmware
|
||||
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.repository.FirmwareUpdateProgress
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.UiText
|
||||
import org.meshtastic.core.resources.firmware_update_verifying
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Represents the progress of a long-running firmware update task.
|
||||
@@ -67,8 +71,14 @@ sealed interface FirmwareUpdateState {
|
||||
/** Firmware file is being downloaded from the release server. */
|
||||
data class Downloading(val progressState: ProgressState) : FirmwareUpdateState
|
||||
|
||||
/** Intermediate processing (e.g. extracting, preparing DFU). */
|
||||
data class Processing(val progressState: ProgressState) : FirmwareUpdateState
|
||||
/**
|
||||
* Intermediate processing (e.g. extracting, preparing DFU).
|
||||
*
|
||||
* @property beforeConfirmation True while a picked local file is checked before the user has confirmed any update,
|
||||
* so nothing outside the screen reports it as a running update.
|
||||
*/
|
||||
data class Processing(val progressState: ProgressState, val beforeConfirmation: Boolean = false) :
|
||||
FirmwareUpdateState
|
||||
|
||||
/** Firmware is actively being written to the device. */
|
||||
data class Updating(val progressState: ProgressState) : FirmwareUpdateState
|
||||
@@ -118,6 +128,36 @@ sealed interface FirmwareUpdateState {
|
||||
data class ReviewingBootloader(val versions: BootloaderVersions) : FirmwareUpdateState
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of this state the foreground-service notification shows, or null when nothing is transferring. Determinate
|
||||
* exactly where the screen draws a determinate bar: downloading and writing, not the processing and verifying waits.
|
||||
*/
|
||||
internal fun FirmwareUpdateState.toUpdateProgress(): FirmwareUpdateProgress? = when (this) {
|
||||
is FirmwareUpdateState.Downloading -> FirmwareUpdateProgress(progressState.message, progressState.percent())
|
||||
|
||||
is FirmwareUpdateState.Updating -> FirmwareUpdateProgress(progressState.message, progressState.percent())
|
||||
|
||||
is FirmwareUpdateState.Processing ->
|
||||
if (beforeConfirmation) null else FirmwareUpdateProgress(progressState.message, percent = null)
|
||||
|
||||
FirmwareUpdateState.Verifying ->
|
||||
FirmwareUpdateProgress(UiText.Resource(Res.string.firmware_update_verifying), percent = null)
|
||||
|
||||
FirmwareUpdateState.Idle,
|
||||
FirmwareUpdateState.Checking,
|
||||
is FirmwareUpdateState.Ready,
|
||||
FirmwareUpdateState.VerificationFailed,
|
||||
is FirmwareUpdateState.Error,
|
||||
is FirmwareUpdateState.Success,
|
||||
is FirmwareUpdateState.AwaitingFileSave,
|
||||
is FirmwareUpdateState.ReviewingBootloader,
|
||||
-> null
|
||||
}
|
||||
|
||||
private const val PERCENT = 100
|
||||
|
||||
private fun ProgressState.percent(): Int = (progress * PERCENT).roundToInt().coerceIn(0, PERCENT)
|
||||
|
||||
private val FORMAT_ARG_REGEX = Regex(":?\\s*%1\\\$d%?")
|
||||
|
||||
/** Strip positional format arguments (e.g. `%1$d`) from a localized template to get a clean base message. */
|
||||
|
||||
+17
-1
@@ -30,9 +30,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
@@ -59,6 +61,7 @@ import org.meshtastic.core.model.MyNodeInfo
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.repository.DeviceHardwareRepository
|
||||
import org.meshtastic.core.repository.FirmwareReleaseRepository
|
||||
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
|
||||
import org.meshtastic.core.repository.MaintenanceUf2Repository
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.NodeRestartTracker
|
||||
@@ -136,6 +139,7 @@ class FirmwareUpdateViewModel(
|
||||
private val analytics: PlatformAnalytics,
|
||||
private val nodeRestartTracker: NodeRestartTracker,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val firmwareUpdateStatusRepository: FirmwareUpdateStatusRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
/** The USB maintenance sequence's hold on the radio. Spans several passes, so it cannot use `withOperation`. */
|
||||
@@ -209,6 +213,14 @@ class FirmwareUpdateViewModel(
|
||||
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
|
||||
checkForUpdates()
|
||||
}
|
||||
// One observer of every state write, so the foreground-service notification can follow a flash the user has
|
||||
// backgrounded without any write site having to remember to publish.
|
||||
viewModelScope.launch {
|
||||
_state
|
||||
.map { it.toUpdateProgress() }
|
||||
.distinctUntilChanged()
|
||||
.collect(firmwareUpdateStatusRepository::publishProgress)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@@ -221,6 +233,7 @@ class FirmwareUpdateViewModel(
|
||||
// leaked lock would permanently suppress the radio transport's auto-reconnect for the rest of the app
|
||||
// session — see startUsbMaintenance/advancePastPass. A no-op if no sequence was in flight.
|
||||
releaseMaintenanceLease()
|
||||
firmwareUpdateStatusRepository.publishProgress(null)
|
||||
// viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the
|
||||
// application-wide scope (SupervisorJob + ioDispatcher). ATOMIC start + NonCancellable
|
||||
// context keeps cleanup running even if something tries to cancel it mid-flight.
|
||||
@@ -972,7 +985,10 @@ class FirmwareUpdateViewModel(
|
||||
LocalFirmwareResolution.Invalid(reason = fallbackReason, fileName = fileName)
|
||||
} else {
|
||||
val extractingState =
|
||||
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_extracting)))
|
||||
FirmwareUpdateState.Processing(
|
||||
ProgressState(UiText.Resource(Res.string.firmware_update_extracting)),
|
||||
beforeConfirmation = true,
|
||||
)
|
||||
_state.value = extractingState
|
||||
try {
|
||||
val extractedArtifact = extractLocalFirmwareArchive(uri, fileName, state, payloadExtension)
|
||||
|
||||
+2
@@ -41,6 +41,7 @@ import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.model.MaintenanceUf2Manifest
|
||||
import org.meshtastic.core.repository.DeviceHardwareRepository
|
||||
import org.meshtastic.core.repository.FirmwareReleaseRepository
|
||||
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
|
||||
import org.meshtastic.core.repository.MaintenanceUf2Repository
|
||||
import org.meshtastic.core.repository.NodeRestartTracker
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
@@ -132,6 +133,7 @@ class FirmwareUpdateIntegrationTest {
|
||||
analytics,
|
||||
NodeRestartTracker(TestApplicationCoroutineScope(testDispatcher)),
|
||||
FakeBluetoothRepository(),
|
||||
FirmwareUpdateStatusRepository(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
||||
+26
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
package org.meshtastic.feature.firmware
|
||||
|
||||
import org.meshtastic.core.repository.FirmwareUpdateProgress
|
||||
import org.meshtastic.core.resources.UiText
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
@@ -58,4 +59,29 @@ class FirmwareUpdateStateTest {
|
||||
fun `stripFormatArgs handles empty string`() {
|
||||
assertEquals("", "".stripFormatArgs())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writing and downloading report a percent the notification can show`() {
|
||||
val message = UiText.DynamicString("Writing")
|
||||
|
||||
assertEquals(
|
||||
FirmwareUpdateProgress(message, percent = 42),
|
||||
FirmwareUpdateState.Updating(ProgressState(message, 0.42f)).toUpdateProgress(),
|
||||
)
|
||||
assertEquals(100, FirmwareUpdateState.Downloading(ProgressState(message, 1.3f)).toUpdateProgress()?.percent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `waits report no percent and states the user acts on report nothing`() {
|
||||
val message = UiText.DynamicString("Waiting for reboot")
|
||||
|
||||
assertEquals(null, FirmwareUpdateState.Processing(ProgressState(message, 0.9f)).toUpdateProgress()?.percent)
|
||||
assertEquals(null, FirmwareUpdateState.Verifying.toUpdateProgress()?.percent)
|
||||
assertEquals(
|
||||
null,
|
||||
FirmwareUpdateState.Processing(ProgressState(message), beforeConfirmation = true).toUpdateProgress(),
|
||||
)
|
||||
assertEquals(null, FirmwareUpdateState.AwaitingFileSave(uf2Artifact = null, fileName = null).toUpdateProgress())
|
||||
assertEquals(null, FirmwareUpdateState.Idle.toUpdateProgress())
|
||||
}
|
||||
}
|
||||
+31
@@ -26,6 +26,7 @@ import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import dev.mokkery.verify.VerifyMode
|
||||
import dev.mokkery.verifySuspend
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -49,6 +50,8 @@ import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.model.MaintenanceUf2Manifest
|
||||
import org.meshtastic.core.repository.DeviceHardwareRepository
|
||||
import org.meshtastic.core.repository.FirmwareReleaseRepository
|
||||
import org.meshtastic.core.repository.FirmwareUpdateProgress
|
||||
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
|
||||
import org.meshtastic.core.repository.MaintenanceUf2Repository
|
||||
import org.meshtastic.core.repository.NodeRestartTracker
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
@@ -68,6 +71,7 @@ import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
@@ -162,8 +166,11 @@ class FirmwareUpdateViewModelTest {
|
||||
analytics,
|
||||
NodeRestartTracker(TestApplicationCoroutineScope(testDispatcher)),
|
||||
bluetoothRepository,
|
||||
firmwareUpdateStatusRepository,
|
||||
)
|
||||
|
||||
private val firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository()
|
||||
|
||||
@Test
|
||||
fun `initialization checks for updates and transitions to Ready`() = runTest {
|
||||
advanceUntilIdle()
|
||||
@@ -268,6 +275,30 @@ class FirmwareUpdateViewModelTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running flash publishes its progress for the service notification`() = runTest {
|
||||
advanceUntilIdle()
|
||||
val transferring = CompletableDeferred<Unit>()
|
||||
val message = UiText.DynamicString("Writing firmware")
|
||||
everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any()) }
|
||||
.calls {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val updateState = it.args[3] as (FirmwareUpdateState) -> Unit
|
||||
updateState(FirmwareUpdateState.Updating(ProgressState(message = message, progress = 0.42f)))
|
||||
transferring.await()
|
||||
null
|
||||
}
|
||||
|
||||
viewModel.startUpdate()
|
||||
runCurrent()
|
||||
|
||||
assertEquals(FirmwareUpdateProgress(message, percent = 42), firmwareUpdateStatusRepository.progress.value)
|
||||
|
||||
transferring.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
assertNotEquals(42, firmwareUpdateStatusRepository.progress.value?.percent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startUpdate with wipe factory-resets only after verification succeeds`() = runTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
+4
@@ -56,6 +56,7 @@ import org.meshtastic.core.model.OtafixAssetEntry
|
||||
import org.meshtastic.core.model.SoftDeviceVariant
|
||||
import org.meshtastic.core.repository.DeviceHardwareRepository
|
||||
import org.meshtastic.core.repository.FirmwareReleaseRepository
|
||||
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
|
||||
import org.meshtastic.core.repository.MaintenanceUf2Repository
|
||||
import org.meshtastic.core.repository.NodeRestartTracker
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
@@ -100,6 +101,7 @@ class FirmwareUpdateViewModelFileTest {
|
||||
private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill)
|
||||
private val firmwareRetriever: FirmwareRetriever = mock(MockMode.autofill)
|
||||
private val radioOperationLock = RadioOperationLock()
|
||||
private val firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository()
|
||||
private val analytics: PlatformAnalytics = mock(MockMode.autofill)
|
||||
|
||||
private lateinit var viewModel: FirmwareUpdateViewModel
|
||||
@@ -184,6 +186,7 @@ class FirmwareUpdateViewModelFileTest {
|
||||
analytics,
|
||||
NodeRestartTracker(TestApplicationCoroutineScope(testDispatcher)),
|
||||
FakeBluetoothRepository(),
|
||||
firmwareUpdateStatusRepository,
|
||||
)
|
||||
|
||||
private fun firmwareUri(fileName: String): CommonUri = CommonUri.parse("file:///downloads/$fileName")
|
||||
@@ -406,6 +409,7 @@ class FirmwareUpdateViewModelFileTest {
|
||||
val processing = assertIs<FirmwareUpdateState.Processing>(viewModel.state.value)
|
||||
val message = assertIs<UiText.Resource>(processing.progressState.message)
|
||||
assertEquals(Res.string.firmware_update_extracting, message.res)
|
||||
assertNull(firmwareUpdateStatusRepository.progress.value, "checking a picked file is not a running update")
|
||||
|
||||
allowExtraction.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
Reference in new issue
Block a user