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

@ -40,6 +40,7 @@ import androidx.compose.runtime.getValue
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
@ -47,14 +48,23 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.compose.LocalEnvironmentOwner
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.intro.AndroidIntroViewModel
import org.meshtastic.app.map.getMapViewProvider
import org.meshtastic.app.model.UIViewModel
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.intro.AppIntroductionScreen
import javax.inject.Inject
@ -108,7 +118,13 @@ class MainActivity : ComponentActivity() {
}
@Suppress("SpreadOperator")
CompositionLocalProvider(*(LocalEnvironmentOwner provides androidEnvironment)) {
CompositionLocalProvider(
*(LocalEnvironmentOwner provides androidEnvironment),
LocalBarcodeScannerProvider provides { onResult -> rememberBarcodeScanner(onResult) },
LocalNfcScannerProvider provides { onResult, onDisabled -> NfcScannerEffect(onResult, onDisabled) },
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(),
) {
AppTheme(dynamicColor = dynamic, darkTheme = dark) {
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
@ -119,7 +135,8 @@ class MainActivity : ComponentActivity() {
if (appIntroCompleted) {
MainScreen(uIViewModel = model)
} else {
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() })
val introViewModel = hiltViewModel<AndroidIntroViewModel>()
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() }, viewModel = introViewModel)
}
}
}

View file

@ -0,0 +1,75 @@
/*
* 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.app.di
import android.content.Context
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.native
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.NativeAndroidEnvironment
import org.meshtastic.core.ble.AndroidBleConnectionFactory
import org.meshtastic.core.ble.AndroidBleScanner
import org.meshtastic.core.ble.AndroidBluetoothRepository
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.di.CoroutineDispatchers
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class BleModule {
@Binds @Singleton
abstract fun bindBleScanner(impl: AndroidBleScanner): BleScanner
@Binds @Singleton
abstract fun bindBluetoothRepository(impl: AndroidBluetoothRepository): BluetoothRepository
@Binds @Singleton
abstract fun bindBleConnectionFactory(impl: AndroidBleConnectionFactory): BleConnectionFactory
companion object {
@Provides
@Singleton
fun provideAndroidEnvironment(@ApplicationContext context: Context): AndroidEnvironment =
NativeAndroidEnvironment.getInstance(context, isNeverForLocationFlagSet = true)
@Provides
@Singleton
fun provideBleSingletonCoroutineScope(dispatchers: CoroutineDispatchers): CoroutineScope =
CoroutineScope(SupervisorJob() + dispatchers.default)
@Provides
@Singleton
fun provideCentralManager(environment: AndroidEnvironment, coroutineScope: CoroutineScope): CentralManager =
CentralManager.native(environment as NativeAndroidEnvironment, coroutineScope)
@Provides
fun provideBleConnection(factory: BleConnectionFactory, coroutineScope: CoroutineScope): BleConnection =
factory.create(coroutineScope, "BLE")
}
}

View file

@ -0,0 +1,38 @@
/*
* 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.app.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.service.AndroidRadioControllerImpl
import org.meshtastic.core.service.AndroidServiceRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class ServiceModule {
@Binds @Singleton
abstract fun bindRadioController(impl: AndroidRadioControllerImpl): RadioController
@Binds @Singleton
abstract fun bindServiceRepository(impl: AndroidServiceRepository): ServiceRepository
}

View file

@ -129,7 +129,7 @@ constructor(
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
db.values.find { node ->
val suffix = entry.peripheral.getMeshtasticShortName()?.lowercase(Locale.ROOT)
val suffix = entry.device.getMeshtasticShortName()?.lowercase(Locale.ROOT)
suffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {

View file

@ -0,0 +1,24 @@
/*
* 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.app.intro
import dagger.hilt.android.lifecycle.HiltViewModel
import org.meshtastic.feature.intro.IntroViewModel
import javax.inject.Inject
/** Android-specific Hilt wrapper for IntroViewModel. */
@HiltViewModel class AndroidIntroViewModel @Inject constructor() : IntroViewModel()

