mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat(core/ui): add safeLaunch, UiState, KMP permissions, and CMP lifecycle modernization (#5118)
This commit is contained in:
parent
27367e9064
commit
e46a8296cb
26 changed files with 374 additions and 184 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue