feat: Migrate project to Kotlin Multiplatform (KMP) architecture (#4738)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-06 20:43:45 -06:00 committed by GitHub
parent 182ad933f4
commit 0ce322a0f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
163 changed files with 1837 additions and 877 deletions

View file

@ -95,7 +95,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.collectAsLazyPagingItems
import kotlinx.coroutines.CoroutineScope
@ -161,7 +160,7 @@ private const val ROUNDED_CORNER_PERCENT = 100
fun MessageScreen(
contactKey: String,
message: String,
viewModel: MessageViewModel = hiltViewModel(),
viewModel: MessageViewModel,
navigateToNodeDetails: (Int) -> Unit,
navigateToQuickChatOptions: () -> Unit,
onNavigateBack: () -> Unit,

View file

@ -61,7 +61,6 @@ import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.QuickChatAction
@ -85,11 +84,7 @@ import org.meshtastic.core.ui.component.rememberDragDropState
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun QuickChatScreen(
modifier: Modifier = Modifier,
viewModel: QuickChatViewModel = hiltViewModel(),
onNavigateUp: () -> Unit,
) {
fun QuickChatScreen(modifier: Modifier = Modifier, viewModel: QuickChatViewModel, onNavigateUp: () -> Unit) {
val actions by viewModel.quickChatActions.collectAsStateWithLifecycle()
var showActionDialog by remember { mutableStateOf<QuickChatAction?>(null) }

View file

@ -64,6 +64,8 @@ import org.meshtastic.proto.SharedContact
@Composable
fun AdaptiveContactsScreen(
navController: NavHostController,
contactsViewModel: org.meshtastic.feature.messaging.ui.contact.ContactsViewModel,
messageViewModel: org.meshtastic.feature.messaging.MessageViewModel,
scrollToTopEvents: Flow<ScrollToTopEvent>,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
@ -138,6 +140,7 @@ fun AdaptiveContactsScreen(
onHandleScannedUri = onHandleScannedUri,
onClearSharedContactRequested = onClearSharedContactRequested,
onClearRequestChannelUrl = onClearRequestChannelUrl,
viewModel = contactsViewModel,
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true
@ -160,6 +163,7 @@ fun AdaptiveContactsScreen(
MessageScreen(
contactKey = contactKey,
message = if (contactKey == initialContactKey) initialMessage else "",
viewModel = messageViewModel,
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
navigateToQuickChatOptions = { navController.navigate(ContactsRoutes.QuickChat) },
onNavigateBack = handleBack,

View file

@ -53,7 +53,6 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
@ -121,7 +120,7 @@ fun ContactsScreen(
onHandleScannedUri: (Uri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
viewModel: ContactsViewModel = hiltViewModel<ContactsViewModel>(),
viewModel: ContactsViewModel,
onClickNodeChip: (Int) -> Unit = {},
onNavigateToMessages: (String) -> Unit = {},
onNavigateToNodeDetails: (Int) -> Unit = {},

View file

@ -36,7 +36,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Contact
@ -52,7 +51,7 @@ import org.meshtastic.feature.messaging.ui.contact.ContactItem
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
@Composable
fun ShareScreen(viewModel: ContactsViewModel = hiltViewModel(), onConfirm: (String) -> Unit, onNavigateUp: () -> Unit) {
fun ShareScreen(viewModel: ContactsViewModel, onConfirm: (String) -> Unit, onNavigateUp: () -> Unit) {
val contactList by viewModel.contactList.collectAsStateWithLifecycle()
ShareScreen(contacts = contactList, onConfirm = onConfirm, onNavigateUp = onNavigateUp)

View file

@ -21,7 +21,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@ -49,13 +48,9 @@ import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.repository.usecase.SendMessageUseCase
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.ChannelSet
import javax.inject.Inject
@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class MessageViewModel
@Inject
constructor(
open class MessageViewModel(
savedStateHandle: SavedStateHandle,
private val nodeRepository: NodeRepository,
radioConfigRepository: RadioConfigRepository,

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,22 +14,17 @@
* 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
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.QuickChatActionRepository
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import javax.inject.Inject
@HiltViewModel
class QuickChatViewModel @Inject constructor(private val quickChatActionRepository: QuickChatActionRepository) :
ViewModel() {
open class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionRepository) : ViewModel() {
val quickChatActions
get() = quickChatActionRepository.getAllActions().stateInWhileSubscribed(initialValue = emptyList())

View file

@ -21,7 +21,6 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@ -39,13 +38,9 @@ import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.ChannelSet
import javax.inject.Inject
import kotlin.collections.map as collectionsMap
@HiltViewModel
class ContactsViewModel
@Inject
constructor(
open class ContactsViewModel(
private val nodeRepository: NodeRepository,
private val packetRepository: PacketRepository,
radioConfigRepository: RadioConfigRepository,

View file

@ -1,31 +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.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.feature.messaging.domain.worker.WorkManagerMessageQueue
@Module
@InstallIn(SingletonComponent::class)
abstract class MessagingModule {
@Binds abstract fun bindMessageQueue(impl: WorkManagerMessageQueue): MessageQueue
}

View file

@ -1,68 +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.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.model.ConnectionState
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.PacketRepository
@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 packetData =
packetRepository.getPacketByPacketId(packetId)
?: return Result.failure() // Packet no longer exists in DB? Do not retry.
return try {
radioController.sendMessage(packetData)
packetRepository.updateMessageStatus(packetData, MessageStatus.ENROUTE)
Result.success()
} catch (e: Exception) {
packetRepository.updateMessageStatus(packetData, MessageStatus.QUEUED)
Result.retry()
}
}
companion object {
const val KEY_PACKET_ID = "packet_id"
const val WORK_NAME_PREFIX = "send_message_"
}
}

View file

@ -1,43 +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.domain.worker
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import org.meshtastic.core.repository.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,
)
}
}

View file

@ -1,152 +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.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 okio.ByteString.Companion.toByteString
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.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.PacketRepository
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(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
coEvery { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
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(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
coEvery { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
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)
}
}