kopia lustrzana https://github.com/meshtastic/Meshtastic-Android
Decouple `SettingsScreen` from `UiViewModel` (#3137)
rodzic
48da34ce1a
commit
eedc3ef963
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -397,7 +397,7 @@ fun MainScreen(
|
|||
mapGraph(navController, uiViewModel = uIViewModel)
|
||||
channelsGraph(navController, uiViewModel = uIViewModel)
|
||||
connectionsGraph(navController, bluetoothViewModel)
|
||||
settingsGraph(navController, uiViewModel = uIViewModel)
|
||||
settingsGraph(navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Ładowanie…
Reference in New Issue