From 6e50db0b91ec9967fbfd01494edfa21c5a898fcd Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:58:47 -0500 Subject: [PATCH] docs: Unify notification channel management and migrate unit tests (#4867) --- core/service/build.gradle.kts | 13 +- .../core/service/AndroidFileServiceTest.kt | 13 +- .../service/AndroidLocationServiceTest.kt | 23 ++- .../service/AndroidNotificationManagerTest.kt | 156 ++++++++++++++++++ .../MeshServiceNotificationsImplTest.kt | 111 +++++++++++++ .../core/service/SendMessageWorkerTest.kt | 60 +++++-- .../core/service/ServiceBroadcastsTest.kt | 135 +++++++++++++++ .../service/AndroidNotificationManager.kt | 64 +++---- .../service/MeshServiceNotificationsImpl.kt | 19 ++- .../service/NotificationChannelMigration.kt | 28 ++++ .../core/service/NotificationChannels.kt | 32 ++++ .../service/AndroidNotificationManagerTest.kt | 80 --------- .../core/service/ServiceBroadcastsTest.kt | 71 -------- .../core/testing/FakeRadioController.kt | 2 + 14 files changed, 586 insertions(+), 221 deletions(-) rename core/service/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt (74%) rename core/service/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/service/AndroidLocationServiceTest.kt (59%) create mode 100644 core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt create mode 100644 core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt rename core/service/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt (71%) create mode 100644 core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt create mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannelMigration.kt create mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannels.kt delete mode 100644 core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt delete mode 100644 core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts index 2b8afbe91..dbffac9af 100644 --- a/core/service/build.gradle.kts +++ b/core/service/build.gradle.kts @@ -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 { diff --git a/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt similarity index 74% rename from core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt rename to core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt index 546181bea..644b377e5 100644 --- a/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt @@ -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(MockMode.autofill) - val service = AndroidFileService(mockContext) + val context = RuntimeEnvironment.getApplication() + val service = AndroidFileService(context) assertNotNull(service) } } diff --git a/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidLocationServiceTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidLocationServiceTest.kt similarity index 59% rename from core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidLocationServiceTest.kt rename to core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidLocationServiceTest.kt index eb39b7697..5a9309aa5 100644 --- a/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidLocationServiceTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidLocationServiceTest.kt @@ -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(MockMode.autofill) - val mockRepo = mock(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() + } } diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt new file mode 100644 index 000000000..3c723a4b8 --- /dev/null +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt @@ -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 . + */ +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) } + } +} diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt new file mode 100644 index 000000000..878a6478a --- /dev/null +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt @@ -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 . + */ +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 { error("Not used in this test") }, + nodeRepository = lazy { 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) } + } +} diff --git a/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt similarity index 71% rename from core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt rename to core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt index 6c28ef5a4..efd9bd196 100644 --- a/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt @@ -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(context) @@ -120,14 +120,39 @@ class SendMessageWorkerTest { // Assert assertEquals(ListenableWorker.Result.retry(), result) - verifySuspend(mode = VerifyMode.exactly(0)) { radioController.sendMessage(any()) } + assertEquals(emptyList(), 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(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(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) } } } diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt new file mode 100644 index 000000000..9c68925e9 --- /dev/null +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt @@ -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 . + */ +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.Disconnected) + override val clientNotification = MutableStateFlow(null) + override val errorMessage = MutableStateFlow(null) + override val connectionProgress = MutableStateFlow(null) + private val meshPackets = MutableSharedFlow() + override val meshPacketFlow: SharedFlow = meshPackets + override val tracerouteResponse = MutableStateFlow(null) + override val neighborInfoResponse = MutableStateFlow(null) + private val serviceActions = MutableSharedFlow() + override val serviceAction: Flow = 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) + } + } +} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt index 8792315dd..f15190c8a 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt @@ -38,6 +38,8 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan private val notificationManager = context.getSystemService()!! + 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) diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt index ea17e4fc0..1277aa764 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt @@ -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) } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannelMigration.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannelMigration.kt new file mode 100644 index 000000000..c47afd20f --- /dev/null +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannelMigration.kt @@ -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 . + */ +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) + } + } +} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannels.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannels.kt new file mode 100644 index 000000000..f8db3a517 --- /dev/null +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannels.kt @@ -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 . + */ +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") +} diff --git a/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt b/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt deleted file mode 100644 index b22d0b572..000000000 --- a/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt +++ /dev/null @@ -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 . - */ -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()) } - } -} diff --git a/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt b/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt deleted file mode 100644 index ac977a5f8..000000000 --- a/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt +++ /dev/null @@ -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 . - */ -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)) - } -} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index 806f18af3..a8d6bf733 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt @@ -51,8 +51,10 @@ class FakeRadioController : RadioController { val sentPackets = mutableListOf() val favoritedNodes = mutableListOf() val sentSharedContacts = mutableListOf() + var throwOnSend: Boolean = false override suspend fun sendMessage(packet: DataPacket) { + if (throwOnSend) error("Fake send failure") sentPackets.add(packet) }