From b87c9528faa4bb653e710ad2b7beac92085091e3 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 13 Apr 2026 15:28:09 -0500 Subject: [PATCH] refactor(viewmodels): adopt safeLaunch across 13 ViewModels Replace raw viewModelScope.launch with safeLaunch() context receiver across all feature ViewModels for consistent error handling: - MessageViewModel, QuickChatViewModel, ContactsViewModel (messaging) - MetricsViewModel, CompassViewModel (node) - SettingsViewModel, RadioConfigViewModel, CleanNodeDatabaseViewModel, DebugViewModel, ChannelViewModel (settings) - BaseMapViewModel (map) - ScannerViewModel (connections) - ScannedQrCodeViewModel (core/ui) Add optional dispatcher parameter to safeLaunch() for ViewModels that need ioDispatcher (e.g. database operations in MessageViewModel). RadioConfigViewModel: only import/export/install methods converted; ResponseState config request flows left as-is due to specialized progress tracking requirements. --- .../core/ui/qr/ScannedQrCodeViewModel.kt | 9 ++--- .../core/ui/viewmodel/ViewModelExtensions.kt | 23 +++++++----- .../feature/connections/ScannerViewModel.kt | 7 ++-- .../feature/map/BaseMapViewModel.kt | 8 ++-- .../feature/messaging/MessageViewModel.kt | 18 +++++---- .../feature/messaging/QuickChatViewModel.kt | 11 +++--- .../messaging/ui/contact/ContactsViewModel.kt | 13 ++++--- .../feature/node/compass/CompassViewModel.kt | 19 ++++++---- .../feature/node/metrics/MetricsViewModel.kt | 15 ++++---- .../feature/settings/SettingsViewModel.kt | 11 +++--- .../settings/channel/ChannelViewModel.kt | 9 ++--- .../settings/debugging/DebugViewModel.kt | 15 ++++---- .../radio/CleanNodeDatabaseViewModel.kt | 9 ++--- .../settings/radio/RadioConfigViewModel.kt | 37 +++++++------------ 14 files changed, 104 insertions(+), 100 deletions(-) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt index 2c10206aa..db23f1d77 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt @@ -17,12 +17,11 @@ package org.meshtastic.core.ui.qr import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.ui.util.getChannelList +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Channel import org.meshtastic.proto.ChannelSet @@ -40,7 +39,7 @@ class ScannedQrCodeViewModel( private val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) /** Set the radio config (also updates our saved copy in preferences). */ - fun setChannels(channelSet: ChannelSet) = viewModelScope.launch { + fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") { getChannelList(channelSet.settings, channels.value.settings).forEach(::setChannel) radioConfigRepository.replaceAllSettings(channelSet.settings) @@ -51,11 +50,11 @@ class ScannedQrCodeViewModel( } private fun setChannel(channel: Channel) { - viewModelScope.launch { radioController.setLocalChannel(channel) } + safeLaunch(tag = "setChannel") { radioController.setLocalChannel(channel) } } // Set the radio config (also updates our saved copy in preferences) private fun setConfig(config: Config) { - viewModelScope.launch { radioController.setLocalConfig(config) } + safeLaunch(tag = "setConfig") { radioController.setLocalConfig(config) } } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt index 05b5b151b..2d1923d2d 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt @@ -21,6 +21,7 @@ package org.meshtastic.core.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -102,18 +103,22 @@ fun Flow.asUiState(stopTimeout: Duration = 5.seconds): StateFlow? = null, tag: String? = null, block: suspend CoroutineScope.() -> Unit, -): Job = viewModel.viewModelScope.launch { - try { - block() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - val label = tag ?: "safeLaunch" - Logger.e(e) { "[$label] Unhandled exception" } - errorEvents?.emit(UiText.DynamicString(e.message ?: "Unknown error")) +): Job { + val context = dispatcher ?: viewModel.viewModelScope.coroutineContext + return viewModel.viewModelScope.launch(context) { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val label = tag ?: "safeLaunch" + Logger.e(e) { "[$label] Unhandled exception" } + errorEvents?.emit(UiText.DynamicString(e.message ?: "Unknown error")) + } } } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt index ccdc9ea24..3a0cc95f7 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt @@ -37,6 +37,7 @@ import org.meshtastic.core.model.util.anonymize import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.connections.model.DeviceListEntry import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase @@ -185,11 +186,11 @@ open class ScannerViewModel( fun addRecentAddress(address: String, name: String) { if (!address.startsWith("t")) return - viewModelScope.launch { recentAddressesDataSource.add(RecentAddress(address, name)) } + safeLaunch(tag = "addRecentAddress") { recentAddressesDataSource.add(RecentAddress(address, name)) } } fun removeRecentAddress(address: String) { - viewModelScope.launch { recentAddressesDataSource.remove(address) } + safeLaunch(tag = "removeRecentAddress") { recentAddressesDataSource.remove(address) } } /** @@ -221,7 +222,7 @@ open class ScannerViewModel( } } is DeviceListEntry.Tcp -> { - viewModelScope.launch { + safeLaunch(tag = "onSelectedTcp") { radioPrefs.setDevName(it.name) addRecentAddress(it.fullAddress, it.name) changeDeviceAddress(it.fullAddress) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index a1a31dbf4..54e35f9ab 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -17,14 +17,12 @@ package org.meshtastic.feature.map import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowSeconds @@ -41,6 +39,7 @@ import org.meshtastic.core.resources.eight_hours import org.meshtastic.core.resources.one_day import org.meshtastic.core.resources.one_hour import org.meshtastic.core.resources.two_days +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Position import org.meshtastic.proto.Waypoint @@ -147,7 +146,8 @@ open class BaseMapViewModel( fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum) - fun deleteWaypoint(id: Int) = viewModelScope.launch(ioDispatcher) { packetRepository.deleteWaypoint(id) } + fun deleteWaypoint(id: Int) = + safeLaunch(dispatcher = ioDispatcher, tag = "deleteWaypoint") { packetRepository.deleteWaypoint(id) } fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") { // contactKey: unique contact key filter (channel)+(nodeId) @@ -159,7 +159,7 @@ open class BaseMapViewModel( } private fun sendDataPacket(p: DataPacket) { - viewModelScope.launch(ioDispatcher) { radioController.sendMessage(p) } + safeLaunch(dispatcher = ioDispatcher, tag = "sendDataPacket") { radioController.sendMessage(p) } } fun generatePacketId(): Int = radioController.getPacketId() diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index 7c57b46af..79d7ee030 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.ContactSettings @@ -49,6 +48,7 @@ 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.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.ChannelSet @@ -157,7 +157,7 @@ class MessageViewModel( } fun setTitle(title: String) { - viewModelScope.launch { _title.value = title } + safeLaunch(tag = "setTitle") { _title.value = title } } fun getMessagesFromPaged(contactKey: String): Flow> { @@ -190,7 +190,9 @@ class MessageViewModel( } fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) { - viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) } + safeLaunch(dispatcher = ioDispatcher, tag = "setContactFilteringDisabled") { + packetRepository.setContactFilteringDisabled(contactKey, disabled) + } } fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) @@ -211,21 +213,21 @@ class MessageViewModel( * @param replyId The ID of the message this is a reply to, if any. */ fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) { - viewModelScope.launch { sendMessageUseCase.invoke(str, contactKey, replyId) } + safeLaunch(tag = "sendMessage") { sendMessageUseCase.invoke(str, contactKey, replyId) } } - fun sendReaction(emoji: String, replyId: Int, contactKey: String) = viewModelScope.launch { + fun sendReaction(emoji: String, replyId: Int, contactKey: String) = safeLaunch(tag = "sendReaction") { serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) } fun deleteMessages(uuidList: List) = - viewModelScope.launch(ioDispatcher) { packetRepository.deleteMessages(uuidList) } + safeLaunch(dispatcher = ioDispatcher, tag = "deleteMessages") { packetRepository.deleteMessages(uuidList) } fun clearUnreadCount(contact: String, messageUuid: Long, lastReadTimestamp: Long) = - viewModelScope.launch(ioDispatcher) { + safeLaunch(dispatcher = ioDispatcher, tag = "clearUnreadCount") { val existingTimestamp = contactSettings.value[contact]?.lastReadMessageTimestamp ?: Long.MIN_VALUE if (lastReadTimestamp <= existingTimestamp) { - return@launch + return@safeLaunch } packetRepository.clearUnreadCount(contact, lastReadTimestamp) packetRepository.updateLastReadMessage(contact, messageUuid, lastReadTimestamp) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt index 53d023d08..e41adb99f 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt @@ -17,12 +17,11 @@ package org.meshtastic.feature.messaging import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.entity.QuickChatAction import org.meshtastic.core.repository.QuickChatActionRepository +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed @KoinViewModel @@ -31,7 +30,7 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR get() = quickChatActionRepository.getAllActions().stateInWhileSubscribed(initialValue = emptyList()) fun updateActionPositions(actions: List) { - viewModelScope.launch(ioDispatcher) { + safeLaunch(dispatcher = ioDispatcher, tag = "updateActionPositions") { for (position in actions.indices) { quickChatActionRepository.setItemPosition(actions[position].uuid, position) } @@ -39,8 +38,10 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR } fun addQuickChatAction(action: QuickChatAction) = - viewModelScope.launch(ioDispatcher) { quickChatActionRepository.upsert(action) } + safeLaunch(dispatcher = ioDispatcher, tag = "addQuickChatAction") { quickChatActionRepository.upsert(action) } fun deleteQuickChatAction(action: QuickChatAction) = - viewModelScope.launch(ioDispatcher) { quickChatActionRepository.delete(action) } + safeLaunch(dispatcher = ioDispatcher, tag = "deleteQuickChatAction") { + quickChatActionRepository.delete(action) + } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt index 865242cfb..6967f5e7f 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.Contact @@ -37,6 +36,7 @@ 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.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.ChannelSet import kotlin.collections.map as collectionsMap @@ -188,17 +188,20 @@ class ContactsViewModel( fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) fun deleteContacts(contacts: List) = - viewModelScope.launch(ioDispatcher) { packetRepository.deleteContacts(contacts) } + safeLaunch(dispatcher = ioDispatcher, tag = "deleteContacts") { packetRepository.deleteContacts(contacts) } - fun markAllAsRead() = viewModelScope.launch(ioDispatcher) { packetRepository.clearAllUnreadCounts() } + fun markAllAsRead() = + safeLaunch(dispatcher = ioDispatcher, tag = "markAllAsRead") { packetRepository.clearAllUnreadCounts() } fun setMuteUntil(contacts: List, until: Long) = - viewModelScope.launch(ioDispatcher) { packetRepository.setMuteUntil(contacts, until) } + safeLaunch(dispatcher = ioDispatcher, tag = "setMuteUntil") { packetRepository.setMuteUntil(contacts, until) } fun getContactSettings() = packetRepository.getContactSettings() fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) { - viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) } + safeLaunch(dispatcher = ioDispatcher, tag = "setContactFilteringDisabled") { + packetRepository.setContactFilteringDisabled(contactKey, disabled) + } } /** diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt index b7c5f35bd..699021fbc 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt @@ -18,7 +18,6 @@ package org.meshtastic.feature.node.compass import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -26,7 +25,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.bearing import org.meshtastic.core.common.util.formatString @@ -37,6 +35,7 @@ import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.ui.component.precisionBitsToMeters +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.proto.Config import org.meshtastic.proto.Position import kotlin.math.abs @@ -92,13 +91,17 @@ class CompassViewModel( updatesJob?.cancel() - updatesJob = viewModelScope.launch { - combine(headingProvider.headingUpdates(), phoneLocationProvider.locationUpdates()) { heading, location -> - buildState(heading, location) + updatesJob = + safeLaunch(tag = "compassUpdates") { + combine(headingProvider.headingUpdates(), phoneLocationProvider.locationUpdates()) { + heading, + location, + -> + buildState(heading, location) + } + .flowOn(dispatchers.default) + .collect { _uiState.value = it } } - .flowOn(dispatchers.default) - .collect { _uiState.value = it } - } } fun stop() { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 3b6ea5656..ace92f020 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import okio.ByteString.Companion.decodeBase64 @@ -60,6 +59,7 @@ import org.meshtastic.core.resources.traceroute import org.meshtastic.core.resources.view_on_map import org.meshtastic.core.ui.util.AlertManager import org.meshtastic.core.ui.util.toMessageRes +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.detail.NodeRequestActions import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase @@ -181,7 +181,8 @@ open class MetricsViewModel( fun getUser(nodeNum: Int) = nodeRepository.getUser(nodeNum) - fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) } + fun deleteLog(uuid: String) = + safeLaunch(dispatcher = dispatchers.io, tag = "deleteLog") { meshLogRepository.deleteLog(uuid) } fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? { val cached = tracerouteOverlayCache.value[requestId] @@ -216,7 +217,7 @@ open class MetricsViewModel( private fun List.numSet(): Set = map { it.num }.toSet() init { - viewModelScope.launch { + safeLaunch(tag = "tracerouteCollector") { serviceRepository.tracerouteResponse.filterNotNull().collect { response -> val overlay = TracerouteOverlay( @@ -232,7 +233,7 @@ open class MetricsViewModel( Logger.d { "MetricsViewModel created" } } - fun clearPosition() = viewModelScope.launch(dispatchers.io) { + fun clearPosition() = safeLaunch(dispatcher = dispatchers.io, tag = "clearPosition") { (manualNodeId.value ?: nodeIdFromRoute)?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP.value) } @@ -276,7 +277,7 @@ open class MetricsViewModel( overlay: TracerouteOverlay?, onViewOnMap: (Int, String) -> Unit, ) { - viewModelScope.launch { + safeLaunch(tag = "showTracerouteDetail") { val snapshotPositions = tracerouteSnapshotRepository.getSnapshotPositions(responseLogUuid).first() alertManager.showAlert( titleRes = Res.string.traceroute, @@ -299,7 +300,7 @@ open class MetricsViewModel( if (errorRes != null) { // Post the error alert after the current alert is dismissed to avoid // the wrapping dismissAlert() in AlertManager immediately clearing it. - viewModelScope.launch { + safeLaunch(tag = "tracerouteError") { alertManager.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes) } } else { @@ -336,7 +337,7 @@ open class MetricsViewModel( epochSeconds: (T) -> Long, rowMapper: (T) -> String, ) { - viewModelScope.launch(dispatchers.io) { + safeLaunch(dispatcher = dispatchers.io, tag = "exportCsv") { fileService.write(uri) { sink -> sink.writeUtf8(header) rows.forEach { item -> diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index 27c57fafe..fc5923c1a 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -17,7 +17,6 @@ package org.meshtastic.feature.settings import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -25,7 +24,6 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import okio.BufferedSink import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.BuildConfigProvider @@ -51,6 +49,7 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationPrefs import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.LocalConfig @@ -146,12 +145,12 @@ class SettingsViewModel( val meshLogLoggingEnabled: StateFlow = _meshLogLoggingEnabled.asStateFlow() fun setMeshLogRetentionDays(days: Int) { - viewModelScope.launch { setMeshLogSettingsUseCase.setRetentionDays(days) } + safeLaunch(tag = "setMeshLogRetentionDays") { setMeshLogSettingsUseCase.setRetentionDays(days) } _meshLogRetentionDays.value = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS) } fun setMeshLogLoggingEnabled(enabled: Boolean) { - viewModelScope.launch { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) } + safeLaunch(tag = "setMeshLogLoggingEnabled") { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) } _meshLogLoggingEnabled.value = enabled } @@ -183,7 +182,9 @@ class SettingsViewModel( * @param filterPortnum If provided, only packets with this port number will be exported. */ fun saveDataCsv(uri: MeshtasticUri, filterPortnum: Int? = null) { - viewModelScope.launch { fileService.write(uri) { writer -> performDataExport(writer, filterPortnum) } } + safeLaunch(tag = "saveDataCsv") { + fileService.write(uri) { writer -> performDataExport(writer, filterPortnum) } + } } private suspend fun performDataExport(writer: BufferedSink, filterPortnum: Int?) { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt index f479e3d26..c1d36e2ee 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt @@ -17,11 +17,9 @@ package org.meshtastic.feature.settings.channel import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.model.RadioController @@ -30,6 +28,7 @@ import org.meshtastic.core.repository.DataPair import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.ui.util.getChannelList +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Channel import org.meshtastic.proto.ChannelSet @@ -86,7 +85,7 @@ class ChannelViewModel( } /** Set the radio config (also updates our saved copy in preferences). */ - fun setChannels(channelSet: ChannelSet) = viewModelScope.launch { + fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") { getChannelList(channelSet.settings, channels.value.settings).forEach(::setChannel) radioConfigRepository.replaceAllSettings(channelSet.settings) @@ -97,12 +96,12 @@ class ChannelViewModel( } fun setChannel(channel: Channel) { - viewModelScope.launch { radioController.setLocalChannel(channel) } + safeLaunch(tag = "setChannel") { radioController.setLocalChannel(channel) } } // Set the radio config (also updates our saved copy in preferences) fun setConfig(config: Config) { - viewModelScope.launch { radioController.setLocalConfig(config) } + safeLaunch(tag = "setConfig") { radioController.setLocalConfig(config) } } fun trackShare() { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index 8ed442ccd..792c79855 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -18,7 +18,6 @@ package org.meshtastic.feature.settings.debugging import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -29,7 +28,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.DateFormatter @@ -47,6 +45,7 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.debug_clear import org.meshtastic.core.resources.debug_clear_logs_confirm import org.meshtastic.core.ui.util.AlertManager +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.MeshPacket @@ -265,16 +264,18 @@ class DebugViewModel( val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS) meshLogPrefs.setRetentionDays(clamped) _retentionDays.value = clamped - viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(clamped) } + safeLaunch(tag = "setRetentionDays") { meshLogRepository.deleteLogsOlderThan(clamped) } } fun setLoggingEnabled(enabled: Boolean) { meshLogPrefs.setLoggingEnabled(enabled) _loggingEnabled.value = enabled if (!enabled) { - viewModelScope.launch { meshLogRepository.deleteAll() } + safeLaunch(tag = "disableLogging") { meshLogRepository.deleteAll() } } else { - viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays.value) } + safeLaunch(tag = "enableLogging") { + meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays.value) + } } } @@ -286,7 +287,7 @@ class DebugViewModel( init { Logger.d { "DebugViewModel created" } - viewModelScope.launch { + safeLaunch(tag = "searchMatchUpdater") { combine(searchManager.searchText, filterManager.filteredLogs) { searchText, logs -> searchManager.findSearchMatches(searchText, logs) } @@ -406,7 +407,7 @@ class DebugViewModel( ) } - fun deleteAllLogs() = viewModelScope.launch(ioDispatcher) { meshLogRepository.deleteAll() } + fun deleteAllLogs() = safeLaunch(dispatcher = ioDispatcher, tag = "deleteAllLogs") { meshLogRepository.deleteAll() } @Immutable data class UiMeshLog( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt index d47791300..26bacd139 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt @@ -17,10 +17,8 @@ package org.meshtastic.feature.settings.radio import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.nowSeconds @@ -31,6 +29,7 @@ import org.meshtastic.core.resources.are_you_sure import org.meshtastic.core.resources.clean_node_database_confirmation import org.meshtastic.core.resources.clean_now import org.meshtastic.core.ui.util.AlertManager +import org.meshtastic.core.ui.viewmodel.safeLaunch private const val MIN_DAYS_THRESHOLD = 7f @@ -65,7 +64,7 @@ class CleanNodeDatabaseViewModel( /** Updates the list of nodes to be deleted based on the current filter criteria. */ fun getNodesToDelete() { - viewModelScope.launch { + safeLaunch(tag = "getNodesToDelete") { _nodesToDelete.value = cleanNodeDatabaseUseCase.getNodesToClean( olderThanDays = _olderThanDays.value, @@ -76,7 +75,7 @@ class CleanNodeDatabaseViewModel( } fun requestCleanNodes() { - viewModelScope.launch { + safeLaunch(tag = "requestCleanNodes") { val count = _nodesToDelete.value.size val message = getString(Res.string.clean_node_database_confirmation, count) alertManager.showAlert( @@ -93,7 +92,7 @@ class CleanNodeDatabaseViewModel( * them. */ fun cleanNodes() { - viewModelScope.launch { + safeLaunch(tag = "cleanNodes") { val nodeNums = _nodesToDelete.value.map { it.num } cleanNodeDatabaseUseCase.cleanNodes(nodeNums) // Clear the list after deletion or if it was empty diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 592c15d3a..891425939 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -62,6 +62,7 @@ import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.cant_shutdown import org.meshtastic.core.resources.timeout import org.meshtastic.core.ui.util.getChannelList +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.feature.settings.navigation.ConfigRoute import org.meshtastic.feature.settings.navigation.ModuleRoute import org.meshtastic.proto.AdminMessage @@ -385,46 +386,34 @@ open class RadioConfigViewModel( } fun importProfile(uri: MeshtasticUri, onResult: (DeviceProfile) -> Unit) { - viewModelScope.launch { - try { - var profile: DeviceProfile? = null - fileService.read(uri) { source -> - importProfileUseCase(source).onSuccess { profile = it }.onFailure { throw it } - } - profile?.let { onResult(it) } - } catch (ex: Exception) { - Logger.e { "Import DeviceProfile error: ${ex.message}" } + safeLaunch(tag = "importProfile") { + var profile: DeviceProfile? = null + fileService.read(uri) { source -> + importProfileUseCase(source).onSuccess { profile = it }.onFailure { throw it } } + profile?.let { onResult(it) } } } fun exportProfile(uri: MeshtasticUri, profile: DeviceProfile) { - viewModelScope.launch { - try { - fileService.write(uri) { sink -> - exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it } - } - } catch (ex: Exception) { - Logger.e { "Can't write file error: ${ex.message}" } + safeLaunch(tag = "exportProfile") { + fileService.write(uri) { sink -> + exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it } } } } fun exportSecurityConfig(uri: MeshtasticUri, securityConfig: Config.SecurityConfig) { - viewModelScope.launch { - try { - fileService.write(uri) { sink -> - exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it } - } - } catch (ex: Exception) { - Logger.e { "Can't write security keys JSON error: ${ex.message}" } + safeLaunch(tag = "exportSecurityConfig") { + fileService.write(uri) { sink -> + exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it } } } } fun installProfile(protobuf: DeviceProfile) { val destNum = destNode.value?.num ?: return - viewModelScope.launch { installProfileUseCase(destNum, protobuf, destNode.value?.user) } + safeLaunch(tag = "installProfile") { installProfileUseCase(destNum, protobuf, destNode.value?.user) } } fun clearPacketResponse() {