Decouple `SettingsScreen` from `UiViewModel` (#3137)

pull/3138/head
Phil Oliver 2025-09-18 07:40:33 -04:00 zatwierdzone przez GitHub
rodzic 48da34ce1a
commit eedc3ef963
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
7 zmienionych plików z 364 dodań i 251 usunięć

Wyświetl plik

@ -106,16 +106,16 @@ class MainActivity :
SideEffect { AppCompatDelegate.setDefaultNightMode(theme) }
}
val showAppIntro by model.showAppIntro.collectAsStateWithLifecycle()
if (showAppIntro) {
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
if (appIntroCompleted) {
MainScreen(uIViewModel = model, bluetoothViewModel = bluetoothViewModel)
} else {
AppIntroductionScreen(
onDone = {
model.onAppIntroCompleted()
(application as GeeksvilleApplication).askToRate(this@MainActivity)
},
)
} else {
MainScreen(uIViewModel = model, bluetoothViewModel = bluetoothViewModel)
}
}
}

Wyświetl plik

@ -25,12 +25,15 @@ import com.geeksville.mesh.util.LanguageUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.util.concurrent.ConcurrentHashMap
interface UiPrefs {
var lang: String
var theme: Int
val themeFlow: StateFlow<Int>
var appIntroCompleted: Boolean
val appIntroCompletedFlow: StateFlow<Boolean>
var hasShownNotPairedWarning: Boolean
var nodeSortOption: Int
var includeUnknown: Boolean
@ -45,19 +48,35 @@ interface UiPrefs {
fun setShouldProvideNodeLocation(nodeNum: Int, value: Boolean)
}
const val KEY_THEME = "theme"
const val KEY_APP_INTRO_COMPLETED = "app_intro_completed"
class UiPrefsImpl(private val prefs: SharedPreferences) : UiPrefs {
override var theme: Int by PrefDelegate(prefs, KEY_THEME, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
private var _themeFlow = MutableStateFlow(theme)
override val themeFlow = _themeFlow.asStateFlow()
override var appIntroCompleted: Boolean by PrefDelegate(prefs, KEY_APP_INTRO_COMPLETED, false)
private var _appIntroCompletedFlow = MutableStateFlow(appIntroCompleted)
override val appIntroCompletedFlow = _appIntroCompletedFlow.asStateFlow()
// Maps nodeNum to a flow for the for the "provide-location-nodeNum" pref
private val provideNodeLocationFlows = ConcurrentHashMap<Int, MutableStateFlow<Boolean>>()
private val sharedPreferencesListener =
SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
// Check if the changed key is one of our node location keys
provideNodeLocationFlows.keys.forEach { nodeNum ->
if (key == provideLocationKey(nodeNum)) {
val newValue = sharedPreferences.getBoolean(key, false)
provideNodeLocationFlows[nodeNum]?.tryEmit(newValue)
}
when (key) {
KEY_THEME -> _themeFlow.update { theme }
KEY_APP_INTRO_COMPLETED -> _appIntroCompletedFlow.update { appIntroCompleted }
// Check if the changed key is one of our node location keys
else ->
provideNodeLocationFlows.keys.forEach { nodeNum ->
if (key == provideLocationKey(nodeNum)) {
val newValue = sharedPreferences.getBoolean(key, false)
provideNodeLocationFlows[nodeNum]?.tryEmit(newValue)
}
}
}
}
@ -66,8 +85,6 @@ class UiPrefsImpl(private val prefs: SharedPreferences) : UiPrefs {
}
override var lang: String by PrefDelegate(prefs, "lang", LanguageUtils.SYSTEM_DEFAULT)
override var theme: Int by PrefDelegate(prefs, "theme", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
override var appIntroCompleted: Boolean by PrefDelegate(prefs, "app_intro_completed", false)
override var hasShownNotPairedWarning: Boolean by PrefDelegate(prefs, "has_shown_not_paired_warning", false)
override var nodeSortOption: Int by PrefDelegate(prefs, "node-sort-option", NodeSortOption.VIA_FAVORITE.ordinal)
override var includeUnknown: Boolean by PrefDelegate(prefs, "include-unknown", false)

Wyświetl plik

@ -37,7 +37,6 @@ import com.geeksville.mesh.IMeshService
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.Portnums
import com.geeksville.mesh.Position
import com.geeksville.mesh.R
import com.geeksville.mesh.android.Logging
@ -58,14 +57,12 @@ import com.geeksville.mesh.database.entity.asDeviceVersion
import com.geeksville.mesh.repository.api.DeviceHardwareRepository
import com.geeksville.mesh.repository.api.FirmwareReleaseRepository
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.repository.location.LocationRepository
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.service.MeshServiceNotifications
import com.geeksville.mesh.service.ServiceAction
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.util.getShortDate
import com.geeksville.mesh.util.positionToMeter
import com.geeksville.mesh.util.safeNumber
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
@ -79,7 +76,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
@ -88,14 +84,7 @@ import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Locale
import javax.inject.Inject
import kotlin.math.roundToInt
// Given a human name, strip out the first letter of the first three words and return that as the
// initials for
@ -197,31 +186,17 @@ constructor(
private val deviceHardwareRepository: DeviceHardwareRepository,
private val packetRepository: PacketRepository,
private val quickChatActionRepository: QuickChatActionRepository,
private val locationRepository: LocationRepository,
firmwareReleaseRepository: FirmwareReleaseRepository,
private val uiPrefs: UiPrefs,
private val meshServiceNotifications: MeshServiceNotifications,
) : ViewModel(),
Logging {
private val _theme = MutableStateFlow(uiPrefs.theme)
val theme: StateFlow<Int> = _theme.asStateFlow()
fun setTheme(theme: Int) {
_theme.value = theme
uiPrefs.theme = theme
}
val theme: StateFlow<Int> = uiPrefs.themeFlow
private val _lastTraceRouteTime = MutableStateFlow<Long?>(null)
val lastTraceRouteTime: StateFlow<Long?> = _lastTraceRouteTime.asStateFlow()
private val _excludedModulesUnlocked = MutableStateFlow(false)
val excludedModulesUnlocked: StateFlow<Boolean> = _excludedModulesUnlocked.asStateFlow()
fun unlockExcludedModules() {
viewModelScope.launch { _excludedModulesUnlocked.value = true }
}
val firmwareVersion = myNodeInfo.mapNotNull { nodeInfo -> nodeInfo?.firmwareVersion }
val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmwareEdition }
@ -294,16 +269,13 @@ constructor(
viewModelScope.launch { _title.value = title }
}
val receivingLocationUpdates: StateFlow<Boolean>
get() = locationRepository.receivingLocationUpdates
val meshService: IMeshService?
get() = radioConfigRepository.meshService
private val _localConfig = MutableStateFlow<LocalConfig>(LocalConfig.getDefaultInstance())
val localConfig: StateFlow<LocalConfig> = _localConfig
private val localConfig = MutableStateFlow<LocalConfig>(LocalConfig.getDefaultInstance())
val config
get() = _localConfig.value
get() = localConfig.value
private val _moduleConfig = MutableStateFlow<LocalModuleConfig>(LocalModuleConfig.getDefaultInstance())
val moduleConfig: StateFlow<LocalModuleConfig> = _moduleConfig
@ -482,7 +454,7 @@ constructor(
}
.launchIn(viewModelScope)
radioConfigRepository.localConfigFlow.onEach { config -> _localConfig.value = config }.launchIn(viewModelScope)
radioConfigRepository.localConfigFlow.onEach { config -> localConfig.value = config }.launchIn(viewModelScope)
radioConfigRepository.moduleConfigFlow
.onEach { config -> _moduleConfig.value = config }
.launchIn(viewModelScope)
@ -810,23 +782,6 @@ constructor(
if (config.lora != newConfig.lora) setConfig(newConfig)
}
val provideLocation: StateFlow<Boolean>
get() =
myNodeInfo
.flatMapLatest { myNodeEntity ->
// When myNodeInfo changes, set up emissions for the "provide-location-nodeNum" pref.
if (myNodeEntity == null) {
flowOf(false)
} else {
uiPrefs.shouldProvideNodeLocation(myNodeEntity.myNodeNum)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
fun setProvideLocation(value: Boolean) {
myNodeNum?.let { uiPrefs.setShouldProvideNodeLocation(it, value) }
}
fun setOwner(name: String) {
val user =
ourNodeInfo.value?.user?.copy {
@ -842,140 +797,6 @@ constructor(
}
}
/**
* Export all persisted packet data to a CSV file at the given URI.
*
* The CSV will include all packets, or only those matching the given port number if specified. Each row contains:
* date, time, sender node number, sender name, sender latitude, sender longitude, receiver latitude, receiver
* longitude, receiver elevation, received SNR, distance, hop limit, and payload.
*
* @param uri The destination URI for the CSV file.
* @param filterPortnum If provided, only packets with this port number will be exported.
*/
@Suppress("detekt:CyclomaticComplexMethod", "detekt:LongMethod")
fun saveDataCsv(uri: Uri, filterPortnum: Int? = null) {
viewModelScope.launch(Dispatchers.Main) {
// Extract distances to this device from position messages and put (node,SNR,distance)
// in the file_uri
val myNodeNum = myNodeNum ?: return@launch
// Capture the current node value while we're still on main thread
val nodes = nodeDB.nodeDBbyNum.value
val positionToPos: (MeshProtos.Position?) -> Position? = { meshPosition ->
meshPosition?.let { Position(it) }.takeIf { it?.isValid() == true }
}
writeToUri(uri) { writer ->
val nodePositions = mutableMapOf<Int, MeshProtos.Position?>()
@Suppress("MaxLineLength")
writer.appendLine(
"\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance\",\"hop limit\",\"payload\"",
)
// Packets are ordered by time, we keep most recent position of
// our device in localNodePosition.
val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
meshLogRepository.getAllLogsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet ->
// If we get a NodeInfo packet, use it to update our position data (if valid)
packet.nodeInfo?.let { nodeInfo ->
positionToPos.invoke(nodeInfo.position)?.let { nodePositions[nodeInfo.num] = nodeInfo.position }
}
packet.meshPacket?.let { proto ->
// If the packet contains position data then use it to update, if valid
packet.position?.let { position ->
positionToPos.invoke(position)?.let {
nodePositions[
proto.from.takeIf { it != 0 } ?: myNodeNum,
] = position
}
}
// packets must have rxSNR, and optionally match the filter given as a param.
if (
(filterPortnum == null || proto.decoded.portnumValue == filterPortnum) &&
proto.rxSnr != 0.0f
) {
val rxDateTime = dateFormat.format(packet.received_date)
val rxFrom = proto.from.toUInt()
val senderName = nodes[proto.from]?.user?.longName ?: ""
// sender lat & long
val senderPosition = nodePositions[proto.from]
val senderPos = positionToPos.invoke(senderPosition)
val senderLat = senderPos?.latitude ?: ""
val senderLong = senderPos?.longitude ?: ""
// rx lat, long, and elevation
val rxPosition = nodePositions[myNodeNum]
val rxPos = positionToPos.invoke(rxPosition)
val rxLat = rxPos?.latitude ?: ""
val rxLong = rxPos?.longitude ?: ""
val rxAlt = rxPos?.altitude ?: ""
val rxSnr = proto.rxSnr
// Calculate the distance if both positions are valid
val dist =
if (senderPos == null || rxPos == null) {
""
} else {
positionToMeter(
Position(rxPosition!!), // Use rxPosition but only if rxPos was
// valid
Position(senderPosition!!), // Use senderPosition but only if
// senderPos was valid
)
.roundToInt()
.toString()
}
val hopLimit = proto.hopLimit
val payload =
when {
proto.decoded.portnumValue !in
setOf(
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
Portnums.PortNum.RANGE_TEST_APP_VALUE,
) -> "<${proto.decoded.portnum}>"
proto.hasDecoded() -> proto.decoded.payload.toStringUtf8().replace("\"", "\"\"")
proto.hasEncrypted() -> "${proto.encrypted.size()} encrypted bytes"
else -> ""
}
// date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx
// elevation,rx
// snr,distance,hop limit,payload
@Suppress("MaxLineLength")
writer.appendLine(
"$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",
)
}
}
}
}
}
}
private suspend inline fun writeToUri(uri: Uri, crossinline block: suspend (BufferedWriter) -> Unit) {
withContext(Dispatchers.IO) {
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileWriter(parcelFileDescriptor.fileDescriptor).use { fileWriter ->
BufferedWriter(fileWriter).use { writer -> block.invoke(writer) }
}
}
} catch (ex: FileNotFoundException) {
errormsg("Can't write file error: ${ex.message}")
}
}
}
fun addQuickChatAction(action: QuickChatAction) =
viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.upsert(action) }
@ -1001,19 +822,9 @@ constructor(
nodeFilterText.value = text
}
// region Main menu actions logic
private val _showAppIntro: MutableStateFlow<Boolean> = MutableStateFlow(!uiPrefs.appIntroCompleted)
val showAppIntro: StateFlow<Boolean> = _showAppIntro.asStateFlow()
fun showAppIntro() {
_showAppIntro.update { true }
}
// endregion
val appIntroCompleted: StateFlow<Boolean> = uiPrefs.appIntroCompletedFlow
fun onAppIntroCompleted() {
uiPrefs.appIntroCompleted = true
_showAppIntro.update { false }
}
}

Wyświetl plik

@ -57,7 +57,6 @@ import androidx.navigation.navigation
import com.geeksville.mesh.AdminProtos
import com.geeksville.mesh.MeshProtos.DeviceMetadata
import com.geeksville.mesh.R
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.debug.DebugScreen
import com.geeksville.mesh.ui.settings.SettingsScreen
import com.geeksville.mesh.ui.settings.radio.CleanNodeDatabaseScreen
@ -90,7 +89,7 @@ fun getNavRouteFrom(routeName: String): Route? =
ConfigRoute.entries.find { it.name == routeName }?.route ?: ModuleRoute.entries.find { it.name == routeName }?.route
@Suppress("LongMethod")
fun NavGraphBuilder.settingsGraph(navController: NavHostController, uiViewModel: UIViewModel) {
fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
navigation<SettingsRoutes.SettingsGraph>(startDestination = SettingsRoutes.Settings()) {
composable<SettingsRoutes.Settings>(
deepLinks = listOf(navDeepLink<SettingsRoutes.Settings>(basePath = "$DEEP_LINK_BASE_URI/settings")),
@ -98,7 +97,6 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController, uiViewModel:
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
SettingsScreen(
uiViewModel = uiViewModel,
viewModel = hiltViewModel(parentEntry),
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {

Wyświetl plik

@ -397,7 +397,7 @@ fun MainScreen(
mapGraph(navController, uiViewModel = uIViewModel)
channelsGraph(navController, uiViewModel = uIViewModel)
connectionsGraph(navController, bluetoothViewModel)
settingsGraph(navController, uiViewModel = uIViewModel)
settingsGraph(navController)
}
}
}

Wyświetl plik

@ -40,7 +40,6 @@ import androidx.compose.material.icons.rounded.WavingHand
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@ -55,12 +54,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile
import com.geeksville.mesh.R
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.gpsDisabled
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.navigation.getNavRouteFrom
import com.geeksville.mesh.ui.common.components.MainAppBar
import com.geeksville.mesh.ui.common.components.MultipleChoiceAlertDialog
import com.geeksville.mesh.ui.common.components.TitledCard
import com.geeksville.mesh.ui.common.theme.MODE_DYNAMIC
import com.geeksville.mesh.ui.node.components.NodeMenuAction
@ -84,15 +82,15 @@ import kotlin.time.Duration.Companion.seconds
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun SettingsScreen(
settingsViewModel: SettingsViewModel = hiltViewModel(),
viewModel: RadioConfigViewModel = hiltViewModel(),
uiViewModel: UIViewModel = hiltViewModel(),
onClickNodeChip: (Int) -> Unit = {},
onNavigate: (Route) -> Unit = {},
) {
val excludedModulesUnlocked by uiViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
val localConfig by uiViewModel.localConfig.collectAsStateWithLifecycle()
val ourNode by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by uiViewModel.isConnectedStateFlow.collectAsStateWithLifecycle(false)
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle()
val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by settingsViewModel.isConnected.collectAsStateWithLifecycle(false)
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
var isWaiting by remember { mutableStateOf(false) }
@ -166,6 +164,19 @@ fun SettingsScreen(
)
}
var showLanguagePickerDialog by remember { mutableStateOf(false) }
if (showLanguagePickerDialog) {
LanguagePickerDialog { showLanguagePickerDialog = false }
}
var showThemePickerDialog by remember { mutableStateOf(false) }
if (showThemePickerDialog) {
ThemePickerDialog(
onClickTheme = { settingsViewModel.setTheme(it) },
onDismiss = { showThemePickerDialog = false },
)
}
Scaffold(
topBar = {
MainAppBar(
@ -227,22 +238,27 @@ fun SettingsScreen(
val locationPermissionsState =
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
val isGpsDisabled = context.gpsDisabled()
val provideLocation by uiViewModel.provideLocation.collectAsState(false)
val provideLocation by settingsViewModel.provideLocation.collectAsStateWithLifecycle()
LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
if (provideLocation) {
if (locationPermissionsState.allPermissionsGranted) {
if (!isGpsDisabled) {
uiViewModel.meshService?.startProvideLocation()
settingsViewModel.meshService?.startProvideLocation()
} else {
uiViewModel.showSnackBar(context.getString(R.string.location_disabled))
Toast.makeText(
context,
context.getString(R.string.location_disabled),
Toast.LENGTH_LONG,
)
.show()
}
} else {
// Request permissions if not granted and user wants to provide location
locationPermissionsState.launchMultiplePermissionRequest()
}
} else {
uiViewModel.meshService?.stopProvideLocation()
settingsViewModel.meshService?.stopProvideLocation()
}
}
@ -252,51 +268,30 @@ fun SettingsScreen(
enabled = !isGpsDisabled,
checked = provideLocation,
) {
uiViewModel.setProvideLocation(!provideLocation)
settingsViewModel.setProvideLocation(!provideLocation)
}
val languageTags = remember { LanguageUtils.getLanguageTags(context) }
SettingsItem(
text = stringResource(R.string.preferences_language),
leadingIcon = Icons.Rounded.Language,
trailingIcon = null,
) {
val lang = LanguageUtils.getLocale()
debug("Lang from prefs: $lang")
val langMap = languageTags.mapValues { (_, value) -> { LanguageUtils.setLocale(value) } }
uiViewModel.showAlert(
title = context.getString(R.string.preferences_language),
message = "",
choices = langMap,
)
showLanguagePickerDialog = true
}
val themeMap = remember {
mapOf(
context.getString(R.string.dynamic) to MODE_DYNAMIC,
context.getString(R.string.theme_light) to AppCompatDelegate.MODE_NIGHT_NO,
context.getString(R.string.theme_dark) to AppCompatDelegate.MODE_NIGHT_YES,
context.getString(R.string.theme_system) to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
)
}
SettingsItem(
text = stringResource(R.string.theme),
leadingIcon = Icons.Rounded.FormatPaint,
trailingIcon = null,
) {
uiViewModel.showAlert(
title = context.getString(R.string.choose_theme),
message = "",
choices = themeMap.mapValues { (_, value) -> { uiViewModel.setTheme(value) } },
)
showThemePickerDialog = true
}
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val exportRangeTestLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> uiViewModel.saveDataCsv(uri) }
it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
}
}
SettingsItem(
@ -316,7 +311,7 @@ fun SettingsScreen(
val exportDataLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> uiViewModel.saveDataCsv(uri) }
it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
}
}
SettingsItem(
@ -338,10 +333,10 @@ fun SettingsScreen(
leadingIcon = Icons.Rounded.WavingHand,
trailingIcon = null,
) {
uiViewModel.showAppIntro()
settingsViewModel.showAppIntro()
}
AppVersionButton(excludedModulesUnlocked) { uiViewModel.unlockExcludedModules() }
AppVersionButton(excludedModulesUnlocked) { settingsViewModel.unlockExcludedModules() }
}
}
}
@ -384,3 +379,39 @@ private fun AppVersionButton(excludedModulesUnlocked: Boolean, onUnlockExcludedM
}
}
}
@Composable
private fun LanguagePickerDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
val languages = remember {
LanguageUtils.getLanguageTags(context).mapValues { (_, value) -> { LanguageUtils.setLocale(value) } }
}
MultipleChoiceAlertDialog(
title = stringResource(R.string.preferences_language),
message = "",
choices = languages,
onDismissRequest = onDismiss,
)
}
@Composable
private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
val context = LocalContext.current
val themeMap = remember {
mapOf(
context.getString(R.string.dynamic) to MODE_DYNAMIC,
context.getString(R.string.theme_light) to AppCompatDelegate.MODE_NIGHT_NO,
context.getString(R.string.theme_dark) to AppCompatDelegate.MODE_NIGHT_YES,
context.getString(R.string.theme_system) to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
)
.mapValues { (_, value) -> { onClickTheme(value) } }
}
MultipleChoiceAlertDialog(
title = stringResource(R.string.choose_theme),
message = "",
choices = themeMap,
onDismissRequest = onDismiss,
)
}

