diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 2c54c6fc0..d74a173a5 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -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) } } } diff --git a/app/src/main/java/com/geeksville/mesh/android/prefs/UiPrefs.kt b/app/src/main/java/com/geeksville/mesh/android/prefs/UiPrefs.kt index de00c88e1..122afb5f2 100644 --- a/app/src/main/java/com/geeksville/mesh/android/prefs/UiPrefs.kt +++ b/app/src/main/java/com/geeksville/mesh/android/prefs/UiPrefs.kt @@ -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 var appIntroCompleted: Boolean + val appIntroCompletedFlow: StateFlow 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>() 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) diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 20c77ef3e..3d11b4246 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -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 = _theme.asStateFlow() - - fun setTheme(theme: Int) { - _theme.value = theme - uiPrefs.theme = theme - } + val theme: StateFlow = uiPrefs.themeFlow private val _lastTraceRouteTime = MutableStateFlow(null) val lastTraceRouteTime: StateFlow = _lastTraceRouteTime.asStateFlow() - private val _excludedModulesUnlocked = MutableStateFlow(false) - val excludedModulesUnlocked: StateFlow = _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 - get() = locationRepository.receivingLocationUpdates - val meshService: IMeshService? get() = radioConfigRepository.meshService - private val _localConfig = MutableStateFlow(LocalConfig.getDefaultInstance()) - val localConfig: StateFlow = _localConfig + private val localConfig = MutableStateFlow(LocalConfig.getDefaultInstance()) + val config - get() = _localConfig.value + get() = localConfig.value private val _moduleConfig = MutableStateFlow(LocalModuleConfig.getDefaultInstance()) val moduleConfig: StateFlow = _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 - 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() - - @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 = MutableStateFlow(!uiPrefs.appIntroCompleted) - val showAppIntro: StateFlow = _showAppIntro.asStateFlow() - - fun showAppIntro() { - _showAppIntro.update { true } - } - - // endregion + val appIntroCompleted: StateFlow = uiPrefs.appIntroCompletedFlow fun onAppIntroCompleted() { uiPrefs.appIntroCompleted = true - _showAppIntro.update { false } } } diff --git a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt index e0d4b9ef7..1435b5603 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt @@ -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(startDestination = SettingsRoutes.Settings()) { composable( deepLinks = listOf(navDeepLink(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)) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index db3ef6fad..1e69265c4 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -397,7 +397,7 @@ fun MainScreen( mapGraph(navController, uiViewModel = uIViewModel) channelsGraph(navController, uiViewModel = uIViewModel) connectionsGraph(navController, bluetoothViewModel) - settingsGraph(navController, uiViewModel = uIViewModel) + settingsGraph(navController) } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsScreen.kt index e4445749d..9792ca4a9 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsScreen.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsViewModel.kt new file mode 100644 index 000000000..8d64499ba --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsViewModel.kt @@ -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 . + */ + +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 = nodeRepository.myNodeInfo + + val myNodeNum + get() = myNodeInfo.value?.myNodeNum + + val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo + + val isConnected = + radioConfigRepository.connectionState + .map { it.isConnected() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), false) + + val localConfig: StateFlow = + radioConfigRepository.localConfigFlow.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000L), + LocalConfig.getDefaultInstance(), + ) + + val meshService: IMeshService? + get() = radioConfigRepository.meshService + + val provideLocation: StateFlow = + 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 = _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() + + @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}") + } + } + } +}