docs: Unify notification channel management and migrate unit tests (#4867)

This commit is contained in:
James Rich
2026-03-20 17:58:47 -05:00
committed by GitHub
parent b1e433e0dc
commit 6e50db0b91
14 changed files with 586 additions and 221 deletions

View File

@@ -58,10 +58,15 @@ kotlin {
implementation(libs.koin.androidx.workmanager)
}
androidUnitTest.dependencies {
implementation(libs.robolectric)
implementation(libs.androidx.test.core)
implementation(libs.androidx.work.testing)
val androidHostTest by getting {
dependencies {
implementation(projects.core.testing)
implementation(libs.junit)
implementation(libs.robolectric)
implementation(libs.androidx.test.core)
implementation(libs.androidx.test.ext.junit)
implementation(libs.androidx.work.testing)
}
}
commonTest.dependencies {

View File

@@ -16,18 +16,21 @@
*/
package org.meshtastic.core.service
import android.app.Application
import dev.mokkery.MockMode
import dev.mokkery.mock
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class AndroidFileServiceTest {
@Test
fun testInitialization() = runTest {
val mockContext = mock<Application>(MockMode.autofill)
val service = AndroidFileService(mockContext)
val context = RuntimeEnvironment.getApplication()
val service = AndroidFileService(context)
assertNotNull(service)
}
}

View File

@@ -16,20 +16,31 @@
*/
package org.meshtastic.core.service
import android.app.Application
import dev.mokkery.MockMode
import dev.mokkery.mock
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.repository.Location
import org.meshtastic.core.repository.LocationRepository
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class AndroidLocationServiceTest {
@Test
fun testInitialization() = runTest {
val mockContext = mock<Application>(MockMode.autofill)
val mockRepo = mock<LocationRepository>(MockMode.autofill)
val service = AndroidLocationService(mockContext, mockRepo)
val context = RuntimeEnvironment.getApplication()
val service = AndroidLocationService(context, FakeLocationRepository())
assertNotNull(service)
}
private class FakeLocationRepository : LocationRepository {
override val receivingLocationUpdates = MutableStateFlow(false)
override fun getLocations() = emptyFlow<Location>()
}
}

View File

@@ -0,0 +1,156 @@
/*
* 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 org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.repository.Notification
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@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 `init removes legacy node channel and creates canonical node channel`() {
createChannel("NodeEvent")
AndroidNotificationManager(context)
assertNull(systemNotificationManager.getNotificationChannel("NodeEvent"))
assertNotNull(systemNotificationManager.getNotificationChannel(NotificationChannels.NEW_NODES))
}
@Test
fun `dispatch routes node event notifications to canonical new nodes channel`() {
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 `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`() {
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)
}
private 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) {
systemNotificationManager.createNotificationChannel(
NotificationChannel(id, id, NotificationManager.IMPORTANCE_DEFAULT),
)
}
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) }
}
}

View File

@@ -0,0 +1,111 @@
/*
* 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 org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
@Config(sdk = [34])
class MeshServiceNotificationsImplTest {
private lateinit var context: Context
private lateinit var systemNotificationManager: NotificationManager
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
systemNotificationManager = context.getSystemService(NotificationManager::class.java)!!
clearManagedChannels()
}
@After
fun tearDown() {
clearManagedChannels()
}
@Test
fun `initChannels removes legacy categories and creates canonical channels`() {
NotificationChannels.LEGACY_CATEGORY_IDS.forEach(::createChannel)
val notifications =
MeshServiceNotificationsImpl(
context = context,
packetRepository = lazy<PacketRepository> { error("Not used in this test") },
nodeRepository = lazy<NodeRepository> { error("Not used in this test") },
)
notifications.initChannels()
NotificationChannels.LEGACY_CATEGORY_IDS.forEach { legacyId ->
assertNull(systemNotificationManager.getNotificationChannel(legacyId))
}
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,
)
canonicalChannelIds.forEach { channelId ->
assertNotNull(systemNotificationManager.getNotificationChannel(channelId))
}
}
private fun createChannel(id: String) {
systemNotificationManager.createNotificationChannel(
NotificationChannel(id, id, NotificationManager.IMPORTANCE_DEFAULT),
)
}
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) }
}
}

View File

@@ -23,13 +23,12 @@ import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.workDataOf
import dev.mokkery.MockMode
import dev.mokkery.every
import dev.mokkery.answering.returns
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify.VerifyMode
import dev.mokkery.verifySuspend
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
@@ -39,23 +38,26 @@ import org.junit.runner.RunWith
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.service.worker.SendMessageWorker
import org.meshtastic.core.testing.FakeRadioController
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class SendMessageWorkerTest {
private lateinit var context: Context
private lateinit var packetRepository: PacketRepository
private lateinit var radioController: RadioController
private lateinit var radioController: FakeRadioController
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
packetRepository = mock(MockMode.autofill)
radioController = mock(MockMode.autofill)
every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Connected)
radioController = FakeRadioController()
radioController.setConnectionState(ConnectionState.Connected)
}
@Test
@@ -64,8 +66,6 @@ class SendMessageWorkerTest {
val packetId = 12345
val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
everySuspend { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Connected)
everySuspend { radioController.sendMessage(any()) } returns Unit
everySuspend { packetRepository.updateMessageStatus(any(), any()) } returns Unit
val worker =
@@ -88,7 +88,7 @@ class SendMessageWorkerTest {
// Assert
assertEquals(ListenableWorker.Result.success(), result)
verifySuspend { radioController.sendMessage(dataPacket) }
assertEquals(listOf(dataPacket), radioController.sentPackets)
verifySuspend { packetRepository.updateMessageStatus(dataPacket, MessageStatus.ENROUTE) }
}
@@ -98,7 +98,7 @@ class SendMessageWorkerTest {
val packetId = 12345
val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
everySuspend { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Disconnected)
radioController.setConnectionState(ConnectionState.Disconnected)
val worker =
TestListenableWorkerBuilder<SendMessageWorker>(context)
@@ -120,14 +120,39 @@ class SendMessageWorkerTest {
// Assert
assertEquals(ListenableWorker.Result.retry(), result)
verifySuspend(mode = VerifyMode.exactly(0)) { radioController.sendMessage(any()) }
assertEquals(emptyList<DataPacket>(), radioController.sentPackets)
verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.updateMessageStatus(any(), any()) }
}
@Test
fun `doWork returns failure when packet is missing`() = runTest {
// Arrange
val packetId = 999
everySuspend { packetRepository.getPacketByPacketId(packetId) } returns null
fun `doWork returns failure when packet id is missing`() = runTest {
val worker =
TestListenableWorkerBuilder<SendMessageWorker>(context)
.setWorkerFactory(
object : androidx.work.WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters,
): ListenableWorker? =
SendMessageWorker(appContext, workerParameters, packetRepository, radioController)
},
)
.build()
val result = worker.doWork()
assertEquals(ListenableWorker.Result.failure(), result)
verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.getPacketByPacketId(any()) }
}
@Test
fun `doWork returns retry and marks queued when send throws`() = runTest {
val packetId = 12345
val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
everySuspend { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
everySuspend { packetRepository.updateMessageStatus(any(), any()) } returns Unit
radioController.throwOnSend = true
val worker =
TestListenableWorkerBuilder<SendMessageWorker>(context)
@@ -144,10 +169,9 @@ class SendMessageWorkerTest {
)
.build()
// Act
val result = worker.doWork()
// Assert
assertEquals(ListenableWorker.Result.failure(), result)
assertEquals(ListenableWorker.Result.retry(), result)
verifySuspend { packetRepository.updateMessageStatus(dataPacket, MessageStatus.QUEUED) }
}
}

View File

@@ -0,0 +1,135 @@
/*
* Copyright (c) 2025-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.Application
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import co.touchlab.kermit.Severity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.MeshPacket
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ServiceBroadcastsTest {
private lateinit var context: Context
private val serviceRepository = FakeServiceRepository()
private lateinit var broadcasts: ServiceBroadcasts
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
broadcasts = ServiceBroadcasts(context, serviceRepository)
serviceRepository.setConnectionState(ConnectionState.Connected)
}
@Test
fun `broadcastConnection sends uppercase state string for ATAK`() {
broadcasts.broadcastConnection()
val shadowApp = shadowOf(context as Application)
val intent = shadowApp.broadcastIntents.find { it.action == ACTION_MESH_CONNECTED }
assertEquals("CONNECTED", intent?.getStringExtra(EXTRA_CONNECTED))
}
@Test
fun `broadcastConnection sends legacy connection intent`() {
broadcasts.broadcastConnection()
val shadowApp = shadowOf(context as Application)
val intent = shadowApp.broadcastIntents.find { it.action == ACTION_CONNECTION_CHANGED }
assertEquals("CONNECTED", intent?.getStringExtra(EXTRA_CONNECTED))
assertEquals(true, intent?.getBooleanExtra("connected", false))
}
private class FakeServiceRepository : ServiceRepository {
override val connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
override val clientNotification = MutableStateFlow<ClientNotification?>(null)
override val errorMessage = MutableStateFlow<String?>(null)
override val connectionProgress = MutableStateFlow<String?>(null)
private val meshPackets = MutableSharedFlow<MeshPacket>()
override val meshPacketFlow: SharedFlow<MeshPacket> = meshPackets
override val tracerouteResponse = MutableStateFlow<TracerouteResponse?>(null)
override val neighborInfoResponse = MutableStateFlow<String?>(null)
private val serviceActions = MutableSharedFlow<ServiceAction>()
override val serviceAction: Flow<ServiceAction> = serviceActions
override fun setConnectionState(connectionState: ConnectionState) {
this.connectionState.value = connectionState
}
override fun setClientNotification(notification: ClientNotification?) {
clientNotification.value = notification
}
override fun clearClientNotification() {
clientNotification.value = null
}
override fun setErrorMessage(text: String, severity: Severity) {
errorMessage.value = text
}
override fun clearErrorMessage() {
errorMessage.value = null
}
override fun setConnectionProgress(text: String) {
connectionProgress.value = text
}
override suspend fun emitMeshPacket(packet: MeshPacket) {
meshPackets.emit(packet)
}
override fun setTracerouteResponse(value: TracerouteResponse?) {
tracerouteResponse.value = value
}
override fun clearTracerouteResponse() {
tracerouteResponse.value = null
}
override fun setNeighborInfoResponse(value: String?) {
neighborInfoResponse.value = value
}
override fun clearNeighborInfoResponse() {
neighborInfoResponse.value = null
}
override suspend fun onServiceAction(action: ServiceAction) {
serviceActions.emit(action)
}
}
}

View File

@@ -38,6 +38,8 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan
private val notificationManager = context.getSystemService<SystemNotificationManager>()!!
private data class ChannelConfig(val id: String, val importance: Int)
init {
initChannels()
}
@@ -46,45 +48,51 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channels =
listOf(
createChannel(
Notification.Category.Message,
Res.string.meshtastic_messages_notifications,
SystemNotificationManager.IMPORTANCE_DEFAULT,
),
createChannel(
Notification.Category.NodeEvent,
Res.string.meshtastic_new_nodes_notifications,
SystemNotificationManager.IMPORTANCE_DEFAULT,
),
createChannel(
Notification.Category.Battery,
Res.string.meshtastic_low_battery_notifications,
SystemNotificationManager.IMPORTANCE_DEFAULT,
),
createChannel(
Notification.Category.Alert,
Res.string.meshtastic_alerts_notifications,
SystemNotificationManager.IMPORTANCE_HIGH,
),
createChannel(
Notification.Category.Service,
Res.string.meshtastic_service_notifications,
SystemNotificationManager.IMPORTANCE_MIN,
),
createChannel(Notification.Category.Message, Res.string.meshtastic_messages_notifications),
createChannel(Notification.Category.NodeEvent, Res.string.meshtastic_new_nodes_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 fun createChannel(
category: Notification.Category,
nameRes: org.jetbrains.compose.resources.StringResource,
importance: Int,
): NotificationChannel = NotificationChannel(category.name, getString(nameRes), importance)
): NotificationChannel {
val channelConfig = category.channelConfig()
return NotificationChannel(channelConfig.id, getString(nameRes), channelConfig.importance)
}
// Keep category-to-channel mapping aligned with MeshServiceNotificationsImpl.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.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 fun dispatch(notification: Notification) {
val builder =
NotificationCompat.Builder(context, notification.category.name)
NotificationCompat.Builder(context, notification.category.channelConfig().id)
.setContentTitle(notification.title)
.setContentText(notification.message)
.setSmallIcon(android.R.drawable.ic_dialog_info)

View File

@@ -134,63 +134,63 @@ class MeshServiceNotificationsImpl(
) {
object ServiceState :
NotificationType(
"my_service",
NotificationChannels.SERVICE,
Res.string.meshtastic_service_notifications,
NotificationManager.IMPORTANCE_MIN,
)
object DirectMessage :
NotificationType(
"my_messages",
NotificationChannels.MESSAGES,
Res.string.meshtastic_messages_notifications,
NotificationManager.IMPORTANCE_HIGH,
)
object BroadcastMessage :
NotificationType(
"my_broadcasts",
NotificationChannels.BROADCASTS,
Res.string.meshtastic_broadcast_notifications,
NotificationManager.IMPORTANCE_DEFAULT,
)
object Waypoint :
NotificationType(
"my_waypoints",
NotificationChannels.WAYPOINTS,
Res.string.meshtastic_waypoints_notifications,
NotificationManager.IMPORTANCE_DEFAULT,
)
object Alert :
NotificationType(
"my_alerts",
NotificationChannels.ALERTS,
Res.string.meshtastic_alerts_notifications,
NotificationManager.IMPORTANCE_HIGH,
)
object NewNode :
NotificationType(
"new_nodes",
NotificationChannels.NEW_NODES,
Res.string.meshtastic_new_nodes_notifications,
NotificationManager.IMPORTANCE_DEFAULT,
)
object LowBatteryLocal :
NotificationType(
"low_battery",
NotificationChannels.LOW_BATTERY,
Res.string.meshtastic_low_battery_notifications,
NotificationManager.IMPORTANCE_DEFAULT,
)
object LowBatteryRemote :
NotificationType(
"low_battery_remote",
NotificationChannels.LOW_BATTERY_REMOTE,
Res.string.meshtastic_low_battery_temporary_remote_notifications,
NotificationManager.IMPORTANCE_DEFAULT,
)
object Client :
NotificationType(
"client_notifications",
NotificationChannels.CLIENT,
Res.string.client_notification,
NotificationManager.IMPORTANCE_HIGH,
)
@@ -220,6 +220,7 @@ class MeshServiceNotificationsImpl(
* when the service is created.
*/
override fun initChannels() {
notificationManager.removeLegacyCategoryChannels()
NotificationType.allTypes().forEach { type -> createNotificationChannel(type) }
}

View File

@@ -0,0 +1,28 @@
/*
* 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.NotificationManager
/** One-time alpha cleanup: remove legacy enum-name category channels introduced before canonical IDs. */
internal fun NotificationManager.removeLegacyCategoryChannels() {
NotificationChannels.LEGACY_CATEGORY_IDS.forEach { legacyId ->
if (getNotificationChannel(legacyId) != null) {
deleteNotificationChannel(legacyId)
}
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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
internal object NotificationChannels {
const val SERVICE = "my_service"
const val MESSAGES = "my_messages"
const val BROADCASTS = "my_broadcasts"
const val WAYPOINTS = "my_waypoints"
const val ALERTS = "my_alerts"
const val NEW_NODES = "new_nodes"
const val LOW_BATTERY = "low_battery"
const val LOW_BATTERY_REMOTE = "low_battery_remote"
const val CLIENT = "client_notifications"
// Legacy enum-name channel IDs introduced by alpha channel routing.
val LEGACY_CATEGORY_IDS = listOf("Message", "NodeEvent", "Battery", "Alert", "Service")
}

View File

@@ -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.core.service
import android.content.Context
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationPrefs
import android.app.NotificationManager as SystemNotificationManager
class AndroidNotificationManagerTest {
private lateinit var context: Context
private lateinit var notificationManager: SystemNotificationManager
private lateinit var prefs: NotificationPrefs
private lateinit var androidNotificationManager: AndroidNotificationManager
private val messagesEnabled = MutableStateFlow(true)
private val nodeEventsEnabled = MutableStateFlow(true)
private val lowBatteryEnabled = MutableStateFlow(true)
@Before
fun setup() {
context = mock(MockMode.autofill)
notificationManager = mock(MockMode.autofill)
prefs = mock(MockMode.autofill)
every { prefs.messagesEnabled } returns this@AndroidNotificationManagerTest.messagesEnabled
every { prefs.nodeEventsEnabled } returns this@AndroidNotificationManagerTest.nodeEventsEnabled
every { prefs.lowBatteryEnabled } returns this@AndroidNotificationManagerTest.lowBatteryEnabled
every { context.getSystemService(Context.NOTIFICATION_SERVICE) } returns notificationManager
every { context.packageName } returns "org.meshtastic.test"
// Mocking initChannels to avoid getString calls during initialization for now if possible
// but it's called in init block.
androidNotificationManager = AndroidNotificationManager(context, prefs)
}
@Test
fun `dispatch notifies when enabled`() {
val notification = Notification("Title", "Message", category = Notification.Category.Message)
androidNotificationManager.dispatch(notification)
verify { notificationManager.notify(any(), any()) }
}
@Test
fun `dispatch does not notify when disabled`() {
messagesEnabled.value = false
val notification = Notification("Title", "Message", category = Notification.Category.Message)
androidNotificationManager.dispatch(notification)
verify(VerifyMode.exactly(0)) { notificationManager.notify(any(), any()) }
}
}

View File

@@ -1,71 +0,0 @@
/*
* Copyright (c) 2025-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.Application
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.ServiceRepository
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class)
class ServiceBroadcastsTest {
private lateinit var context: Context
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private lateinit var broadcasts: ServiceBroadcasts
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
broadcasts = ServiceBroadcasts(context, serviceRepository)
}
@Test
fun `broadcastConnection sends uppercase state string for ATAK`() {
every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Connected)
broadcasts.broadcastConnection()
val shadowApp = shadowOf(context as Application)
val intent = shadowApp.broadcastIntents.find { it.action == ACTION_MESH_CONNECTED }
assertEquals("CONNECTED", intent?.getStringExtra(EXTRA_CONNECTED))
}
@Test
fun `broadcastConnection sends legacy connection intent`() {
every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Connected)
broadcasts.broadcastConnection()
val shadowApp = shadowOf(context as Application)
val intent = shadowApp.broadcastIntents.find { it.action == ACTION_CONNECTION_CHANGED }
assertEquals("CONNECTED", intent?.getStringExtra(EXTRA_CONNECTED))
assertEquals(true, intent?.getBooleanExtra("connected", false))
}
}

View File

@@ -51,8 +51,10 @@ class FakeRadioController : RadioController {
val sentPackets = mutableListOf<DataPacket>()
val favoritedNodes = mutableListOf<Int>()
val sentSharedContacts = mutableListOf<Int>()
var throwOnSend: Boolean = false
override suspend fun sendMessage(packet: DataPacket) {
if (throwOnSend) error("Fake send failure")
sentPackets.add(packet)
}