Wyświetl plik

@ -0,0 +1,256 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.settings
import android.app.Application
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.IMeshService
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.Portnums
import com.geeksville.mesh.Position
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.android.prefs.UiPrefs
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.NodeRepository
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.util.positionToMeter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Locale
import javax.inject.Inject
import kotlin.math.roundToInt
@HiltViewModel
class SettingsViewModel
@Inject
constructor(
private val app: Application,
private val radioConfigRepository: RadioConfigRepository,
private val nodeRepository: NodeRepository,
private val meshLogRepository: MeshLogRepository,
private val uiPrefs: UiPrefs,
) : ViewModel(),
Logging {
val myNodeInfo: StateFlow<MyNodeEntity?> = nodeRepository.myNodeInfo
val myNodeNum
get() = myNodeInfo.value?.myNodeNum
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
val isConnected =
radioConfigRepository.connectionState
.map { it.isConnected() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), false)
val localConfig: StateFlow<LocalConfig> =
radioConfigRepository.localConfigFlow.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000L),
LocalConfig.getDefaultInstance(),
)
val meshService: IMeshService?
get() = radioConfigRepository.meshService
val provideLocation: StateFlow<Boolean> =
myNodeInfo
.flatMapLatest { myNodeEntity ->
// When myNodeInfo changes, set up emissions for the "provide-location-nodeNum" pref.
if (myNodeEntity == null) {
flowOf(false)
} else {
uiPrefs.shouldProvideNodeLocation(myNodeEntity.myNodeNum)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
private val _excludedModulesUnlocked = MutableStateFlow(false)
val excludedModulesUnlocked: StateFlow<Boolean> = _excludedModulesUnlocked.asStateFlow()
fun setProvideLocation(value: Boolean) {
myNodeNum?.let { uiPrefs.setShouldProvideNodeLocation(it, value) }
}
fun setTheme(theme: Int) {
uiPrefs.theme = theme
}
fun showAppIntro() {
uiPrefs.appIntroCompleted = false
}
fun unlockExcludedModules() {
_excludedModulesUnlocked.update { true }
}
/**
* Export all persisted packet data to a CSV file at the given URI.
*
* The CSV will include all packets, or only those matching the given port number if specified. Each row contains:
* date, time, sender node number, sender name, sender latitude, sender longitude, receiver latitude, receiver
* longitude, receiver elevation, received SNR, distance, hop limit, and payload.
*
* @param uri The destination URI for the CSV file.
* @param filterPortnum If provided, only packets with this port number will be exported.
*/
@Suppress("detekt:CyclomaticComplexMethod", "detekt:LongMethod")
fun saveDataCsv(uri: Uri, filterPortnum: Int? = null) {
viewModelScope.launch(Dispatchers.Main) {
// Extract distances to this device from position messages and put (node,SNR,distance)
// in the file_uri
val myNodeNum = myNodeNum ?: return@launch
// Capture the current node value while we're still on main thread
val nodes = nodeRepository.nodeDBbyNum.value
val positionToPos: (MeshProtos.Position?) -> Position? = { meshPosition ->
meshPosition?.let { Position(it) }.takeIf { it?.isValid() == true }
}
writeToUri(uri) { writer ->
val nodePositions = mutableMapOf<Int, MeshProtos.Position?>()
@Suppress("MaxLineLength")
writer.appendLine(
"\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance\",\"hop limit\",\"payload\"",
)
// Packets are ordered by time, we keep most recent position of
// our device in localNodePosition.
val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
meshLogRepository.getAllLogsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet ->
// If we get a NodeInfo packet, use it to update our position data (if valid)
packet.nodeInfo?.let { nodeInfo ->
positionToPos.invoke(nodeInfo.position)?.let { nodePositions[nodeInfo.num] = nodeInfo.position }
}
packet.meshPacket?.let { proto ->
// If the packet contains position data then use it to update, if valid
packet.position?.let { position ->
positionToPos.invoke(position)?.let {
nodePositions[
proto.from.takeIf { it != 0 } ?: myNodeNum,
] = position
}
}
// packets must have rxSNR, and optionally match the filter given as a param.
if (
(filterPortnum == null || proto.decoded.portnumValue == filterPortnum) &&
proto.rxSnr != 0.0f
) {
val rxDateTime = dateFormat.format(packet.received_date)
val rxFrom = proto.from.toUInt()
val senderName = nodes[proto.from]?.user?.longName ?: ""
// sender lat & long
val senderPosition = nodePositions[proto.from]
val senderPos = positionToPos.invoke(senderPosition)
val senderLat = senderPos?.latitude ?: ""
val senderLong = senderPos?.longitude ?: ""
// rx lat, long, and elevation
val rxPosition = nodePositions[myNodeNum]
val rxPos = positionToPos.invoke(rxPosition)
val rxLat = rxPos?.latitude ?: ""
val rxLong = rxPos?.longitude ?: ""
val rxAlt = rxPos?.altitude ?: ""
val rxSnr = proto.rxSnr
// Calculate the distance if both positions are valid
val dist =
if (senderPos == null || rxPos == null) {
""
} else {
positionToMeter(
Position(rxPosition!!), // Use rxPosition but only if rxPos was
// valid
Position(senderPosition!!), // Use senderPosition but only if
// senderPos was valid
)
.roundToInt()
.toString()
}
val hopLimit = proto.hopLimit
val payload =
when {
proto.decoded.portnumValue !in
setOf(
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
Portnums.PortNum.RANGE_TEST_APP_VALUE,
) -> "<${proto.decoded.portnum}>"
proto.hasDecoded() -> proto.decoded.payload.toStringUtf8().replace("\"", "\"\"")
proto.hasEncrypted() -> "${proto.encrypted.size()} encrypted bytes"
else -> ""
}
// date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx
// elevation,rx
// snr,distance,hop limit,payload
@Suppress("MaxLineLength")
writer.appendLine(
"$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",
)
}
}
}
}
}
}
private suspend inline fun writeToUri(uri: Uri, crossinline block: suspend (BufferedWriter) -> Unit) {
withContext(Dispatchers.IO) {
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileWriter(parcelFileDescriptor.fileDescriptor).use { fileWriter ->
BufferedWriter(fileWriter).use { writer -> block.invoke(writer) }
}
}
} catch (ex: FileNotFoundException) {
errormsg("Can't write file error: ${ex.message}")
}
}
}
}