mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 02:01:35 -04:00
docs: Unify notification channel management and migrate unit tests (#4867)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>()
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()) }
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user