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

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