mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: settings rework part 2, domain and usecase abstraction, tests (#4680)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
5f31df96d8
commit
8c6bd8ab7a
121 changed files with 5245 additions and 1332 deletions
|
|
@ -1,87 +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.feature.messaging
|
||||
|
||||
/**
|
||||
* This util class allows you to optimize the binary size of the transmitted text message strings. It replaces certain
|
||||
* characters from national alphabets with the characters from the latin alphabet that have an identical appearance
|
||||
* (homoglyphs), for example: cyrillic "А", "С", "у" -> latin "A", "C", "y", etc. According to statistics, such letters
|
||||
* can make up about 20-25% of the total number of letters in the average text. Replacing them with Latin characters
|
||||
* reduces the binary size of the transmitted message. The average transmitted message volume can then fit around
|
||||
* ~140-145 characters instead of ~115-120
|
||||
*/
|
||||
internal object HomoglyphCharacterStringTransformer {
|
||||
|
||||
/**
|
||||
* Unicode characters from the basic cyrillic block (U+0400-U+04FF), each of which occupies 2 bytes
|
||||
* https://www.compart.com/en/unicode/block/U+0400 Mapped with the corresponding similarly written latin characters,
|
||||
* each of which occupies 1 byte
|
||||
*
|
||||
* Please note that only 100% "reliable", completely visually identical characters are presented will here The
|
||||
* characters that look like latin but contain various descenders, hooks, strokes, etc are not replaced with
|
||||
* "simplified" latin appearance and will remain 2 byte unicode, as usual
|
||||
*/
|
||||
private val homoglyphCharactersSubstitutionMapping: Map<Char, Char> =
|
||||
mapOf(
|
||||
'\u0405' to 'S', // https://www.compart.com/en/unicode/U+0405 - Cyrillic Capital Letter Dze
|
||||
'\u0406' to
|
||||
'I', // https://www.compart.com/en/unicode/U+0406 - Cyrillic Capital Letter Byelorussian-Ukrainian I
|
||||
'\u0408' to 'J', // https://www.compart.com/en/unicode/U+0408 - Cyrillic Capital Letter Je
|
||||
'\u0410' to 'A', // https://www.compart.com/en/unicode/U+0410 - Cyrillic Capital Letter A
|
||||
'\u0412' to 'B', // https://www.compart.com/en/unicode/U+0412 - Cyrillic Capital Letter Ve
|
||||
'\u0415' to 'E', // https://www.compart.com/en/unicode/U+0415 - Cyrillic Capital Letter Ie
|
||||
'\u041A' to 'K', // https://www.compart.com/en/unicode/U+041A - Cyrillic Capital Letter Ka
|
||||
'\u041C' to 'M', // https://www.compart.com/en/unicode/U+041C - Cyrillic Capital Letter Em
|
||||
'\u041D' to 'H', // https://www.compart.com/en/unicode/U+041D - Cyrillic Capital Letter En
|
||||
'\u041E' to 'O', // https://www.compart.com/en/unicode/U+041E - Cyrillic Capital Letter O
|
||||
'\u0420' to 'P', // https://www.compart.com/en/unicode/U+0420 - Cyrillic Capital Letter Er
|
||||
'\u0421' to 'C', // https://www.compart.com/en/unicode/U+0421 - Cyrillic Capital Letter Es
|
||||
'\u0422' to 'T', // https://www.compart.com/en/unicode/U+0422 - Cyrillic Capital Letter Te
|
||||
'\u0425' to 'X', // https://www.compart.com/en/unicode/U+0425 - Cyrillic Capital Letter Ha
|
||||
'\u0430' to 'a', // https://www.compart.com/en/unicode/U+0430 - Cyrillic Small Letter A
|
||||
'\u0435' to 'e', // https://www.compart.com/en/unicode/U+0435 - Cyrillic Small Letter Ie
|
||||
'\u043E' to 'o', // https://www.compart.com/en/unicode/U+043E - Cyrillic Small Letter O
|
||||
'\u0440' to 'p', // https://www.compart.com/en/unicode/U+0440 - Cyrillic Small Letter Er
|
||||
'\u0441' to 'c', // https://www.compart.com/en/unicode/U+0441 - Cyrillic Small Letter Es
|
||||
'\u0443' to 'y', // https://www.compart.com/en/unicode/U+0443 - Cyrillic Small Letter U
|
||||
'\u0445' to 'x', // https://www.compart.com/en/unicode/U+0445 - Cyrillic Small Letter Ha
|
||||
'\u0455' to 's', // https://www.compart.com/en/unicode/U+0455 - Cyrillic Small Letter Dze
|
||||
'\u0456' to
|
||||
'i', // https://www.compart.com/en/unicode/U+0456 - Cyrillic Small Letter Byelorussian-Ukrainian I
|
||||
'\u0458' to 'j', // https://www.compart.com/en/unicode/U+0458 - Cyrillic Small Letter Je
|
||||
'\u04AE' to 'Y', // https://www.compart.com/en/unicode/U+04AE - Cyrillic Capital Letter Straight U
|
||||
'\u0417' to '3', // https://www.compart.com/en/unicode/U+0417 - Cyrillic Capital Letter Ze
|
||||
// Note that capital "ze" here is a bit special - it technically transforms to a digit "three"
|
||||
// The visuals are all the same, across the different fonts etc& The core idea is the same:
|
||||
// We are still replacing 2-byte unicode letter with a digit character that occupies 1 byte in Unicode
|
||||
// But I have to point it out to avoid confusion
|
||||
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns the transformed optimized [String] value, in which some characters of the national alphabets are replaced
|
||||
* with identical Latin characters so that the text takes up fewer bytes and is more compact for transmission.
|
||||
*
|
||||
* @param value original string value.
|
||||
* @return optimized string value.
|
||||
*/
|
||||
fun optimizeUtf8StringWithHomoglyphs(value: String): String {
|
||||
val stringBuilder = StringBuilder()
|
||||
for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping.getOrDefault(c, c))
|
||||
return stringBuilder.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -100,6 +100,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer
|
||||
import org.meshtastic.core.database.entity.QuickChatAction
|
||||
import org.meshtastic.core.database.model.Message
|
||||
import org.meshtastic.core.database.model.Node
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import org.meshtastic.core.data.repository.RadioConfigRepository
|
|||
import org.meshtastic.core.database.entity.ContactSettings
|
||||
import org.meshtastic.core.database.model.Message
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.domain.usecase.SendMessageUseCase
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.prefs.emoji.CustomEmojiPrefs
|
||||
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
|
||||
|
|
@ -47,7 +48,6 @@ import org.meshtastic.core.service.MeshServiceNotifications
|
|||
import org.meshtastic.core.service.ServiceAction
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.feature.messaging.domain.usecase.SendMessageUseCase
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* 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.feature.messaging.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.meshtastic.core.domain.MessageQueue
|
||||
import org.meshtastic.feature.messaging.domain.worker.WorkManagerMessageQueue
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class MessagingModule {
|
||||
|
||||
@Binds abstract fun bindMessageQueue(impl: WorkManagerMessageQueue): MessageQueue
|
||||
}
|
||||
|
|
@ -1,103 +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.feature.messaging.domain.usecase
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.Capabilities
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
|
||||
import org.meshtastic.core.service.ServiceAction
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.feature.messaging.HomoglyphCharacterStringTransformer
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
class SendMessageUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val homoglyphEncodingPrefs: HomoglyphPrefs,
|
||||
) {
|
||||
|
||||
@Suppress("NestedBlockDepth", "LongMethod", "CyclomaticComplexMethod")
|
||||
suspend operator fun invoke(
|
||||
text: String,
|
||||
contactKey: String = "0${DataPacket.ID_BROADCAST}",
|
||||
replyId: Int? = null,
|
||||
) {
|
||||
val channel = contactKey[0].digitToIntOrNull()
|
||||
val dest = if (channel != null) contactKey.substring(1) else contactKey
|
||||
|
||||
val ourNode = nodeRepository.ourNodeInfo.value
|
||||
val fromId = ourNode?.user?.id ?: DataPacket.ID_LOCAL
|
||||
|
||||
// logic for direct messages
|
||||
if (channel == null) {
|
||||
val destNode = nodeRepository.getNode(dest)
|
||||
val fwVersion = ourNode?.metadata?.firmware_version
|
||||
val isClientBase = ourNode?.user?.role == Config.DeviceConfig.Role.CLIENT_BASE
|
||||
val capabilities = Capabilities(fwVersion)
|
||||
|
||||
if (capabilities.canSendVerifiedContacts) {
|
||||
sendSharedContact(destNode)
|
||||
} else {
|
||||
if (!destNode.isFavorite && !isClientBase) {
|
||||
favoriteNode(destNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply homoglyph encoding
|
||||
val finalMessageText =
|
||||
if (homoglyphEncodingPrefs.homoglyphEncodingEnabled) {
|
||||
HomoglyphCharacterStringTransformer.optimizeUtf8StringWithHomoglyphs(text)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
|
||||
val packet = DataPacket(dest, channel ?: 0, finalMessageText, replyId).apply { from = fromId }
|
||||
|
||||
try {
|
||||
serviceRepository.meshService?.send(packet)
|
||||
} catch (ex: Exception) {
|
||||
Logger.e(ex) { "Failed to send data packet" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun favoriteNode(node: Node) {
|
||||
try {
|
||||
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
|
||||
} catch (ex: Exception) {
|
||||
Logger.e(ex) { "Favorite node error" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendSharedContact(node: Node) {
|
||||
try {
|
||||
val contact =
|
||||
SharedContact(node_num = node.num, user = node.user, manually_verified = node.manuallyVerified)
|
||||
serviceRepository.onServiceAction(ServiceAction.SendContact(contact = contact))
|
||||
} catch (ex: Exception) {
|
||||
Logger.e(ex) { "Send shared contact error" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* 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.feature.messaging.domain.worker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.RadioController
|
||||
|
||||
@HiltWorker
|
||||
class SendMessageWorker
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@Assisted context: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val packetRepository: PacketRepository,
|
||||
private val radioController: RadioController,
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException", "ReturnCount")
|
||||
override suspend fun doWork(): Result {
|
||||
val packetId = inputData.getInt(KEY_PACKET_ID, 0)
|
||||
if (packetId == 0) return Result.failure()
|
||||
|
||||
// Verify we are connected before attempting to send to avoid unnecessary Exception bubbling
|
||||
if (radioController.connectionState.value != ConnectionState.Connected) {
|
||||
return Result.retry()
|
||||
}
|
||||
|
||||
val packetEntity =
|
||||
packetRepository.getPacketByPacketId(packetId)
|
||||
?: return Result.failure() // Packet no longer exists in DB? Do not retry.
|
||||
|
||||
val packetData = packetEntity.packet.data
|
||||
|
||||
return try {
|
||||
radioController.sendMessage(packetData)
|
||||
packetRepository.updateMessageStatus(packetData, MessageStatus.ENROUTE)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
packetRepository.updateMessageStatus(packetData, MessageStatus.ERROR)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_PACKET_ID = "packet_id"
|
||||
const val WORK_NAME_PREFIX = "send_message_"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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.feature.messaging.domain.worker
|
||||
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import org.meshtastic.core.domain.MessageQueue
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Android implementation of [MessageQueue] that uses [WorkManager] for reliable background transmission. */
|
||||
@Singleton
|
||||
class WorkManagerMessageQueue @Inject constructor(private val workManager: WorkManager) : MessageQueue {
|
||||
|
||||
override suspend fun enqueue(packetId: Int) {
|
||||
val workRequest =
|
||||
OneTimeWorkRequestBuilder<SendMessageWorker>()
|
||||
.setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniqueWork(
|
||||
"${SendMessageWorker.WORK_NAME_PREFIX}$packetId",
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
workRequest,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ package org.meshtastic.feature.messaging
|
|||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer
|
||||
|
||||
class HomoglyphCharacterTransformTest {
|
||||
|
||||
|
|
|
|||
|
|
@ -1,146 +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.feature.messaging.domain.usecase
|
||||
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkConstructor
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.Capabilities
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
|
||||
import org.meshtastic.core.service.ServiceAction
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
|
||||
class SendMessageUseCaseTest {
|
||||
|
||||
private lateinit var nodeRepository: NodeRepository
|
||||
private lateinit var serviceRepository: ServiceRepository
|
||||
private lateinit var homoglyphEncodingPrefs: HomoglyphPrefs
|
||||
private lateinit var useCase: SendMessageUseCase
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
nodeRepository = mockk(relaxed = true)
|
||||
serviceRepository = mockk(relaxed = true)
|
||||
homoglyphEncodingPrefs = mockk(relaxed = true)
|
||||
|
||||
useCase =
|
||||
SendMessageUseCase(
|
||||
nodeRepository = nodeRepository,
|
||||
serviceRepository = serviceRepository,
|
||||
homoglyphEncodingPrefs = homoglyphEncodingPrefs,
|
||||
)
|
||||
|
||||
mockkConstructor(Capabilities::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke with broadcast message simply sends data packet`() = runTest {
|
||||
// Arrange
|
||||
val ourNode = mockk<Node>(relaxed = true)
|
||||
every { ourNode.user.id } returns "!1234"
|
||||
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
|
||||
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
|
||||
|
||||
// Act
|
||||
useCase("Hello broadcast", "0${DataPacket.ID_BROADCAST}", null)
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 0) { serviceRepository.onServiceAction(any()) }
|
||||
coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke with direct message to older firmware triggers favoriteNode`() = runTest {
|
||||
// Arrange
|
||||
val ourNode = mockk<Node>(relaxed = true)
|
||||
val metadata = mockk<DeviceMetadata>(relaxed = true)
|
||||
every { ourNode.user.id } returns "!local"
|
||||
every { ourNode.user.role } returns Config.DeviceConfig.Role.CLIENT
|
||||
every { ourNode.metadata } returns metadata
|
||||
every { metadata.firmware_version } returns "2.0.0" // Older firmware
|
||||
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
|
||||
|
||||
val destNode = mockk<Node>(relaxed = true)
|
||||
every { destNode.isFavorite } returns false
|
||||
every { nodeRepository.getNode("!dest") } returns destNode
|
||||
|
||||
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
|
||||
|
||||
every { anyConstructed<Capabilities>().canSendVerifiedContacts } returns false
|
||||
|
||||
// Act
|
||||
useCase("Direct message", "!dest", null)
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { serviceRepository.onServiceAction(match { it is ServiceAction.Favorite }) }
|
||||
coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke with direct message to new firmware triggers sendSharedContact`() = runTest {
|
||||
// Arrange
|
||||
val ourNode = mockk<Node>(relaxed = true)
|
||||
val metadata = mockk<DeviceMetadata>(relaxed = true)
|
||||
every { ourNode.user.id } returns "!local"
|
||||
every { ourNode.user.role } returns Config.DeviceConfig.Role.CLIENT
|
||||
every { ourNode.metadata } returns metadata
|
||||
every { metadata.firmware_version } returns "2.7.12" // Newer firmware
|
||||
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
|
||||
|
||||
val destNode = mockk<Node>(relaxed = true)
|
||||
every { nodeRepository.getNode("!dest") } returns destNode
|
||||
|
||||
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
|
||||
|
||||
every { anyConstructed<Capabilities>().canSendVerifiedContacts } returns true
|
||||
|
||||
// Act
|
||||
useCase("Direct message", "!dest", null)
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { serviceRepository.onServiceAction(match { it is ServiceAction.SendContact }) }
|
||||
coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke with homoglyph enabled transforms text`() = runTest {
|
||||
// Arrange
|
||||
val ourNode = mockk<Node>(relaxed = true)
|
||||
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
|
||||
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns true
|
||||
|
||||
// Let's use a cyrillic character 'A' (U+0410) that will be mapped to Latin 'A'
|
||||
val originalText = "\u0410pple"
|
||||
|
||||
// Act
|
||||
useCase(originalText, "0${DataPacket.ID_BROADCAST}", null)
|
||||
|
||||
// Assert
|
||||
// We verify that send was called with the transformed text (Latin 'A'pple)
|
||||
coVerify(exactly = 1) { serviceRepository.meshService?.send(match { it.text?.contains("Apple") == true }) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* 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.feature.messaging.domain.worker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.workDataOf
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.database.entity.Packet
|
||||
import org.meshtastic.core.database.entity.PacketEntity
|
||||
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.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class SendMessageWorkerTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var packetRepository: PacketRepository
|
||||
private lateinit var radioController: RadioController
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
packetRepository = mockk(relaxed = true)
|
||||
radioController = mockk(relaxed = true)
|
||||
every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Connected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `doWork returns success when packet is sent successfully`() = runTest {
|
||||
// Arrange
|
||||
val packetId = 12345
|
||||
val dataPacket = DataPacket("dest", 0, "Hello")
|
||||
val packet = mockk<Packet>(relaxed = true)
|
||||
val packetEntity = PacketEntity(packet = packet)
|
||||
every { packet.data } returns dataPacket
|
||||
coEvery { packetRepository.getPacketByPacketId(packetId) } returns packetEntity
|
||||
every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Connected)
|
||||
coEvery { radioController.sendMessage(any()) } just Runs
|
||||
coEvery { packetRepository.updateMessageStatus(any(), any()) } just Runs
|
||||
|
||||
val worker =
|
||||
TestListenableWorkerBuilder<SendMessageWorker>(context)
|
||||
.setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
|
||||
.setWorkerFactory(
|
||||
object : androidx.work.WorkerFactory() {
|
||||
override fun createWorker(
|
||||
appContext: Context,
|
||||
workerClassName: String,
|
||||
workerParameters: WorkerParameters,
|
||||
): ListenableWorker? =
|
||||
SendMessageWorker(appContext, workerParameters, packetRepository, radioController)
|
||||
},
|
||||
)
|
||||
.build()
|
||||
|
||||
// Act
|
||||
val result = worker.doWork()
|
||||
|
||||
// Assert
|
||||
assertEquals(ListenableWorker.Result.success(), result)
|
||||
coVerify { radioController.sendMessage(dataPacket) }
|
||||
coVerify { packetRepository.updateMessageStatus(dataPacket, MessageStatus.ENROUTE) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `doWork returns retry when radio is disconnected`() = runTest {
|
||||
// Arrange
|
||||
val packetId = 12345
|
||||
val dataPacket = DataPacket("dest", 0, "Hello")
|
||||
val packet = mockk<Packet>(relaxed = true)
|
||||
val packetEntity = PacketEntity(packet = packet)
|
||||
every { packet.data } returns dataPacket
|
||||
coEvery { packetRepository.getPacketByPacketId(packetId) } returns packetEntity
|
||||
every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Disconnected)
|
||||
|
||||
val worker =
|
||||
TestListenableWorkerBuilder<SendMessageWorker>(context)
|
||||
.setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
|
||||
.setWorkerFactory(
|
||||
object : androidx.work.WorkerFactory() {
|
||||
override fun createWorker(
|
||||
appContext: Context,
|
||||
workerClassName: String,
|
||||
workerParameters: WorkerParameters,
|
||||
): ListenableWorker? =
|
||||
SendMessageWorker(appContext, workerParameters, packetRepository, radioController)
|
||||
},
|
||||
)
|
||||
.build()
|
||||
|
||||
// Act
|
||||
val result = worker.doWork()
|
||||
|
||||
// Assert
|
||||
assertEquals(ListenableWorker.Result.retry(), result)
|
||||
coVerify(exactly = 0) { radioController.sendMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `doWork returns failure when packet is missing`() = runTest {
|
||||
// Arrange
|
||||
val packetId = 999
|
||||
coEvery { packetRepository.getPacketByPacketId(packetId) } returns null
|
||||
|
||||
val worker =
|
||||
TestListenableWorkerBuilder<SendMessageWorker>(context)
|
||||
.setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
|
||||
.setWorkerFactory(
|
||||
object : androidx.work.WorkerFactory() {
|
||||
override fun createWorker(
|
||||
appContext: Context,
|
||||
workerClassName: String,
|
||||
workerParameters: WorkerParameters,
|
||||
): ListenableWorker? =
|
||||
SendMessageWorker(appContext, workerParameters, packetRepository, radioController)
|
||||
},
|
||||
)
|
||||
.build()
|
||||
|
||||
// Act
|
||||
val result = worker.doWork()
|
||||
|
||||
// Assert
|
||||
assertEquals(ListenableWorker.Result.failure(), result)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue