feat(core/ui): add safeLaunch, UiState, KMP permissions, and CMP lifecycle modernization (#5118)

This commit is contained in:
James Rich 2026-04-13 19:45:34 -05:00 committed by GitHub
parent 27367e9064
commit e46a8296cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 374 additions and 184 deletions

View file

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

View file

@ -327,6 +327,7 @@
<string name="delivery_confirmed">Delivery confirmed</string>
<string name="delivery_confirmed_reboot_warning">Your device may disconnect and reboot while settings are applied.</string>
<string name="error">Error</string>
<string name="unknown_error">Unknown error</string>
<string name="ignore">Ignore</string>
<string name="remove_ignored">Remove from ignored</string>
<string name="ignore_add">Add '%1$s' to ignore list?</string>
@ -606,6 +607,9 @@
<string name="output_duration_milliseconds">Output duration (milliseconds)</string>
<string name="nag_timeout_seconds">Nag timeout (seconds)</string>
<string name="ringtone">Ringtone</string>
<string name="ringtone_imported">Imported ringtone</string>
<string name="ringtone_file_empty">File is empty</string>
<string name="ringtone_import_error">Error importing: %1$s</string>
<string name="play">Play</string>
<string name="use_i2s_as_buzzer">Use I2S as buzzer</string>
<string name="lora_config">LoRa</string>

View file

@ -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) } }

View file

@ -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
}

View file

@ -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,

View file

@ -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) }
}
}

View file

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

View file

@ -14,16 +14,30 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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 <T> Flow<T>.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<out T> {
/** Data has not yet arrived. */
data object Loading : UiState<Nothing>
/** Data is available. */
data class Content<T>(val data: T) : UiState<T>
/** An error occurred while loading. */
data class Error(val message: UiText) : UiState<Nothing>
}
/** Returns the [Content] data, or `null` if this state is [Loading] or [Error]. */
fun <T> UiState<T>.dataOrNull(): T? = (this as? UiState.Content)?.data
/**
* Wraps this [Flow] into a `StateFlow<UiState<T>>`, 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 <T> Flow<T>.asUiState(stopTimeout: Duration = 5.seconds): StateFlow<UiState<T>> =
this.map<T, UiState<T>> { 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<UiText>? = 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<UiText> = MutableSharedFlow(extraBufferCapacity = 1)

View file

@ -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) {}

View file

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

View file

@ -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)

View file

@ -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()

View file

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

View file

@ -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<PagingData<Message>> {
@ -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<Long>) =
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)

View file

@ -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<QuickChatAction>) {
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) }
}

View file

@ -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<String>) =
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<String>, 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)
}
}
/**

View file

@ -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() {

View file

@ -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<Node>.numSet(): Set<Int> = 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 ->

View file

@ -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(

View file

@ -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()
}
}
}

View file

@ -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<Boolean> = _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?) {

View file

@ -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() {

View file

@ -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 = {},
)
}
}

View file

@ -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(

View file

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

View file

@ -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> = _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<ChannelSettings>, old: List<ChannelSettings>) {
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)
}