View file

@ -0,0 +1,35 @@
/*
* 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.app.map
import dagger.hilt.android.lifecycle.HiltViewModel
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.feature.map.SharedMapViewModel
import javax.inject.Inject
@HiltViewModel
class AndroidSharedMapViewModel
@Inject
constructor(
mapPrefs: MapPrefs,
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioController: RadioController,
) : SharedMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController)

View file

@ -0,0 +1,84 @@
/*
* 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.app.map.node
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.navigation.toRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.toList
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import javax.inject.Inject
@HiltViewModel
class NodeMapViewModel
@Inject
constructor(
savedStateHandle: SavedStateHandle,
nodeRepository: NodeRepository,
meshLogRepository: MeshLogRepository,
buildConfigProvider: BuildConfigProvider,
private val mapPrefs: MapPrefs,
) : ViewModel() {
private val destNum = savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum
val node =
nodeRepository.nodeDBbyNum
.mapLatest { it[destNum] }
.distinctUntilChanged()
.stateInWhileSubscribed(initialValue = null)
val applicationId = buildConfigProvider.applicationId
private val ourNodeNumFlow = nodeRepository.myNodeInfo.map { it?.myNodeNum }.distinctUntilChanged()
val positionLogs: StateFlow<List<Position>> =
ourNodeNumFlow
.map { if (destNum == it) MeshLog.NODE_NUM_LOCAL else destNum!! }
.distinctUntilChanged()
.flatMapLatest { logId ->
meshLogRepository.getMeshPacketsFrom(logId, PortNum.POSITION_APP.value).map { packets ->
packets
.mapNotNull { it.toPosition() }
.asFlow()
.distinctUntilChanged { old, new ->
old.time == new.time ||
(old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i)
}
.toList()
}
}
.stateInWhileSubscribed(initialValue = emptyList())
val mapStyleId: Int
get() = mapPrefs.mapStyle.value
}

View file

@ -0,0 +1,35 @@
/*
* 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.app.messaging
import dagger.hilt.android.lifecycle.HiltViewModel
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
import javax.inject.Inject
@HiltViewModel
class AndroidContactsViewModel
@Inject
constructor(
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioConfigRepository: RadioConfigRepository,
serviceRepository: ServiceRepository,
) : ContactsViewModel(nodeRepository, packetRepository, radioConfigRepository, serviceRepository)

View file

@ -0,0 +1,62 @@
/*
* 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.app.messaging
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import org.meshtastic.core.data.repository.QuickChatActionRepository
import org.meshtastic.core.repository.CustomEmojiPrefs
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.repository.usecase.SendMessageUseCase
import org.meshtastic.feature.messaging.MessageViewModel
import javax.inject.Inject
@Suppress("LongParameterList")
@HiltViewModel
class AndroidMessageViewModel
@Inject
constructor(
savedStateHandle: SavedStateHandle,
nodeRepository: NodeRepository,
radioConfigRepository: RadioConfigRepository,
quickChatActionRepository: QuickChatActionRepository,
serviceRepository: ServiceRepository,
packetRepository: PacketRepository,
uiPrefs: UiPrefs,
customEmojiPrefs: CustomEmojiPrefs,
homoglyphEncodingPrefs: HomoglyphPrefs,
meshServiceNotifications: MeshServiceNotifications,
sendMessageUseCase: SendMessageUseCase,
) : MessageViewModel(
savedStateHandle,
nodeRepository,
radioConfigRepository,
quickChatActionRepository,
serviceRepository,
packetRepository,
uiPrefs,
customEmojiPrefs,
homoglyphEncodingPrefs,
meshServiceNotifications,
sendMessageUseCase,
)

View file

@ -0,0 +1,26 @@
/*
* 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.app.messaging
import dagger.hilt.android.lifecycle.HiltViewModel
import org.meshtastic.core.data.repository.QuickChatActionRepository
import org.meshtastic.feature.messaging.QuickChatViewModel
import javax.inject.Inject
@HiltViewModel
class AndroidQuickChatViewModel @Inject constructor(quickChatActionRepository: QuickChatActionRepository) :
QuickChatViewModel(quickChatActionRepository)

View file

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

View file

@ -0,0 +1,68 @@
/*
* 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.app.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

@ -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.app.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

@ -18,8 +18,7 @@ package org.meshtastic.app.model
import android.hardware.usb.UsbManager
import com.hoho.android.usbserial.driver.UsbSerialDriver
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.BondState
import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.model.Node
@ -50,15 +49,14 @@ sealed class DeviceListEntry(
override fun toString(): String =
"DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded, hasNode=${node != null})"
@Suppress("MissingPermission")
data class Ble(val peripheral: Peripheral, override val node: Node? = null) :
data class Ble(val device: BleDevice, override val node: Node? = null) :
DeviceListEntry(
name = peripheral.name ?: "unnamed-${peripheral.address}",
fullAddress = "x${peripheral.address}",
bonded = peripheral.bondState.value == BondState.BONDED,
name = device.name ?: "unnamed-${device.address}",
fullAddress = "x${device.address}",
bonded = device.isBonded,
node = node,
) {
override fun copy(node: Node?): Ble = copy(peripheral = peripheral, node = node)
override fun copy(node: Node?): Ble = copy(device = device, node = node)
}
data class Usb(
@ -95,4 +93,4 @@ private val bleNameRegex = Regex(BLE_NAME_PATTERN)
*
* @return The short name (e.g., 1234) or null.
*/
fun Peripheral.getMeshtasticShortName(): String? = name?.let { bleNameRegex.find(it)?.groupValues?.get(1) }
fun BleDevice.getMeshtasticShortName(): String? = name?.let { bleNameRegex.find(it)?.groupValues?.get(1) }

