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
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -327,6 +327,7 @@
|
|||
<string name="delivery_confirmed">Delivery confirmed</string>
|
||||
<string name="delivery_confirmed_reboot_warning">Your device may disconnect and reboot while settings are applied.</string>
|
||||
<string name="error">Error</string>
|
||||
<string name="unknown_error">Unknown error</string>
|
||||
<string name="ignore">Ignore</string>
|
||||
<string name="remove_ignored">Remove from ignored</string>
|
||||
<string name="ignore_add">Add '%1$s' to ignore list?</string>
|
||||
|
|
@ -606,6 +607,9 @@
|
|||
<string name="output_duration_milliseconds">Output duration (milliseconds)</string>
|
||||
<string name="nag_timeout_seconds">Nag timeout (seconds)</string>
|
||||
<string name="ringtone">Ringtone</string>
|
||||
<string name="ringtone_imported">Imported ringtone</string>
|
||||
<string name="ringtone_file_empty">File is empty</string>
|
||||
<string name="ringtone_import_error">Error importing: %1$s</string>
|
||||
<string name="play">Play</string>
|
||||
<string name="use_i2s_as_buzzer">Use I2S as buzzer</string>
|
||||
<string name="lora_config">LoRa</string>
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.filterNotNull
|
|||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.model.ContactSettings
|
||||
|
|
@ -49,6 +48,7 @@ import org.meshtastic.core.repository.RadioConfigRepository
|
|||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.core.repository.usecase.SendMessageUseCase
|
||||
import org.meshtastic.core.ui.viewmodel.safeLaunch
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ class MessageViewModel(
|
|||
}
|
||||
|
||||
fun setTitle(title: String) {
|
||||
viewModelScope.launch { _title.value = title }
|
||||
_title.value = title
|
||||
}
|
||||
|
||||
fun getMessagesFromPaged(contactKey: String): Flow<PagingData<Message>> {
|
||||
|
|
@ -190,7 +190,9 @@ class MessageViewModel(
|
|||
}
|
||||
|
||||
fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) {
|
||||
viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
|
||||
safeLaunch(context = ioDispatcher, tag = "setContactFilteringDisabled") {
|
||||
packetRepository.setContactFilteringDisabled(contactKey, disabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
|
||||
|
|
@ -211,21 +213,21 @@ class MessageViewModel(
|
|||
* @param replyId The ID of the message this is a reply to, if any.
|
||||
*/
|
||||
fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) {
|
||||
viewModelScope.launch { sendMessageUseCase.invoke(str, contactKey, replyId) }
|
||||
safeLaunch(tag = "sendMessage") { sendMessageUseCase.invoke(str, contactKey, replyId) }
|
||||
}
|
||||
|
||||
fun sendReaction(emoji: String, replyId: Int, contactKey: String) = viewModelScope.launch {
|
||||
fun sendReaction(emoji: String, replyId: Int, contactKey: String) = safeLaunch(tag = "sendReaction") {
|
||||
serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey))
|
||||
}
|
||||
|
||||
fun deleteMessages(uuidList: List<Long>) =
|
||||
viewModelScope.launch(ioDispatcher) { packetRepository.deleteMessages(uuidList) }
|
||||
safeLaunch(context = ioDispatcher, tag = "deleteMessages") { packetRepository.deleteMessages(uuidList) }
|
||||
|
||||
fun clearUnreadCount(contact: String, messageUuid: Long, lastReadTimestamp: Long) =
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
safeLaunch(context = ioDispatcher, tag = "clearUnreadCount") {
|
||||
val existingTimestamp = contactSettings.value[contact]?.lastReadMessageTimestamp ?: Long.MIN_VALUE
|
||||
if (lastReadTimestamp <= existingTimestamp) {
|
||||
return@launch
|
||||
return@safeLaunch
|
||||
}
|
||||
packetRepository.clearUnreadCount(contact, lastReadTimestamp)
|
||||
packetRepository.updateLastReadMessage(contact, messageUuid, lastReadTimestamp)
|
||||
|
|
|
|||
|
|
@ -17,12 +17,11 @@
|
|||
package org.meshtastic.feature.messaging
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.database.entity.QuickChatAction
|
||||
import org.meshtastic.core.repository.QuickChatActionRepository
|
||||
import org.meshtastic.core.ui.viewmodel.safeLaunch
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
|
||||
@KoinViewModel
|
||||
|
|
@ -31,7 +30,7 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR
|
|||
get() = quickChatActionRepository.getAllActions().stateInWhileSubscribed(initialValue = emptyList())
|
||||
|
||||
fun updateActionPositions(actions: List<QuickChatAction>) {
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
safeLaunch(context = ioDispatcher, tag = "updateActionPositions") {
|
||||
for (position in actions.indices) {
|
||||
quickChatActionRepository.setItemPosition(actions[position].uuid, position)
|
||||
}
|
||||
|
|
@ -39,8 +38,8 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR
|
|||
}
|
||||
|
||||
fun addQuickChatAction(action: QuickChatAction) =
|
||||
viewModelScope.launch(ioDispatcher) { quickChatActionRepository.upsert(action) }
|
||||
safeLaunch(context = ioDispatcher, tag = "addQuickChatAction") { quickChatActionRepository.upsert(action) }
|
||||
|
||||
fun deleteQuickChatAction(action: QuickChatAction) =
|
||||
viewModelScope.launch(ioDispatcher) { quickChatActionRepository.delete(action) }
|
||||
safeLaunch(context = ioDispatcher, tag = "deleteQuickChatAction") { quickChatActionRepository.delete(action) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.Flow
|
|||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.model.Contact
|
||||
|
|
@ -37,6 +36,7 @@ import org.meshtastic.core.repository.NodeRepository
|
|||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.ui.viewmodel.safeLaunch
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import kotlin.collections.map as collectionsMap
|
||||
|
|
@ -188,17 +188,20 @@ class ContactsViewModel(
|
|||
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
|
||||
|
||||
fun deleteContacts(contacts: List<String>) =
|
||||
viewModelScope.launch(ioDispatcher) { packetRepository.deleteContacts(contacts) }
|
||||
safeLaunch(context = ioDispatcher, tag = "deleteContacts") { packetRepository.deleteContacts(contacts) }
|
||||
|
||||
fun markAllAsRead() = viewModelScope.launch(ioDispatcher) { packetRepository.clearAllUnreadCounts() }
|
||||
fun markAllAsRead() =
|
||||
safeLaunch(context = ioDispatcher, tag = "markAllAsRead") { packetRepository.clearAllUnreadCounts() }
|
||||
|
||||
fun setMuteUntil(contacts: List<String>, until: Long) =
|
||||
viewModelScope.launch(ioDispatcher) { packetRepository.setMuteUntil(contacts, until) }
|
||||
safeLaunch(context = ioDispatcher, tag = "setMuteUntil") { packetRepository.setMuteUntil(contacts, until) }
|
||||
|
||||
fun getContactSettings() = packetRepository.getContactSettings()
|
||||
|
||||
fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) {
|
||||
viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
|
||||
safeLaunch(context = ioDispatcher, tag = "setContactFilteringDisabled") {
|
||||
packetRepository.setContactFilteringDisabled(contactKey, disabled)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.flatMapLatest
|
|||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import okio.ByteString.Companion.decodeBase64
|
||||
|
|
@ -60,6 +59,7 @@ import org.meshtastic.core.resources.traceroute
|
|||
import org.meshtastic.core.resources.view_on_map
|
||||
import org.meshtastic.core.ui.util.AlertManager
|
||||
import org.meshtastic.core.ui.util.toMessageRes
|
||||
import org.meshtastic.core.ui.viewmodel.safeLaunch
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.feature.node.detail.NodeRequestActions
|
||||
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
|
||||
|
|
@ -181,7 +181,8 @@ open class MetricsViewModel(
|
|||
|
||||
fun getUser(nodeNum: Int) = nodeRepository.getUser(nodeNum)
|
||||
|
||||
fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) }
|
||||
fun deleteLog(uuid: String) =
|
||||
safeLaunch(context = dispatchers.io, tag = "deleteLog") { meshLogRepository.deleteLog(uuid) }
|
||||
|
||||
fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? {
|
||||
val cached = tracerouteOverlayCache.value[requestId]
|
||||
|
|
@ -216,7 +217,7 @@ open class MetricsViewModel(
|
|||
private fun List<Node>.numSet(): Set<Int> = map { it.num }.toSet()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "tracerouteCollector") {
|
||||
serviceRepository.tracerouteResponse.filterNotNull().collect { response ->
|
||||
val overlay =
|
||||
TracerouteOverlay(
|
||||
|
|
@ -232,7 +233,7 @@ open class MetricsViewModel(
|
|||
Logger.d { "MetricsViewModel created" }
|
||||
}
|
||||
|
||||
fun clearPosition() = viewModelScope.launch(dispatchers.io) {
|
||||
fun clearPosition() = safeLaunch(context = dispatchers.io, tag = "clearPosition") {
|
||||
(manualNodeId.value ?: nodeIdFromRoute)?.let {
|
||||
meshLogRepository.deleteLogs(it, PortNum.POSITION_APP.value)
|
||||
}
|
||||
|
|
@ -276,7 +277,7 @@ open class MetricsViewModel(
|
|||
overlay: TracerouteOverlay?,
|
||||
onViewOnMap: (Int, String) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "showTracerouteDetail") {
|
||||
val snapshotPositions = tracerouteSnapshotRepository.getSnapshotPositions(responseLogUuid).first()
|
||||
alertManager.showAlert(
|
||||
titleRes = Res.string.traceroute,
|
||||
|
|
@ -299,7 +300,7 @@ open class MetricsViewModel(
|
|||
if (errorRes != null) {
|
||||
// Post the error alert after the current alert is dismissed to avoid
|
||||
// the wrapping dismissAlert() in AlertManager immediately clearing it.
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "tracerouteError") {
|
||||
alertManager.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -336,7 +337,7 @@ open class MetricsViewModel(
|
|||
epochSeconds: (T) -> Long,
|
||||
rowMapper: (T) -> String,
|
||||
) {
|
||||
viewModelScope.launch(dispatchers.io) {
|
||||
safeLaunch(context = dispatchers.io, tag = "exportCsv") {
|
||||
fileService.write(uri) { sink ->
|
||||
sink.writeUtf8(header)
|
||||
rows.forEach { item ->
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
package org.meshtastic.feature.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -25,7 +24,6 @@ import kotlinx.coroutines.flow.flatMapLatest
|
|||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import okio.BufferedSink
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
|
|
@ -51,6 +49,7 @@ import org.meshtastic.core.repository.NodeRepository
|
|||
import org.meshtastic.core.repository.NotificationPrefs
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.core.ui.viewmodel.safeLaunch
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
|
||||
|
|
@ -146,12 +145,12 @@ class SettingsViewModel(
|
|||
val meshLogLoggingEnabled: StateFlow<Boolean> = _meshLogLoggingEnabled.asStateFlow()
|
||||
|
||||
fun setMeshLogRetentionDays(days: Int) {
|
||||
viewModelScope.launch { setMeshLogSettingsUseCase.setRetentionDays(days) }
|
||||
safeLaunch(tag = "setMeshLogRetentionDays") { setMeshLogSettingsUseCase.setRetentionDays(days) }
|
||||
_meshLogRetentionDays.value = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
|
||||
}
|
||||
|
||||
fun setMeshLogLoggingEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) }
|
||||
safeLaunch(tag = "setMeshLogLoggingEnabled") { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) }
|
||||
_meshLogLoggingEnabled.value = enabled
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +182,9 @@ class SettingsViewModel(
|
|||
* @param filterPortnum If provided, only packets with this port number will be exported.
|
||||
*/
|
||||
fun saveDataCsv(uri: MeshtasticUri, filterPortnum: Int? = null) {
|
||||
viewModelScope.launch { fileService.write(uri) { writer -> performDataExport(writer, filterPortnum) } }
|
||||
safeLaunch(tag = "saveDataCsv") {
|
||||
fileService.write(uri) { writer -> performDataExport(writer, filterPortnum) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performDataExport(writer: BufferedSink, filterPortnum: Int?) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.koin.core.annotation.InjectedParam
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
|
|
@ -62,6 +61,7 @@ import org.meshtastic.core.resources.UiText
|
|||
import org.meshtastic.core.resources.cant_shutdown
|
||||
import org.meshtastic.core.resources.timeout
|
||||
import org.meshtastic.core.ui.util.getChannelList
|
||||
import org.meshtastic.core.ui.viewmodel.safeLaunch
|
||||
import org.meshtastic.feature.settings.navigation.ConfigRoute
|
||||
import org.meshtastic.feature.settings.navigation.ModuleRoute
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
|
|
@ -155,7 +155,7 @@ open class RadioConfigViewModel(
|
|||
val radioConfigState: StateFlow<RadioConfigState> = _radioConfigState
|
||||
|
||||
fun setPreserveFavorites(preserveFavorites: Boolean) {
|
||||
viewModelScope.launch { _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } }
|
||||
_radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) }
|
||||
}
|
||||
|
||||
private val _currentDeviceProfile = MutableStateFlow(DeviceProfile())
|
||||
|
|
@ -242,7 +242,7 @@ open class RadioConfigViewModel(
|
|||
|
||||
fun setOwner(user: User) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "setOwner") {
|
||||
_radioConfigState.update { it.copy(userConfig = user) }
|
||||
val packetId = radioConfigUseCase.setOwner(destNum, user)
|
||||
registerRequestId(packetId)
|
||||
|
|
@ -252,14 +252,14 @@ open class RadioConfigViewModel(
|
|||
fun updateChannels(new: List<ChannelSettings>, old: List<ChannelSettings>) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
getChannelList(new, old).forEach { channel ->
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "setRemoteChannel") {
|
||||
val packetId = radioConfigUseCase.setRemoteChannel(destNum, channel)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
|
||||
if (destNum == myNodeNum) {
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "migrateChannels") {
|
||||
packetRepository.migrateChannelsByPSK(old, new)
|
||||
radioConfigRepository.replaceAllSettings(new)
|
||||
}
|
||||
|
|
@ -269,7 +269,7 @@ open class RadioConfigViewModel(
|
|||
|
||||
fun setConfig(config: Config) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "setConfig") {
|
||||
_radioConfigState.update { state ->
|
||||
state.copy(
|
||||
radioConfig =
|
||||
|
|
@ -293,7 +293,7 @@ open class RadioConfigViewModel(
|
|||
@Suppress("CyclomaticComplexMethod")
|
||||
fun setModuleConfig(config: ModuleConfig) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "setModuleConfig") {
|
||||
_radioConfigState.update { state ->
|
||||
state.copy(
|
||||
moduleConfig =
|
||||
|
|
@ -326,13 +326,13 @@ open class RadioConfigViewModel(
|
|||
fun setRingtone(ringtone: String) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
_radioConfigState.update { it.copy(ringtone = ringtone) }
|
||||
viewModelScope.launch { radioConfigUseCase.setRingtone(destNum, ringtone) }
|
||||
safeLaunch(tag = "setRingtone") { radioConfigUseCase.setRingtone(destNum, ringtone) }
|
||||
}
|
||||
|
||||
fun setCannedMessages(messages: String) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
_radioConfigState.update { it.copy(cannedMessageMessages = messages) }
|
||||
viewModelScope.launch { radioConfigUseCase.setCannedMessages(destNum, messages) }
|
||||
safeLaunch(tag = "setCannedMessages") { radioConfigUseCase.setCannedMessages(destNum, messages) }
|
||||
}
|
||||
|
||||
private fun sendAdminRequest(destNum: Int) {
|
||||
|
|
@ -343,7 +343,7 @@ open class RadioConfigViewModel(
|
|||
|
||||
when (route) {
|
||||
AdminRoute.REBOOT.name ->
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "reboot") {
|
||||
val packetId = adminActionsUseCase.reboot(destNum)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
|
|
@ -352,7 +352,7 @@ open class RadioConfigViewModel(
|
|||
if (metadata?.canShutdown != true) {
|
||||
sendError(Res.string.cant_shutdown)
|
||||
} else {
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "shutdown") {
|
||||
val packetId = adminActionsUseCase.shutdown(destNum)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
|
|
@ -360,13 +360,13 @@ open class RadioConfigViewModel(
|
|||
}
|
||||
|
||||
AdminRoute.FACTORY_RESET.name ->
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "factoryReset") {
|
||||
val isLocal = (destNum == myNodeNum)
|
||||
val packetId = adminActionsUseCase.factoryReset(destNum, isLocal)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
AdminRoute.NODEDB_RESET.name ->
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "nodedbReset") {
|
||||
val isLocal = (destNum == myNodeNum)
|
||||
val packetId = adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal)
|
||||
registerRequestId(packetId)
|
||||
|
|
@ -376,55 +376,43 @@ open class RadioConfigViewModel(
|
|||
|
||||
fun setFixedPosition(position: Position) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
viewModelScope.launch { radioConfigUseCase.setFixedPosition(destNum, position) }
|
||||
safeLaunch(tag = "setFixedPosition") { radioConfigUseCase.setFixedPosition(destNum, position) }
|
||||
}
|
||||
|
||||
fun removeFixedPosition() {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
viewModelScope.launch { radioConfigUseCase.removeFixedPosition(destNum) }
|
||||
safeLaunch(tag = "removeFixedPosition") { radioConfigUseCase.removeFixedPosition(destNum) }
|
||||
}
|
||||
|
||||
fun importProfile(uri: MeshtasticUri, onResult: (DeviceProfile) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
var profile: DeviceProfile? = null
|
||||
fileService.read(uri) { source ->
|
||||
importProfileUseCase(source).onSuccess { profile = it }.onFailure { throw it }
|
||||
}
|
||||
profile?.let { onResult(it) }
|
||||
} catch (ex: Exception) {
|
||||
Logger.e { "Import DeviceProfile error: ${ex.message}" }
|
||||
safeLaunch(tag = "importProfile") {
|
||||
var profile: DeviceProfile? = null
|
||||
fileService.read(uri) { source ->
|
||||
importProfileUseCase(source).onSuccess { profile = it }.onFailure { throw it }
|
||||
}
|
||||
profile?.let { onResult(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun exportProfile(uri: MeshtasticUri, profile: DeviceProfile) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
fileService.write(uri) { sink ->
|
||||
exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it }
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Logger.e { "Can't write file error: ${ex.message}" }
|
||||
safeLaunch(tag = "exportProfile") {
|
||||
fileService.write(uri) { sink ->
|
||||
exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun exportSecurityConfig(uri: MeshtasticUri, securityConfig: Config.SecurityConfig) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
fileService.write(uri) { sink ->
|
||||
exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it }
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Logger.e { "Can't write security keys JSON error: ${ex.message}" }
|
||||
safeLaunch(tag = "exportSecurityConfig") {
|
||||
fileService.write(uri) { sink ->
|
||||
exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun installProfile(protobuf: DeviceProfile) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
viewModelScope.launch { installProfileUseCase(destNum, protobuf, destNode.value?.user) }
|
||||
safeLaunch(tag = "installProfile") { installProfileUseCase(destNum, protobuf, destNode.value?.user) }
|
||||
}
|
||||
|
||||
fun clearPacketResponse() {
|
||||
|
|
@ -439,17 +427,17 @@ open class RadioConfigViewModel(
|
|||
|
||||
when (route) {
|
||||
ConfigRoute.USER ->
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "getOwner") {
|
||||
val packetId = radioConfigUseCase.getOwner(destNum)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
|
||||
ConfigRoute.CHANNELS -> {
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "getChannel0") {
|
||||
val packetId = radioConfigUseCase.getChannel(destNum, 0)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "getLoraConfig") {
|
||||
val packetId = radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.LORA_CONFIG.value)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
|
|
@ -458,7 +446,7 @@ open class RadioConfigViewModel(
|
|||
}
|
||||
|
||||
is AdminRoute -> {
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "getSessionKeyConfig") {
|
||||
val packetId =
|
||||
radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value)
|
||||
registerRequestId(packetId)
|
||||
|
|
@ -468,18 +456,18 @@ open class RadioConfigViewModel(
|
|||
|
||||
is ConfigRoute -> {
|
||||
if (route == ConfigRoute.LORA) {
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "getChannel0ForLora") {
|
||||
val packetId = radioConfigUseCase.getChannel(destNum, 0)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
if (route == ConfigRoute.NETWORK) {
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "getConnectionStatus") {
|
||||
val packetId = radioConfigUseCase.getDeviceConnectionStatus(destNum)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "getConfig") {
|
||||
val packetId = radioConfigUseCase.getConfig(destNum, route.type)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
|
|
@ -487,18 +475,18 @@ open class RadioConfigViewModel(
|
|||
|
||||
is ModuleRoute -> {
|
||||
if (route == ModuleRoute.CANNED_MESSAGE) {
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "getCannedMessages") {
|
||||
val packetId = radioConfigUseCase.getCannedMessages(destNum)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
if (route == ModuleRoute.EXT_NOTIFICATION) {
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "getRingtone") {
|
||||
val packetId = radioConfigUseCase.getRingtone(destNum)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "getModuleConfig") {
|
||||
val packetId = radioConfigUseCase.getModuleConfig(destNum, route.type)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
|
|
@ -568,7 +556,7 @@ open class RadioConfigViewModel(
|
|||
}
|
||||
|
||||
val requestTimeout = 30.seconds
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "requestTimeout") {
|
||||
delay(requestTimeout)
|
||||
if (requestIds.value.contains(packetId)) {
|
||||
requestIds.update { it.apply { remove(packetId) } }
|
||||
|
|
@ -628,7 +616,7 @@ open class RadioConfigViewModel(
|
|||
val index = response.index
|
||||
if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
|
||||
// Not done yet, request next channel
|
||||
viewModelScope.launch {
|
||||
safeLaunch(tag = "getNextChannel") {
|
||||
val packetId = radioConfigUseCase.getChannel(destNum, index + 1)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue