diff --git a/.skills/code-review/SKILL.md b/.skills/code-review/SKILL.md
index b39e2d0d9..6a774297c 100644
--- a/.skills/code-review/SKILL.md
+++ b/.skills/code-review/SKILL.md
@@ -55,3 +55,9 @@ When reviewing code, meticulously verify the following categories. Flag any devi
### 8. ProGuard / R8 Rules
- [ ] **New Dependencies:** If a new reflection-heavy dependency is added (DI, serialization, JNI, ServiceLoader), verify keep rules exist in **both** `app/proguard-rules.pro` (R8) and `desktop/proguard-rules.pro` (ProGuard). The two files must stay aligned.
- [ ] **Release Smoke-Test:** For dependency or ProGuard rule changes, verify `assembleRelease` and `./gradlew :desktop:runRelease` succeed.
+
+## Review Output Guidelines
+1. **Be Specific & Constructive:** Provide exact file references and code snippets illustrating the required project pattern.
+2. **Reference the Docs:** Cite `AGENTS.md` and project architecture playbooks to justify change requests (e.g., "Per AGENTS.md, `java.io.*` cannot be used in `commonMain`; please migrate to Okio").
+3. **Enforce Build Health:** Remind authors to run `./gradlew test allTests` locally to verify changes, especially since KMP `test` tasks are ambiguous.
+4. **Praise Good Patterns:** Acknowledge correct usage of complex architecture requirements, like proper Navigation 3 scene transitions or elegant `commonMain` helper extractions.
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 5d7eba25a..4a5e40ade 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -327,6 +327,7 @@
Delivery confirmed
Your device may disconnect and reboot while settings are applied.
Error
+ Unknown error
Ignore
Remove from ignored
Add '%1$s' to ignore list?
@@ -606,6 +607,9 @@
Output duration (milliseconds)
Nag timeout (seconds)
Ringtone
+ Imported ringtone
+ File is empty
+ Error importing: %1$s
Play
Use I2S as buzzer
LoRa
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index 99221edf1..d07a5afc3 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -60,6 +60,7 @@ kotlin {
implementation(libs.jetbrains.compose.material3.adaptive.navigation3)
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
+ implementation(libs.jetbrains.lifecycle.runtime.compose)
}
val jvmAndroidMain by getting { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } }
diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index 559169139..bebed2f46 100644
--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -27,15 +27,20 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.net.toUri
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
+import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.MeshtasticUri
import java.net.URLEncoder
@@ -216,3 +221,67 @@ actual fun rememberOpenLocationSettings(): () -> Unit {
}
return remember(launcher) { { launcher.launch(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)) } }
}
+
+@Composable
+actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit {
+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) {
+ // On pre-Android 12, BLE scanning is gated by location permission, not Bluetooth.
+ return remember { { onGranted() } }
+ }
+ val currentOnGranted = rememberUpdatedState(onGranted)
+ val currentOnDenied = rememberUpdatedState(onDenied)
+ val launcher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
+ if (permissions.values.all { it }) currentOnGranted.value() else currentOnDenied.value()
+ }
+ return remember(launcher) {
+ {
+ launcher.launch(
+ arrayOf(android.Manifest.permission.BLUETOOTH_SCAN, android.Manifest.permission.BLUETOOTH_CONNECT),
+ )
+ }
+ }
+}
+
+@Composable
+actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit {
+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
+ // Pre-Android 13, no runtime notification permission required.
+ return remember { { onGranted() } }
+ }
+ val currentOnGranted = rememberUpdatedState(onGranted)
+ val currentOnDenied = rememberUpdatedState(onDenied)
+ val launcher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ if (granted) currentOnGranted.value() else currentOnDenied.value()
+ }
+ return remember(launcher) { { launcher.launch(android.Manifest.permission.POST_NOTIFICATIONS) } }
+}
+
+@Composable
+actual fun isLocationPermissionGranted(): Boolean {
+ val context = LocalContext.current
+ return rememberOnResumeState {
+ androidx.core.content.ContextCompat.checkSelfPermission(
+ context,
+ android.Manifest.permission.ACCESS_FINE_LOCATION,
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ }
+}
+
+@Composable
+actual fun isGpsDisabled(): Boolean {
+ val context = LocalContext.current
+ return rememberOnResumeState { context.gpsDisabled() }
+}
+
+/**
+ * Remembers a boolean state that is re-evaluated on each [Lifecycle.Event.ON_RESUME], ensuring the value stays fresh
+ * when the user returns from a permission dialog or system settings screen.
+ */
+@Composable
+private fun rememberOnResumeState(check: () -> Boolean): Boolean {
+ val state = remember { mutableStateOf(check()) }
+ LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { state.value = check() }
+ return state.value
+}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt
index 815f9beb7..a0b87ca6a 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt
@@ -30,6 +30,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.okay
@@ -89,7 +90,14 @@ fun TracerouteAlertHandler(
uiViewModel.clearTracerouteResponse()
// Post the error alert after the current alert is dismissed to avoid
// the wrapping dismissAlert() in AlertManager immediately clearing it.
- scope.launch { uiViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes) }
+ @Suppress("TooGenericExceptionCaught")
+ scope.launch {
+ try {
+ uiViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes)
+ } catch (e: Exception) {
+ Logger.e(e) { "[TracerouteAlertHandler] Failed to show error alert" }
+ }
+ }
}
},
dismissTextRes = Res.string.okay,
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/util/PlatformUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index d5910168b..38e870314 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -64,3 +64,21 @@ expect fun rememberSaveFileLauncher(
/** Returns a launcher to open the platform's location settings. */
@Composable expect fun rememberOpenLocationSettings(): () -> Unit
+
+/** Returns a launcher to request Bluetooth scan + connect permissions. No-op on platforms without runtime BLE perms. */
+@Composable expect fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit
+
+/** Returns a launcher to request the POST_NOTIFICATIONS permission. No-op on platforms that don't require it. */
+@Composable
+expect fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit
+
+/**
+ * Returns whether location permissions are currently granted. Always `true` on platforms without runtime permissions.
+ */
+@Composable expect fun isLocationPermissionGranted(): Boolean
+
+/**
+ * Returns whether GPS/location services are currently disabled at the system level. Always `false` on platforms where
+ * this concept doesn't apply.
+ */
+@Composable expect fun isGpsDisabled(): Boolean
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 2201d70bd..b85e68888 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
@@ -14,16 +14,30 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-@file:Suppress("Wrapping", "UnusedImports", "SpacingAroundColon")
+@file:Suppress("Wrapping", "UnusedImports", "SpacingAroundColon", "TooGenericExceptionCaught")
package org.meshtastic.core.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.UiText
+import org.meshtastic.core.resources.unknown_error
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -40,3 +54,82 @@ fun Flow.stateInWhileSubscribed(initialValue: T, stopTimeout: Duration =
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = stopTimeout.inWholeMilliseconds),
initialValue = initialValue,
)
+
+// ---------------------------------------------------------------------------
+// UiState: shared Loading / Content / Error wrapper
+// ---------------------------------------------------------------------------
+
+/**
+ * Lightweight tri-state wrapper for UI data. Prefer this over bare nullable initial values when the UI needs to
+ * distinguish "still loading" from "genuinely empty."
+ */
+sealed interface UiState {
+ /** Data has not yet arrived. */
+ data object Loading : UiState
+
+ /** Data is available. */
+ data class Content(val data: T) : UiState
+
+ /** An error occurred while loading. */
+ data class Error(val message: UiText) : UiState
+}
+
+/** Returns the [Content] data, or `null` if this state is [Loading] or [Error]. */
+fun UiState.dataOrNull(): T? = (this as? UiState.Content)?.data
+
+/**
+ * Wraps this [Flow] into a `StateFlow>`, emitting [UiState.Loading] until the first value, then
+ * [UiState.Content] for each emission. Upstream errors are caught and mapped to [UiState.Error].
+ */
+context(viewModel: ViewModel)
+fun Flow.asUiState(stopTimeout: Duration = 5.seconds): StateFlow> =
+ this.map> { UiState.Content(it) }
+ .onStart { emit(UiState.Loading) }
+ .catch { e ->
+ val message = e.message?.let { UiText.DynamicString(it) } ?: UiText.Resource(Res.string.unknown_error)
+ emit(UiState.Error(message))
+ }
+ .stateInWhileSubscribed(initialValue = UiState.Loading, stopTimeout = stopTimeout)
+
+// ---------------------------------------------------------------------------
+// safeLaunch: CancellationException-safe coroutine launcher with error routing
+// ---------------------------------------------------------------------------
+
+/**
+ * Launches a coroutine in [viewModelScope] that catches all exceptions except [CancellationException]. Non-cancellation
+ * errors are logged and emitted to [errorEvents] (if provided) for one-shot UI consumption (e.g. snackbar / toast).
+ *
+ * @param context optional [CoroutineContext] element (typically a dispatcher) merged into the launch. Defaults to
+ * [EmptyCoroutineContext], inheriting [viewModelScope]'s dispatcher.
+ *
+ * ```
+ * // In a ViewModel:
+ * safeLaunch(errorEvents = _errors) {
+ * repository.saveData(data)
+ * }
+ * ```
+ */
+context(viewModel: ViewModel)
+fun safeLaunch(
+ context: CoroutineContext = EmptyCoroutineContext,
+ errorEvents: MutableSharedFlow? = null,
+ tag: String? = null,
+ block: suspend CoroutineScope.() -> Unit,
+): Job = viewModel.viewModelScope.launch(context) {
+ try {
+ block()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ val label = tag ?: "safeLaunch"
+ Logger.e(e) { "[$label] Unhandled exception" }
+ val message = e.message?.let { UiText.DynamicString(it) } ?: UiText.Resource(Res.string.unknown_error)
+ errorEvents?.tryEmit(message)
+ }
+}
+
+/**
+ * Creates and returns a [MutableSharedFlow] intended for one-shot error events. Expose as `SharedFlow` via
+ * [asSharedFlow] in the ViewModel, and collect in the UI to show snackbars or toasts.
+ */
+fun errorEventFlow(): MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 1)
diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
index 590bd1fe9..0621463bd 100644
--- a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
+++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
@@ -57,4 +57,13 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT
@Composable actual fun rememberOpenLocationSettings(): () -> Unit = {}
+@Composable actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {}
+
+@Composable
+actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {}
+
+@Composable actual fun isLocationPermissionGranted(): Boolean = true
+
+@Composable actual fun isGpsDisabled(): Boolean = false
+
@Composable actual fun SetScreenBrightness(brightness: Float) {}
diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index aa3435d29..08c414490 100644
--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -130,3 +130,19 @@ actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: ()
@Composable
actual fun rememberOpenLocationSettings(): () -> Unit = { Logger.w { "Location settings not implemented on Desktop" } }
+
+/** JVM no-op — Desktop does not require runtime Bluetooth permissions. */
+@Composable
+actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = { onGranted() }
+
+/** JVM no-op — Desktop does not require runtime notification permissions. */
+@Composable
+actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {
+ onGranted()
+}
+
+/** JVM — location permission is always considered granted on Desktop. */
+@Composable actual fun isLocationPermissionGranted(): Boolean = true
+
+/** JVM — GPS is never disabled on Desktop (concept doesn't apply). */
+@Composable actual fun isGpsDisabled(): Boolean = false
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..7e57f2eff 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
@@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.RadioController
@@ -37,6 +36,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
@@ -76,7 +76,7 @@ open class ScannerViewModel(
scannedBleDevices.value = emptyMap()
scanJob =
- viewModelScope.launch {
+ safeLaunch(tag = "startBleScan") {
try {
bleScanner
.scan(
@@ -89,8 +89,6 @@ open class ScannerViewModel(
scannedBleDevices.update { current -> current + (device.address to device) }
}
}
- } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
- co.touchlab.kermit.Logger.w(e) { "BLE scan failed" }
} finally {
isBleScanningState.value = false
}
@@ -185,11 +183,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 +219,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..294d84e4c 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(context = 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(context = ioDispatcher, tag = "sendDataPacket") { radioController.sendMessage(p) }
}
fun generatePacketId(): Int = radioController.getPacketId()
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
index 9cd435f82..9a742a4ea 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
@@ -27,7 +27,6 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
@@ -44,8 +43,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemContentType
@@ -452,23 +450,12 @@ private fun UpdateUnreadCountPaged(
onUnreadChange: (Long, Long) -> Unit,
) {
val currentOnUnreadChange by rememberUpdatedState(onUnreadChange)
- val lifecycleOwner = LocalLifecycleOwner.current
- var isResumed by remember {
- mutableStateOf(lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED))
- }
+ var isResumed by remember { mutableStateOf(false) }
// Track lifecycle state changes
- DisposableEffect(lifecycleOwner) {
- val observer =
- androidx.lifecycle.LifecycleEventObserver { _, event ->
- when (event) {
- Lifecycle.Event.ON_RESUME -> isResumed = true
- Lifecycle.Event.ON_PAUSE -> isResumed = false
- else -> {}
- }
- }
- lifecycleOwner.lifecycle.addObserver(observer)
- onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ LifecycleResumeEffect(Unit) {
+ isResumed = true
+ onPauseOrDispose { isResumed = false }
}
// Track remote message count to restart effect when remote messages change
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..4d3e5679d 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 }
+ _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(context = 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(context = ioDispatcher, tag = "deleteMessages") { packetRepository.deleteMessages(uuidList) }
fun clearUnreadCount(contact: String, messageUuid: Long, lastReadTimestamp: Long) =
- viewModelScope.launch(ioDispatcher) {
+ safeLaunch(context = 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..6451b8885 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(context = ioDispatcher, tag = "updateActionPositions") {
for (position in actions.indices) {
quickChatActionRepository.setItemPosition(actions[position].uuid, position)
}
@@ -39,8 +38,8 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR
}
fun addQuickChatAction(action: QuickChatAction) =
- viewModelScope.launch(ioDispatcher) { quickChatActionRepository.upsert(action) }
+ safeLaunch(context = ioDispatcher, tag = "addQuickChatAction") { quickChatActionRepository.upsert(action) }
fun deleteQuickChatAction(action: QuickChatAction) =
- viewModelScope.launch(ioDispatcher) { quickChatActionRepository.delete(action) }
+ safeLaunch(context = 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..f8aa46032 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(context = ioDispatcher, tag = "deleteContacts") { packetRepository.deleteContacts(contacts) }
- fun markAllAsRead() = viewModelScope.launch(ioDispatcher) { packetRepository.clearAllUnreadCounts() }
+ fun markAllAsRead() =
+ safeLaunch(context = ioDispatcher, tag = "markAllAsRead") { packetRepository.clearAllUnreadCounts() }
fun setMuteUntil(contacts: List, until: Long) =
- viewModelScope.launch(ioDispatcher) { packetRepository.setMuteUntil(contacts, until) }
+ safeLaunch(context = 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(context = 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..b7ab25368 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(context = 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(context = 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(context = dispatchers.io, tag = "exportCsv") {
fileService.write(uri) { sink ->
sink.writeUtf8(header)
rows.forEach { item ->
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index c33c3a293..82558309d 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -34,7 +34,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.google.accompanist.permissions.ExperimentalPermissionsApi
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toDate
@@ -72,7 +71,6 @@ import org.meshtastic.proto.DeviceProfile
import java.text.SimpleDateFormat
import java.util.Locale
-@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun SettingsScreen(
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt
index fe5e381f6..063add0d1 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt
@@ -30,17 +30,26 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.import_label
import org.meshtastic.core.resources.play
+import org.meshtastic.core.resources.ringtone_file_empty
+import org.meshtastic.core.resources.ringtone_import_error
+import org.meshtastic.core.resources.ringtone_imported
import org.meshtastic.core.ui.icon.FolderOpen
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PlayArrow
import java.io.File
private const val MAX_RINGTONE_SIZE = 230
+private const val IMPORT_ERROR_PLACEHOLDER = "@@ERROR@@"
@Suppress("TooGenericExceptionCaught")
@Composable
actual fun RingtoneTrailingIcon(ringtoneInput: String, onRingtoneImported: (String) -> Unit, enabled: Boolean) {
val context = LocalContext.current
+ val importedText = stringResource(Res.string.ringtone_imported)
+ val emptyText = stringResource(Res.string.ringtone_file_empty)
+ // Pre-resolve the format pattern for use in the non-composable launcher callback.
+ // Using a sentinel placeholder that will be replaced at call-site.
+ val importErrorPrefix = stringResource(Res.string.ringtone_import_error, IMPORT_ERROR_PLACEHOLDER)
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
@@ -52,15 +61,16 @@ actual fun RingtoneTrailingIcon(ringtoneInput: String, onRingtoneImported: (Stri
val read = reader.read(buffer)
if (read > 0) {
onRingtoneImported(String(buffer, 0, read))
- Toast.makeText(context, "Imported ringtone", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, importedText, Toast.LENGTH_SHORT).show()
} else {
- Toast.makeText(context, "File is empty", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, emptyText, Toast.LENGTH_SHORT).show()
}
}
}
} catch (e: Exception) {
Logger.e(e) { "Error importing ringtone" }
- Toast.makeText(context, "Error importing: ${e.message}", Toast.LENGTH_SHORT).show()
+ val errorMsg = importErrorPrefix.replace(IMPORT_ERROR_PLACEHOLDER, e.message ?: e.toString())
+ Toast.makeText(context, errorMsg, Toast.LENGTH_SHORT).show()
}
}
}
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/androidMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt
similarity index 64%
rename from feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt
rename to feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt
index d7910f2ea..3930580d1 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt
@@ -16,15 +16,9 @@
*/
package org.meshtastic.feature.settings.component
-import android.Manifest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.tooling.preview.Preview
-import com.google.accompanist.permissions.ExperimentalPermissionsApi
-import com.google.accompanist.permissions.rememberMultiplePermissionsState
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.analytics_okay
import org.meshtastic.core.resources.app_settings
@@ -34,11 +28,12 @@ import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.core.ui.icon.BugReport
import org.meshtastic.core.ui.icon.LocationOn
import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.theme.AppTheme
-import org.meshtastic.core.ui.util.showToast
+import org.meshtastic.core.ui.util.isGpsDisabled
+import org.meshtastic.core.ui.util.isLocationPermissionGranted
+import org.meshtastic.core.ui.util.rememberRequestLocationPermission
+import org.meshtastic.core.ui.util.rememberShowToastResource
/** Section managing privacy settings like analytics and location sharing. */
-@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PrivacySection(
analyticsAvailable: Boolean,
@@ -51,21 +46,22 @@ fun PrivacySection(
startProvideLocation: () -> Unit,
stopProvideLocation: () -> Unit,
) {
- val context = LocalContext.current
- val locationPermissionsState =
- rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
- val isGpsDisabled = context.gpsDisabled()
+ val showToast = rememberShowToastResource()
+ val isLocationGranted = isLocationPermissionGranted()
+ val isGpsOff = isGpsDisabled()
+ val requestLocationPermission =
+ rememberRequestLocationPermission(onGranted = { startProvideLocation() }, onDenied = {})
- LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
+ LaunchedEffect(provideLocation, isLocationGranted, isGpsOff) {
if (provideLocation) {
- if (locationPermissionsState.allPermissionsGranted) {
- if (!isGpsDisabled) {
+ if (isLocationGranted) {
+ if (!isGpsOff) {
startProvideLocation()
} else {
- context.showToast(Res.string.location_disabled)
+ showToast(Res.string.location_disabled)
}
} else {
- locationPermissionsState.launchMultiplePermissionRequest()
+ requestLocationPermission()
}
} else {
stopProvideLocation()
@@ -85,7 +81,7 @@ fun PrivacySection(
SwitchListItem(
text = stringResource(Res.string.provide_location_to_mesh),
leadingIcon = MeshtasticIcons.LocationOn,
- enabled = !isGpsDisabled,
+ enabled = !isGpsOff,
checked = provideLocation,
onClick = { onToggleLocation(!provideLocation) },
)
@@ -93,21 +89,3 @@ fun PrivacySection(
HomoglyphSetting(homoglyphEncodingEnabled = homoglyphEnabled, onToggle = onToggleHomoglyph)
}
}
-
-@Preview(showBackground = true)
-@Composable
-private fun PrivacySectionPreview() {
- AppTheme {
- PrivacySection(
- analyticsAvailable = true,
- analyticsEnabled = true,
- onToggleAnalytics = {},
- provideLocation = true,
- onToggleLocation = {},
- homoglyphEnabled = false,
- onToggleHomoglyph = {},
- startProvideLocation = {},
- stopProvideLocation = {},
- )
- }
-}
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..682e0e8c3 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(context = 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..4b8427c87 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
@@ -28,7 +28,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
@@ -62,6 +61,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
@@ -155,7 +155,7 @@ open class RadioConfigViewModel(
val radioConfigState: StateFlow = _radioConfigState
fun setPreserveFavorites(preserveFavorites: Boolean) {
- viewModelScope.launch { _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } }
+ _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) }
}
private val _currentDeviceProfile = MutableStateFlow(DeviceProfile())
@@ -242,7 +242,7 @@ open class RadioConfigViewModel(
fun setOwner(user: User) {
val destNum = destNode.value?.num ?: return
- viewModelScope.launch {
+ safeLaunch(tag = "setOwner") {
_radioConfigState.update { it.copy(userConfig = user) }
val packetId = radioConfigUseCase.setOwner(destNum, user)
registerRequestId(packetId)
@@ -252,14 +252,14 @@ open class RadioConfigViewModel(
fun updateChannels(new: List, old: List) {
val destNum = destNode.value?.num ?: return
getChannelList(new, old).forEach { channel ->
- viewModelScope.launch {
+ safeLaunch(tag = "setRemoteChannel") {
val packetId = radioConfigUseCase.setRemoteChannel(destNum, channel)
registerRequestId(packetId)
}
}
if (destNum == myNodeNum) {
- viewModelScope.launch {
+ safeLaunch(tag = "migrateChannels") {
packetRepository.migrateChannelsByPSK(old, new)
radioConfigRepository.replaceAllSettings(new)
}
@@ -269,7 +269,7 @@ open class RadioConfigViewModel(
fun setConfig(config: Config) {
val destNum = destNode.value?.num ?: return
- viewModelScope.launch {
+ safeLaunch(tag = "setConfig") {
_radioConfigState.update { state ->
state.copy(
radioConfig =
@@ -293,7 +293,7 @@ open class RadioConfigViewModel(
@Suppress("CyclomaticComplexMethod")
fun setModuleConfig(config: ModuleConfig) {
val destNum = destNode.value?.num ?: return
- viewModelScope.launch {
+ safeLaunch(tag = "setModuleConfig") {
_radioConfigState.update { state ->
state.copy(
moduleConfig =
@@ -326,13 +326,13 @@ open class RadioConfigViewModel(
fun setRingtone(ringtone: String) {
val destNum = destNode.value?.num ?: return
_radioConfigState.update { it.copy(ringtone = ringtone) }
- viewModelScope.launch { radioConfigUseCase.setRingtone(destNum, ringtone) }
+ safeLaunch(tag = "setRingtone") { radioConfigUseCase.setRingtone(destNum, ringtone) }
}
fun setCannedMessages(messages: String) {
val destNum = destNode.value?.num ?: return
_radioConfigState.update { it.copy(cannedMessageMessages = messages) }
- viewModelScope.launch { radioConfigUseCase.setCannedMessages(destNum, messages) }
+ safeLaunch(tag = "setCannedMessages") { radioConfigUseCase.setCannedMessages(destNum, messages) }
}
private fun sendAdminRequest(destNum: Int) {
@@ -343,7 +343,7 @@ open class RadioConfigViewModel(
when (route) {
AdminRoute.REBOOT.name ->
- viewModelScope.launch {
+ safeLaunch(tag = "reboot") {
val packetId = adminActionsUseCase.reboot(destNum)
registerRequestId(packetId)
}
@@ -352,7 +352,7 @@ open class RadioConfigViewModel(
if (metadata?.canShutdown != true) {
sendError(Res.string.cant_shutdown)
} else {
- viewModelScope.launch {
+ safeLaunch(tag = "shutdown") {
val packetId = adminActionsUseCase.shutdown(destNum)
registerRequestId(packetId)
}
@@ -360,13 +360,13 @@ open class RadioConfigViewModel(
}
AdminRoute.FACTORY_RESET.name ->
- viewModelScope.launch {
+ safeLaunch(tag = "factoryReset") {
val isLocal = (destNum == myNodeNum)
val packetId = adminActionsUseCase.factoryReset(destNum, isLocal)
registerRequestId(packetId)
}
AdminRoute.NODEDB_RESET.name ->
- viewModelScope.launch {
+ safeLaunch(tag = "nodedbReset") {
val isLocal = (destNum == myNodeNum)
val packetId = adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal)
registerRequestId(packetId)
@@ -376,55 +376,43 @@ open class RadioConfigViewModel(
fun setFixedPosition(position: Position) {
val destNum = destNode.value?.num ?: return
- viewModelScope.launch { radioConfigUseCase.setFixedPosition(destNum, position) }
+ safeLaunch(tag = "setFixedPosition") { radioConfigUseCase.setFixedPosition(destNum, position) }
}
fun removeFixedPosition() {
val destNum = destNode.value?.num ?: return
- viewModelScope.launch { radioConfigUseCase.removeFixedPosition(destNum) }
+ safeLaunch(tag = "removeFixedPosition") { radioConfigUseCase.removeFixedPosition(destNum) }
}
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() {
@@ -439,17 +427,17 @@ open class RadioConfigViewModel(
when (route) {
ConfigRoute.USER ->
- viewModelScope.launch {
+ safeLaunch(tag = "getOwner") {
val packetId = radioConfigUseCase.getOwner(destNum)
registerRequestId(packetId)
}
ConfigRoute.CHANNELS -> {
- viewModelScope.launch {
+ safeLaunch(tag = "getChannel0") {
val packetId = radioConfigUseCase.getChannel(destNum, 0)
registerRequestId(packetId)
}
- viewModelScope.launch {
+ safeLaunch(tag = "getLoraConfig") {
val packetId = radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.LORA_CONFIG.value)
registerRequestId(packetId)
}
@@ -458,7 +446,7 @@ open class RadioConfigViewModel(
}
is AdminRoute -> {
- viewModelScope.launch {
+ safeLaunch(tag = "getSessionKeyConfig") {
val packetId =
radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value)
registerRequestId(packetId)
@@ -468,18 +456,18 @@ open class RadioConfigViewModel(
is ConfigRoute -> {
if (route == ConfigRoute.LORA) {
- viewModelScope.launch {
+ safeLaunch(tag = "getChannel0ForLora") {
val packetId = radioConfigUseCase.getChannel(destNum, 0)
registerRequestId(packetId)
}
}
if (route == ConfigRoute.NETWORK) {
- viewModelScope.launch {
+ safeLaunch(tag = "getConnectionStatus") {
val packetId = radioConfigUseCase.getDeviceConnectionStatus(destNum)
registerRequestId(packetId)
}
}
- viewModelScope.launch {
+ safeLaunch(tag = "getConfig") {
val packetId = radioConfigUseCase.getConfig(destNum, route.type)
registerRequestId(packetId)
}
@@ -487,18 +475,18 @@ open class RadioConfigViewModel(
is ModuleRoute -> {
if (route == ModuleRoute.CANNED_MESSAGE) {
- viewModelScope.launch {
+ safeLaunch(tag = "getCannedMessages") {
val packetId = radioConfigUseCase.getCannedMessages(destNum)
registerRequestId(packetId)
}
}
if (route == ModuleRoute.EXT_NOTIFICATION) {
- viewModelScope.launch {
+ safeLaunch(tag = "getRingtone") {
val packetId = radioConfigUseCase.getRingtone(destNum)
registerRequestId(packetId)
}
}
- viewModelScope.launch {
+ safeLaunch(tag = "getModuleConfig") {
val packetId = radioConfigUseCase.getModuleConfig(destNum, route.type)
registerRequestId(packetId)
}
@@ -568,7 +556,7 @@ open class RadioConfigViewModel(
}
val requestTimeout = 30.seconds
- viewModelScope.launch {
+ safeLaunch(tag = "requestTimeout") {
delay(requestTimeout)
if (requestIds.value.contains(packetId)) {
requestIds.update { it.apply { remove(packetId) } }
@@ -628,7 +616,7 @@ open class RadioConfigViewModel(
val index = response.index
if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
// Not done yet, request next channel
- viewModelScope.launch {
+ safeLaunch(tag = "getNextChannel") {
val packetId = radioConfigUseCase.getChannel(destNum, index + 1)
registerRequestId(packetId)
}