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