View file

@ -26,6 +26,9 @@ import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import androidx.navigation.toRoute
import kotlinx.coroutines.flow.Flow
import org.meshtastic.app.messaging.AndroidContactsViewModel
import org.meshtastic.app.messaging.AndroidMessageViewModel
import org.meshtastic.app.messaging.AndroidQuickChatViewModel
import org.meshtastic.app.model.UIViewModel
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
@ -43,9 +46,13 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
val uiViewModel: UIViewModel = hiltViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = hiltViewModel<AndroidContactsViewModel>()
val messageViewModel = hiltViewModel<AndroidMessageViewModel>()
AdaptiveContactsScreen(
navController = navController,
contactsViewModel = contactsViewModel,
messageViewModel = messageViewModel,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
@ -67,9 +74,13 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
val uiViewModel: UIViewModel = hiltViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = hiltViewModel<AndroidContactsViewModel>()
val messageViewModel = hiltViewModel<AndroidMessageViewModel>()
AdaptiveContactsScreen(
navController = navController,
contactsViewModel = contactsViewModel,
messageViewModel = messageViewModel,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
@ -90,7 +101,9 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
),
) { backStackEntry ->
val message = backStackEntry.toRoute<ContactsRoutes.Share>().message
val viewModel = hiltViewModel<AndroidContactsViewModel>()
ShareScreen(
viewModel = viewModel,
onConfirm = {
navController.navigate(ContactsRoutes.Messages(it, message)) {
popUpTo<ContactsRoutes.Share> { inclusive = true }
@ -102,6 +115,7 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
composable<ContactsRoutes.QuickChat>(
deepLinks = listOf(navDeepLink<ContactsRoutes.QuickChat>(basePath = "$DEEP_LINK_BASE_URI/quick_chat")),
) {
QuickChatScreen(onNavigateUp = navController::navigateUp)
val viewModel = hiltViewModel<AndroidQuickChatViewModel>()
QuickChatScreen(viewModel = viewModel, onNavigateUp = navController::navigateUp)
}
}

View file

@ -16,10 +16,12 @@
*/
package org.meshtastic.app.navigation
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import org.meshtastic.app.map.AndroidSharedMapViewModel
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.navigation.NodesRoutes
@ -27,7 +29,9 @@ import org.meshtastic.feature.map.MapScreen
fun NavGraphBuilder.mapGraph(navController: NavHostController) {
composable<MapRoutes.Map>(deepLinks = listOf(navDeepLink<MapRoutes.Map>(basePath = "$DEEP_LINK_BASE_URI/map"))) {
val viewModel = hiltViewModel<AndroidSharedMapViewModel>()
MapScreen(
viewModel = viewModel,
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true

View file

@ -40,6 +40,8 @@ import androidx.navigation.navDeepLink
import androidx.navigation.toRoute
import kotlinx.coroutines.flow.Flow
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.app.map.node.NodeMapScreen
import org.meshtastic.app.map.node.NodeMapViewModel
import org.meshtastic.app.ui.node.AdaptiveNodeListScreen
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
@ -57,8 +59,6 @@ import org.meshtastic.core.resources.power
import org.meshtastic.core.resources.signal
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.map.node.NodeMapScreen
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen

View file

@ -33,12 +33,15 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.WriteType
import org.meshtastic.core.ble.AndroidBleDevice
import org.meshtastic.core.ble.AndroidBleService
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleConnectionState
import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.ble.retryBleOperation
import org.meshtastic.core.common.util.nowMillis
@ -62,7 +65,9 @@ private val SCAN_TIMEOUT = 5.seconds
* - Routing raw byte packets between the radio and [RadioInterfaceService].
*
* @param serviceScope The coroutine scope to use for launching coroutines.
* @param centralManager The central manager provided by Nordic BLE Library.
* @param scanner The BLE scanner.
* @param bluetoothRepository The Bluetooth repository.
* @param connectionFactory The BLE connection factory.
* @param service The [RadioInterfaceService] to use for handling radio events.
* @param address The BLE address of the device to connect to.
*/
@ -71,7 +76,9 @@ class NordicBleInterface
@AssistedInject
constructor(
private val serviceScope: CoroutineScope,
private val centralManager: CentralManager,
private val scanner: BleScanner,
private val bluetoothRepository: BluetoothRepository,
private val connectionFactory: BleConnectionFactory,
private val service: RadioInterfaceService,
@Assisted val address: String,
) : IRadioInterface {
@ -91,7 +98,7 @@ constructor(
private val connectionScope: CoroutineScope =
CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
private val bleConnection: BleConnection = BleConnection(centralManager, connectionScope, address)
private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
private val writeMutex: Mutex = Mutex()
private var connectionStartTime: Long = 0
@ -106,21 +113,19 @@ constructor(
// --- Connection & Discovery Logic ---
/** Robustly finds the peripheral. First checks bonded devices, then performs a short scan if not found. */
private suspend fun findPeripheral(): Peripheral {
centralManager
.getBondedPeripherals()
/** Robustly finds the device. First checks bonded devices, then performs a short scan if not found. */
private suspend fun findDevice(): BleDevice {
bluetoothRepository.state.value.bondedDevices
.firstOrNull { it.address == address }
?.let {
return it
}
Logger.i { "[$address] Device not found in bonded list, scanning..." }
val scanner = BleScanner(centralManager)
repeat(SCAN_RETRY_COUNT) { attempt ->
val p = scanner.scan(SCAN_TIMEOUT).firstOrNull { it.address == address }
if (p != null) return p
val d = scanner.scan(SCAN_TIMEOUT).firstOrNull { it.address == address }
if (d != null) return d
if (attempt < SCAN_RETRY_COUNT - 1) {
delay(SCAN_RETRY_DELAY_MS)
@ -138,7 +143,7 @@ constructor(
bleConnection.connectionState
.onEach { state ->
if (state is ConnectionState.Disconnected) {
if (state is BleConnectionState.Disconnected) {
onDisconnected(state)
}
}
@ -148,9 +153,9 @@ constructor(
}
.launchIn(connectionScope)
val p = findPeripheral()
val state = bleConnection.connectAndAwait(p, CONNECTION_TIMEOUT_MS)
if (state !is ConnectionState.Connected) {
val device = findDevice()
val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
if (state !is BleConnectionState.Connected) {
throw RadioNotConnectedException("Failed to connect to device at address $address")
}
@ -158,7 +163,7 @@ constructor(
discoverServicesAndSetupCharacteristics()
} catch (e: Exception) {
val failureTime = nowMillis - connectionStartTime
Logger.w(e) { "[$address] Failed to connect to peripheral after ${failureTime}ms" }
Logger.w(e) { "[$address] Failed to connect to device after ${failureTime}ms" }
handleFailure(e)
}
}
@ -166,8 +171,9 @@ constructor(
private suspend fun onConnected() {
try {
bleConnection.peripheralFlow.first()?.let { p ->
val rssi = retryBleOperation(tag = address) { p.readRssi() }
bleConnection.deviceFlow.first()?.let { device ->
val androidDevice = device as AndroidBleDevice
val rssi = retryBleOperation(tag = address) { androidDevice.peripheral.readRssi() }
Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
}
} catch (e: Exception) {
@ -175,7 +181,7 @@ constructor(
}
}
private fun onDisconnected(state: ConnectionState.Disconnected) {
private fun onDisconnected(@Suppress("UNUSED_PARAMETER") state: BleConnectionState.Disconnected) {
radioService = null
val uptime =
@ -185,26 +191,22 @@ constructor(
0
}
Logger.w {
"[$address] BLE disconnected - Reason: ${state.reason}, " +
"[$address] BLE disconnected, " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
val (isPermanent, msg) =
when (val reason = state.reason) {
is ConnectionState.Disconnected.Reason.InsufficientAuthentication ->
Pair(true, "Insufficient authentication: please unpair and repair the device")
is ConnectionState.Disconnected.Reason.RequiredServiceNotFound ->
Pair(false, "Required characteristic missing")
else -> Pair(false, reason.toString())
}
service.onDisconnect(isPermanent, errorMessage = msg)
// Note: Disconnected state in commonMain doesn't currently carry a reason.
// We might want to add that later if needed.
service.onDisconnect(false, errorMessage = "Disconnected")
}
private suspend fun discoverServicesAndSetupCharacteristics() {
try {
bleConnection.profile(serviceUuid = SERVICE_UUID) { service ->
val radioService = MeshtasticRadioServiceImpl(service)
val androidService = (service as AndroidBleService).service
val radioService = MeshtasticRadioServiceImpl(androidService)
// Wire up notifications
radioService.fromRadio
@ -235,7 +237,7 @@ constructor(
Logger.i { "[$address] Profile service active and characteristics subscribed" }
// Log negotiated MTU for diagnostics
val maxLen = bleConnection.maximumWriteValueLength(WriteType.WITHOUT_RESPONSE)
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
this@NordicBleInterface.service.onConnect()

View file

@ -20,8 +20,8 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import org.meshtastic.app.messaging.domain.worker.SendMessageWorker
import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.feature.messaging.domain.worker.SendMessageWorker
import javax.inject.Inject
import javax.inject.Singleton

View file

@ -117,15 +117,15 @@ constructor(
/** Initiates the bonding process and connects to the device upon success. */
private fun requestBonding(entry: DeviceListEntry.Ble) {
Logger.i { "Starting bonding for ${entry.peripheral.address.anonymize}" }
Logger.i { "Starting bonding for ${entry.device.address.anonymize}" }
viewModelScope.launch {
@Suppress("TooGenericExceptionCaught")
try {
bluetoothRepository.bond(entry.peripheral)
Logger.i { "Bonding complete for ${entry.peripheral.address.anonymize}, selecting device..." }
bluetoothRepository.bond(entry.device)
Logger.i { "Bonding complete for ${entry.device.address.anonymize}, selecting device..." }
changeDeviceAddress(entry.fullAddress)
} catch (ex: SecurityException) {
Logger.w(ex) { "Bonding failed for ${entry.peripheral.address.anonymize} Permissions not granted" }
Logger.w(ex) { "Bonding failed for ${entry.device.address.anonymize} Permissions not granted" }
serviceRepository.setErrorMessage(
text = "Bonding failed: ${ex.message} Permissions not granted",
severity = Severity.Warn,
@ -135,9 +135,9 @@ constructor(
val message = ex.message ?: ""
if (message.contains("Received bond state changed 11")) {
// This is a known issue where bonding is still in progress, ignore as error
Logger.d { "Bonding still in progress for ${entry.peripheral.address.anonymize}" }
Logger.d { "Bonding still in progress for ${entry.device.address.anonymize}" }
} else {
Logger.w(ex) { "Bonding failed for ${entry.peripheral.address.anonymize}" }
Logger.w(ex) { "Bonding failed for ${entry.device.address.anonymize}" }
serviceRepository.setErrorMessage(text = "Bonding failed: ${ex.message}", severity = Severity.Warn)
}
}

View file

@ -35,6 +35,7 @@ import no.nordicsemi.android.common.scanner.view.ScannerView
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.model.DeviceListEntry
import org.meshtastic.app.ui.connections.ScannerViewModel
import org.meshtastic.core.ble.AndroidBleDevice
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.model.ConnectionState
@ -73,12 +74,14 @@ fun BLEDevices(connectionState: ConnectionState, selectedDevice: String, scanMod
ScannerView(
state = filterState,
onScanResultSelected = { result -> scanModel.onSelected(DeviceListEntry.Ble(result.peripheral)) },
onScanResultSelected = { result ->
scanModel.onSelected(DeviceListEntry.Ble(AndroidBleDevice(result.peripheral)))
},
deviceItem = { result ->
val device =
remember(result.peripheral.address, bleDevices) {
bleDevices.find { it.fullAddress == "x${result.peripheral.address}" }
?: DeviceListEntry.Ble(result.peripheral)
?: DeviceListEntry.Ble(AndroidBleDevice(result.peripheral))
}
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),

View file

@ -43,8 +43,6 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import no.nordicsemi.android.common.ui.view.RssiIcon
import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException
import no.nordicsemi.kotlin.ble.client.exception.PeripheralNotConnectedException
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.model.DeviceListEntry
import org.meshtastic.core.model.Node
@ -75,23 +73,14 @@ fun CurrentlyConnectedInfo(
var rssi by remember { mutableIntStateOf(0) }
LaunchedEffect(bleDevice) {
if (bleDevice != null) {
while (bleDevice.peripheral.isConnected) {
while (bleDevice.device.isConnected) {
try {
rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.peripheral.readRssi() }
rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.device.readRssi() }
delay(RSSI_DELAY.seconds)
} catch (e: PeripheralNotConnectedException) {
Logger.w(e) { "Failed to read RSSI ${e.message}" }
break
} catch (e: OperationFailedException) {
} catch (e: Exception) {
// RSSI reading failures are common when disconnecting; log as warning to avoid Crashlytics noise
Logger.w(e) { "Failed to read RSSI ${e.message}" }
break
} catch (e: SecurityException) {
Logger.w(e) { "Failed to read RSSI ${e.message}" }
break
} catch (e: Exception) {
Logger.w(e) { "Unexpected error reading RSSI: ${e.message}" }
break
}
}
}