mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: settings rework part 2, domain and usecase abstraction, tests (#4680)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
5f31df96d8
commit
8c6bd8ab7a
121 changed files with 5245 additions and 1332 deletions
|
|
@ -47,8 +47,8 @@ import org.meshtastic.core.resources.preserve_favorites
|
|||
import org.meshtastic.core.resources.remotely_administrating
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.feature.settings.component.ExpressiveSection
|
||||
import org.meshtastic.feature.settings.radio.AdminRoute
|
||||
import org.meshtastic.feature.settings.radio.ExpressiveSection
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigState
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
import org.meshtastic.feature.settings.radio.ResponseState
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ import org.meshtastic.core.resources.device_configuration
|
|||
import org.meshtastic.core.resources.remotely_administrating
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.feature.settings.component.ExpressiveSection
|
||||
import org.meshtastic.feature.settings.navigation.ConfigRoute
|
||||
import org.meshtastic.feature.settings.radio.ExpressiveSection
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -36,8 +36,8 @@ import org.meshtastic.core.resources.module_settings
|
|||
import org.meshtastic.core.resources.remotely_administrating
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.feature.settings.component.ExpressiveSection
|
||||
import org.meshtastic.feature.settings.navigation.ModuleRoute
|
||||
import org.meshtastic.feature.settings.radio.ExpressiveSection
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -16,101 +16,54 @@
|
|||
*/
|
||||
package org.meshtastic.feature.settings
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.provider.Settings.ACTION_APP_LOCALE_SETTINGS
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Abc
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.rounded.AppSettingsAlt
|
||||
import androidx.compose.material.icons.rounded.FormatPaint
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material.icons.rounded.Language
|
||||
import androidx.compose.material.icons.rounded.LocationOn
|
||||
import androidx.compose.material.icons.rounded.Memory
|
||||
import androidx.compose.material.icons.rounded.Output
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.gpsDisabled
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.toDate
|
||||
import org.meshtastic.core.common.util.toInstant
|
||||
import org.meshtastic.core.database.DatabaseConstants
|
||||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.navigation.SettingsRoutes
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.acknowledgements
|
||||
import org.meshtastic.core.resources.analytics_okay
|
||||
import org.meshtastic.core.resources.app_settings
|
||||
import org.meshtastic.core.resources.app_version
|
||||
import org.meshtastic.core.resources.bottom_nav_settings
|
||||
import org.meshtastic.core.resources.choose_theme
|
||||
import org.meshtastic.core.resources.device_db_cache_limit
|
||||
import org.meshtastic.core.resources.device_db_cache_limit_summary
|
||||
import org.meshtastic.core.resources.dynamic
|
||||
import org.meshtastic.core.resources.export_configuration
|
||||
import org.meshtastic.core.resources.export_data_csv
|
||||
import org.meshtastic.core.resources.import_configuration
|
||||
import org.meshtastic.core.resources.intro_show
|
||||
import org.meshtastic.core.resources.location_disabled
|
||||
import org.meshtastic.core.resources.modules_already_unlocked
|
||||
import org.meshtastic.core.resources.modules_unlocked
|
||||
import org.meshtastic.core.resources.preferences_language
|
||||
import org.meshtastic.core.resources.provide_location_to_mesh
|
||||
import org.meshtastic.core.resources.remotely_administrating
|
||||
import org.meshtastic.core.resources.save_rangetest
|
||||
import org.meshtastic.core.resources.system_settings
|
||||
import org.meshtastic.core.resources.theme
|
||||
import org.meshtastic.core.resources.theme_dark
|
||||
import org.meshtastic.core.resources.theme_light
|
||||
import org.meshtastic.core.resources.theme_system
|
||||
import org.meshtastic.core.resources.use_homoglyph_characters_encoding
|
||||
import org.meshtastic.core.ui.component.DropDownPreference
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.component.SwitchListItem
|
||||
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.settings.component.AppInfoSection
|
||||
import org.meshtastic.feature.settings.component.AppearanceSection
|
||||
import org.meshtastic.feature.settings.component.PersistenceSection
|
||||
import org.meshtastic.feature.settings.component.PrivacySection
|
||||
import org.meshtastic.feature.settings.navigation.ConfigRoute
|
||||
import org.meshtastic.feature.settings.navigation.ModuleRoute
|
||||
import org.meshtastic.feature.settings.radio.ExpressiveSection
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigItemList
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
import org.meshtastic.feature.settings.radio.component.EditDeviceProfileDialog
|
||||
|
|
@ -119,7 +72,6 @@ import org.meshtastic.feature.settings.util.LanguageUtils.languageMap
|
|||
import org.meshtastic.proto.DeviceProfile
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
|
|
@ -259,226 +211,37 @@ fun SettingsScreen(
|
|||
onNavigate = onNavigate,
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
PrivacySection(
|
||||
analyticsAvailable = state.analyticsAvailable,
|
||||
analyticsEnabled = viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false).value,
|
||||
onToggleAnalytics = { viewModel.toggleAnalyticsAllowed() },
|
||||
provideLocation = settingsViewModel.provideLocation.collectAsStateWithLifecycle().value,
|
||||
onToggleLocation = { settingsViewModel.setProvideLocation(it) },
|
||||
homoglyphEnabled = viewModel.homoglyphEncodingEnabledFlow.collectAsStateWithLifecycle(false).value,
|
||||
onToggleHomoglyph = { viewModel.toggleHomoglyphCharactersEncodingEnabled() },
|
||||
startProvideLocation = { settingsViewModel.startProvidingLocation() },
|
||||
stopProvideLocation = { settingsViewModel.stopProvidingLocation() },
|
||||
)
|
||||
|
||||
ExpressiveSection(title = stringResource(Res.string.app_settings)) {
|
||||
if (state.analyticsAvailable) {
|
||||
val allowed by viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false)
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.analytics_okay),
|
||||
checked = allowed,
|
||||
leadingIcon = Icons.Default.BugReport,
|
||||
onClick = { viewModel.toggleAnalyticsAllowed() },
|
||||
)
|
||||
}
|
||||
AppearanceSection(
|
||||
onShowLanguagePicker = { showLanguagePickerDialog = true },
|
||||
onShowThemePicker = { showThemePickerDialog = true },
|
||||
)
|
||||
|
||||
val locationPermissionsState =
|
||||
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
|
||||
val isGpsDisabled = context.gpsDisabled()
|
||||
val provideLocation by settingsViewModel.provideLocation.collectAsStateWithLifecycle()
|
||||
PersistenceSection(
|
||||
cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value,
|
||||
onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) },
|
||||
nodeShortName = ourNode?.user?.short_name ?: "",
|
||||
onExportData = { settingsViewModel.saveDataCsv(it) },
|
||||
)
|
||||
|
||||
LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
|
||||
if (provideLocation) {
|
||||
if (locationPermissionsState.allPermissionsGranted) {
|
||||
if (!isGpsDisabled) {
|
||||
settingsViewModel.meshService?.startProvideLocation()
|
||||
} else {
|
||||
context.showToast(Res.string.location_disabled)
|
||||
}
|
||||
} else {
|
||||
// Request permissions if not granted and user wants to provide location
|
||||
locationPermissionsState.launchMultiplePermissionRequest()
|
||||
}
|
||||
} else {
|
||||
settingsViewModel.meshService?.stopProvideLocation()
|
||||
}
|
||||
}
|
||||
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.provide_location_to_mesh),
|
||||
leadingIcon = Icons.Rounded.LocationOn,
|
||||
enabled = !isGpsDisabled,
|
||||
checked = provideLocation,
|
||||
onClick = { settingsViewModel.setProvideLocation(!provideLocation) },
|
||||
)
|
||||
|
||||
val homoglyphEncodingEnabled by
|
||||
viewModel.homoglyphEncodingEnabledFlow.collectAsStateWithLifecycle(false)
|
||||
|
||||
HomoglyphSetting(
|
||||
homoglyphEncodingEnabled = homoglyphEncodingEnabled,
|
||||
onToggle = { viewModel.toggleHomoglyphCharactersEncodingEnabled() },
|
||||
)
|
||||
|
||||
val settingsLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
|
||||
|
||||
// On Android 12 and below, system app settings for language are not available. Use the in-app language
|
||||
// picker for these devices.
|
||||
val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
|
||||
ListItem(
|
||||
text = stringResource(Res.string.preferences_language),
|
||||
leadingIcon = Icons.Rounded.Language,
|
||||
trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
) {
|
||||
if (useInAppLangPicker) {
|
||||
showLanguagePickerDialog = true
|
||||
} else {
|
||||
val intent = Intent(ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri())
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
settingsLauncher.launch(intent)
|
||||
} else {
|
||||
// Fall back to the in-app picker
|
||||
showLanguagePickerDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.theme),
|
||||
leadingIcon = Icons.Rounded.FormatPaint,
|
||||
trailingIcon = null,
|
||||
) {
|
||||
showThemePickerDialog = true
|
||||
}
|
||||
|
||||
// Node DB cache limit (App setting)
|
||||
val cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value
|
||||
val cacheItems = remember {
|
||||
(DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map {
|
||||
it.toLong() to it.toString()
|
||||
}
|
||||
}
|
||||
DropDownPreference(
|
||||
title = stringResource(Res.string.device_db_cache_limit),
|
||||
enabled = true,
|
||||
items = cacheItems,
|
||||
selectedItem = cacheLimit.toLong(),
|
||||
onItemSelected = { selected -> settingsViewModel.setDbCacheLimit(selected.toInt()) },
|
||||
summary = stringResource(Res.string.device_db_cache_limit_summary),
|
||||
)
|
||||
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(nowMillis.toInstant().toDate())
|
||||
val nodeName = ourNode?.user?.short_name ?: ""
|
||||
|
||||
val exportRangeTestLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == RESULT_OK) {
|
||||
it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
|
||||
}
|
||||
}
|
||||
ListItem(
|
||||
text = stringResource(Res.string.save_rangetest),
|
||||
leadingIcon = Icons.Rounded.Output,
|
||||
trailingIcon = null,
|
||||
) {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/csv"
|
||||
putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_${nodeName}_$timestamp.csv")
|
||||
}
|
||||
exportRangeTestLauncher.launch(intent)
|
||||
}
|
||||
|
||||
val exportDataLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == RESULT_OK) {
|
||||
it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
|
||||
}
|
||||
}
|
||||
ListItem(
|
||||
text = stringResource(Res.string.export_data_csv),
|
||||
leadingIcon = Icons.Rounded.Output,
|
||||
trailingIcon = null,
|
||||
) {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/csv"
|
||||
putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_${nodeName}_$timestamp.csv")
|
||||
}
|
||||
exportDataLauncher.launch(intent)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.intro_show),
|
||||
leadingIcon = Icons.Rounded.WavingHand,
|
||||
trailingIcon = null,
|
||||
) {
|
||||
settingsViewModel.showAppIntro()
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.system_settings),
|
||||
leadingIcon = Icons.Rounded.AppSettingsAlt,
|
||||
trailingIcon = null,
|
||||
) {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
intent.data = Uri.fromParts("package", context.packageName, null)
|
||||
settingsLauncher.launch(intent)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.acknowledgements),
|
||||
leadingIcon = Icons.Rounded.Info,
|
||||
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
) {
|
||||
onNavigate(SettingsRoutes.About)
|
||||
}
|
||||
|
||||
AppVersionButton(
|
||||
excludedModulesUnlocked = excludedModulesUnlocked,
|
||||
appVersionName = settingsViewModel.appVersionName,
|
||||
) {
|
||||
settingsViewModel.unlockExcludedModules()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val UNLOCK_CLICK_COUNT = 5 // Number of clicks required to unlock excluded modules.
|
||||
private const val UNLOCKED_CLICK_COUNT = 3 // Number of clicks before we toast that modules are already unlocked.
|
||||
private const val UNLOCK_TIMEOUT_SECONDS = 1 // Timeout in seconds to reset the click counter.
|
||||
|
||||
/** A button to display the app version. Clicking it 5 times will unlock the excluded modules. */
|
||||
@Composable
|
||||
private fun AppVersionButton(
|
||||
excludedModulesUnlocked: Boolean,
|
||||
appVersionName: String,
|
||||
onUnlockExcludedModules: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
var clickCount by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(clickCount) {
|
||||
if (clickCount in 1..<UNLOCK_CLICK_COUNT) {
|
||||
delay(UNLOCK_TIMEOUT_SECONDS.seconds)
|
||||
clickCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.app_version),
|
||||
leadingIcon = Icons.Rounded.Memory,
|
||||
supportingText = appVersionName,
|
||||
trailingIcon = null,
|
||||
) {
|
||||
clickCount = clickCount.inc().coerceIn(0, UNLOCK_CLICK_COUNT)
|
||||
|
||||
when {
|
||||
clickCount == UNLOCKED_CLICK_COUNT && excludedModulesUnlocked -> {
|
||||
clickCount = 0
|
||||
scope.launch { context.showToast(Res.string.modules_already_unlocked) }
|
||||
}
|
||||
|
||||
clickCount == UNLOCK_CLICK_COUNT -> {
|
||||
clickCount = 0
|
||||
onUnlockExcludedModules()
|
||||
scope.launch { context.showToast(Res.string.modules_unlocked) }
|
||||
}
|
||||
AppInfoSection(
|
||||
appVersionName = settingsViewModel.appVersionName,
|
||||
excludedModulesUnlocked = excludedModulesUnlocked,
|
||||
onUnlockExcludedModules = { settingsViewModel.unlockExcludedModules() },
|
||||
onShowAppIntro = { settingsViewModel.showAppIntro() },
|
||||
onNavigateToAbout = { onNavigate(SettingsRoutes.About) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -525,18 +288,3 @@ private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit
|
|||
},
|
||||
)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@Composable
|
||||
fun HomoglyphSetting(homoglyphEncodingEnabled: Boolean, onToggle: () -> Unit) {
|
||||
val currentLocale = ConfigurationCompat.getLocales(LocalConfiguration.current).get(0)
|
||||
val supportedLanguages = listOf("ru", "uk", "be", "bg", "sr", "mk", "kk", "ky", "tg", "mn")
|
||||
if (currentLocale?.language in supportedLanguages) {
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.use_homoglyph_characters_encoding),
|
||||
checked = homoglyphEncodingEnabled,
|
||||
leadingIcon = Icons.Default.Abc,
|
||||
onClick = onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@
|
|||
*/
|
||||
package org.meshtastic.feature.settings
|
||||
|
||||
import android.app.Application
|
||||
import android.icu.text.SimpleDateFormat
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
|
@ -27,64 +25,57 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.data.repository.DeviceHardwareRepository
|
||||
import org.meshtastic.core.data.repository.MeshLogRepository
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.database.DatabaseConstants
|
||||
import org.meshtastic.core.database.DatabaseManager
|
||||
import org.meshtastic.core.database.entity.MyNodeEntity
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.datastore.UiPreferencesDataSource
|
||||
import org.meshtastic.core.model.Capabilities
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.util.positionToMeter
|
||||
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
|
||||
import org.meshtastic.core.prefs.radio.RadioPrefs
|
||||
import org.meshtastic.core.prefs.radio.isBle
|
||||
import org.meshtastic.core.prefs.radio.isSerial
|
||||
import org.meshtastic.core.prefs.radio.isTcp
|
||||
import org.meshtastic.core.prefs.ui.UiPrefs
|
||||
import org.meshtastic.core.service.IMeshService
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
import org.meshtastic.proto.PortNum
|
||||
import java.io.BufferedWriter
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.FileWriter
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
import org.meshtastic.proto.Position as ProtoPosition
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@HiltViewModel
|
||||
class SettingsViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val app: Application,
|
||||
private val app: android.app.Application,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val radioController: RadioController,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val meshLogRepository: MeshLogRepository,
|
||||
private val uiPrefs: UiPrefs,
|
||||
private val uiPreferencesDataSource: UiPreferencesDataSource,
|
||||
private val buildConfigProvider: BuildConfigProvider,
|
||||
private val databaseManager: DatabaseManager,
|
||||
private val deviceHardwareRepository: DeviceHardwareRepository,
|
||||
private val radioPrefs: RadioPrefs,
|
||||
private val meshLogPrefs: MeshLogPrefs,
|
||||
private val setThemeUseCase: SetThemeUseCase,
|
||||
private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase,
|
||||
private val setProvideLocationUseCase: SetProvideLocationUseCase,
|
||||
private val setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase,
|
||||
private val setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase,
|
||||
private val meshLocationUseCase: MeshLocationUseCase,
|
||||
private val exportDataUseCase: ExportDataUseCase,
|
||||
private val isOtaCapableUseCase: IsOtaCapableUseCase,
|
||||
) : ViewModel() {
|
||||
val myNodeInfo: StateFlow<MyNodeEntity?> = nodeRepository.myNodeInfo
|
||||
|
||||
|
|
@ -94,14 +85,11 @@ constructor(
|
|||
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
|
||||
|
||||
val isConnected =
|
||||
serviceRepository.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false)
|
||||
radioController.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false)
|
||||
|
||||
val localConfig: StateFlow<LocalConfig> =
|
||||
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
|
||||
|
||||
val meshService: IMeshService?
|
||||
get() = serviceRepository.meshService
|
||||
|
||||
val provideLocation: StateFlow<Boolean> =
|
||||
myNodeInfo
|
||||
.flatMapLatest { myNodeEntity ->
|
||||
|
|
@ -114,41 +102,27 @@ constructor(
|
|||
}
|
||||
.stateInWhileSubscribed(initialValue = false)
|
||||
|
||||
fun startProvidingLocation() {
|
||||
meshLocationUseCase.startProvidingLocation()
|
||||
}
|
||||
|
||||
fun stopProvidingLocation() {
|
||||
meshLocationUseCase.stopProvidingLocation()
|
||||
}
|
||||
|
||||
private val _excludedModulesUnlocked = MutableStateFlow(false)
|
||||
val excludedModulesUnlocked: StateFlow<Boolean> = _excludedModulesUnlocked.asStateFlow()
|
||||
|
||||
val appVersionName
|
||||
get() = buildConfigProvider.versionName
|
||||
|
||||
val isOtaCapable: StateFlow<Boolean> =
|
||||
combine(ourNodeInfo, serviceRepository.connectionState) { node, connectionState -> Pair(node, connectionState) }
|
||||
.flatMapLatest { (node, connectionState) ->
|
||||
if (node == null || !connectionState.isConnected()) {
|
||||
flowOf(false)
|
||||
} else if (radioPrefs.isBle() || radioPrefs.isSerial() || radioPrefs.isTcp()) {
|
||||
val hwModel = node.user.hw_model.value
|
||||
val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
|
||||
// Support both Nordic DFU (requiresDfu) and ESP32 Unified OTA (supportsUnifiedOta)
|
||||
val capabilities = Capabilities(node.metadata?.firmware_version)
|
||||
|
||||
// ESP32 Unified OTA is only supported via BLE or WiFi (TCP), not USB Serial.
|
||||
// TODO: Re-enable when supportsUnifiedOta is added to DeviceHardware
|
||||
val isEsp32OtaSupported = false
|
||||
// hw?.supportsUnifiedOta == true && capabilities.supportsEsp32Ota && !radioPrefs.isSerial()
|
||||
|
||||
flow { emit(hw?.requiresDfu == true || isEsp32OtaSupported) }
|
||||
} else {
|
||||
flowOf(false)
|
||||
}
|
||||
}
|
||||
.stateInWhileSubscribed(initialValue = false)
|
||||
val isOtaCapable: StateFlow<Boolean> = isOtaCapableUseCase().stateInWhileSubscribed(initialValue = false)
|
||||
|
||||
// Device DB cache limit (bounded by DatabaseConstants)
|
||||
val dbCacheLimit: StateFlow<Int> = databaseManager.cacheLimit
|
||||
|
||||
fun setDbCacheLimit(limit: Int) {
|
||||
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
|
||||
databaseManager.setCacheLimit(clamped)
|
||||
setDatabaseCacheLimitUseCase(limit)
|
||||
}
|
||||
|
||||
// MeshLog retention period (bounded by MeshLogPrefsImpl constants)
|
||||
|
|
@ -159,32 +133,25 @@ constructor(
|
|||
val meshLogLoggingEnabled: StateFlow<Boolean> = _meshLogLoggingEnabled.asStateFlow()
|
||||
|
||||
fun setMeshLogRetentionDays(days: Int) {
|
||||
val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
|
||||
meshLogPrefs.retentionDays = clamped
|
||||
_meshLogRetentionDays.value = clamped
|
||||
viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(clamped) }
|
||||
viewModelScope.launch { setMeshLogSettingsUseCase.setRetentionDays(days) }
|
||||
_meshLogRetentionDays.value = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
|
||||
}
|
||||
|
||||
fun setMeshLogLoggingEnabled(enabled: Boolean) {
|
||||
meshLogPrefs.loggingEnabled = enabled
|
||||
viewModelScope.launch { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) }
|
||||
_meshLogLoggingEnabled.value = enabled
|
||||
if (!enabled) {
|
||||
viewModelScope.launch { meshLogRepository.deleteAll() }
|
||||
} else {
|
||||
viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setProvideLocation(value: Boolean) {
|
||||
myNodeNum?.let { uiPrefs.setShouldProvideNodeLocation(it, value) }
|
||||
myNodeNum?.let { setProvideLocationUseCase(it, value) }
|
||||
}
|
||||
|
||||
fun setTheme(theme: Int) {
|
||||
uiPreferencesDataSource.setTheme(theme)
|
||||
setThemeUseCase(theme)
|
||||
}
|
||||
|
||||
fun showAppIntro() {
|
||||
uiPreferencesDataSource.setAppIntroCompleted(false)
|
||||
setAppIntroCompletedUseCase(false)
|
||||
}
|
||||
|
||||
fun unlockExcludedModules() {
|
||||
|
|
@ -204,112 +171,8 @@ constructor(
|
|||
@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
|
||||
|
||||
// Converts a ProtoPosition (nullable) to a Position, but only if it's valid, otherwise returns null.
|
||||
// The returned Position is guaranteed to be non-null and valid, or null if the input was null or invalid.
|
||||
val positionToPos: (ProtoPosition?) -> Position? = { meshPosition ->
|
||||
meshPosition?.let { Position(it) }?.takeIf { it.isValid() }
|
||||
}
|
||||
|
||||
writeToUri(uri) { writer ->
|
||||
val nodePositions = mutableMapOf<Int, ProtoPosition?>()
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
writer.appendLine(
|
||||
"\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance(m)\",\"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?.portnum?.value ?: 0) == filterPortnum) &&
|
||||
(proto.rx_snr ?: 0f) != 0.0f
|
||||
) {
|
||||
val rxDateTime = dateFormat.format(packet.received_date)
|
||||
val rxFrom = proto.from.toUInt()
|
||||
val senderName = nodes[proto.from]?.user?.long_name ?: ""
|
||||
|
||||
// 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.rx_snr
|
||||
|
||||
// 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.hop_limit ?: 0
|
||||
|
||||
val decoded = proto.decoded
|
||||
val encrypted = proto.encrypted
|
||||
val payload =
|
||||
when {
|
||||
(decoded?.portnum?.value ?: 0) !in
|
||||
setOf(PortNum.TEXT_MESSAGE_APP.value, PortNum.RANGE_TEST_APP.value) ->
|
||||
"<${decoded?.portnum}>"
|
||||
|
||||
decoded != null -> decoded.payload.utf8().replace("\"", "\"\"")
|
||||
|
||||
encrypted != null -> "${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\"",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeToUri(uri) { writer -> exportDataUseCase(writer, myNodeNum, filterPortnum) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.feature.settings.component
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.rounded.AppSettingsAlt
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material.icons.rounded.Memory
|
||||
import androidx.compose.material.icons.rounded.WavingHand
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.acknowledgements
|
||||
import org.meshtastic.core.resources.app_version
|
||||
import org.meshtastic.core.resources.info
|
||||
import org.meshtastic.core.resources.intro_show
|
||||
import org.meshtastic.core.resources.modules_already_unlocked
|
||||
import org.meshtastic.core.resources.modules_unlocked
|
||||
import org.meshtastic.core.resources.system_settings
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/** Section displaying application information and related actions. */
|
||||
@Composable
|
||||
fun AppInfoSection(
|
||||
appVersionName: String,
|
||||
excludedModulesUnlocked: Boolean,
|
||||
onUnlockExcludedModules: () -> Unit,
|
||||
onShowAppIntro: () -> Unit,
|
||||
onNavigateToAbout: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val settingsLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
|
||||
|
||||
ExpressiveSection(title = stringResource(Res.string.info)) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.intro_show),
|
||||
leadingIcon = Icons.Rounded.WavingHand,
|
||||
trailingIcon = null,
|
||||
) {
|
||||
onShowAppIntro()
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.system_settings),
|
||||
leadingIcon = Icons.Rounded.AppSettingsAlt,
|
||||
trailingIcon = null,
|
||||
) {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
intent.data = Uri.fromParts("package", context.packageName, null)
|
||||
settingsLauncher.launch(intent)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.acknowledgements),
|
||||
leadingIcon = Icons.Rounded.Info,
|
||||
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
) {
|
||||
onNavigateToAbout()
|
||||
}
|
||||
|
||||
AppVersionButton(
|
||||
excludedModulesUnlocked = excludedModulesUnlocked,
|
||||
appVersionName = appVersionName,
|
||||
onUnlockExcludedModules = onUnlockExcludedModules,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val UNLOCK_CLICK_COUNT = 5 // Number of clicks required to unlock excluded modules.
|
||||
private const val UNLOCKED_CLICK_COUNT = 3 // Number of clicks before we toast that modules are already unlocked.
|
||||
private const val UNLOCK_TIMEOUT_SECONDS = 1 // Timeout in seconds to reset the click counter.
|
||||
|
||||
@Composable
|
||||
private fun AppVersionButton(
|
||||
excludedModulesUnlocked: Boolean,
|
||||
appVersionName: String,
|
||||
onUnlockExcludedModules: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
var clickCount by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(clickCount) {
|
||||
if (clickCount in 1..<UNLOCK_CLICK_COUNT) {
|
||||
delay(UNLOCK_TIMEOUT_SECONDS.seconds)
|
||||
clickCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.app_version),
|
||||
leadingIcon = Icons.Rounded.Memory,
|
||||
supportingText = appVersionName,
|
||||
trailingIcon = null,
|
||||
) {
|
||||
clickCount = clickCount.inc().coerceIn(0, UNLOCK_CLICK_COUNT)
|
||||
|
||||
when {
|
||||
clickCount == UNLOCKED_CLICK_COUNT && excludedModulesUnlocked -> {
|
||||
clickCount = 0
|
||||
scope.launch { context.showToast(Res.string.modules_already_unlocked) }
|
||||
}
|
||||
|
||||
clickCount == UNLOCK_CLICK_COUNT -> {
|
||||
clickCount = 0
|
||||
onUnlockExcludedModules()
|
||||
scope.launch { context.showToast(Res.string.modules_unlocked) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun AppInfoSectionPreview() {
|
||||
AppTheme {
|
||||
AppInfoSection(
|
||||
appVersionName = "2.5.0",
|
||||
excludedModulesUnlocked = false,
|
||||
onUnlockExcludedModules = {},
|
||||
onShowAppIntro = {},
|
||||
onNavigateToAbout = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.feature.settings.component
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.rounded.FormatPaint
|
||||
import androidx.compose.material.icons.rounded.Language
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.core.net.toUri
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.app_settings
|
||||
import org.meshtastic.core.resources.preferences_language
|
||||
import org.meshtastic.core.resources.theme
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
/** Section for app appearance settings like language and theme. */
|
||||
@Composable
|
||||
fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val settingsLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
|
||||
|
||||
// On Android 12 and below, system app settings for language are not available. Use the in-app language
|
||||
// picker for these devices.
|
||||
val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
|
||||
|
||||
ExpressiveSection(title = stringResource(Res.string.app_settings)) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.preferences_language),
|
||||
leadingIcon = Icons.Rounded.Language,
|
||||
trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
) {
|
||||
if (useInAppLangPicker) {
|
||||
onShowLanguagePicker()
|
||||
} else {
|
||||
val intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri())
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
settingsLauncher.launch(intent)
|
||||
} else {
|
||||
// Fall back to the in-app picker
|
||||
onShowLanguagePicker()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.theme),
|
||||
leadingIcon = Icons.Rounded.FormatPaint,
|
||||
trailingIcon = null,
|
||||
) {
|
||||
onShowThemePicker()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun AppearanceSectionPreview() {
|
||||
AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}) }
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.feature.settings.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** A styled section container for settings screens. */
|
||||
@Composable
|
||||
fun ExpressiveSection(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
titleColor: Color = MaterialTheme.colorScheme.primary,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = titleColor,
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.feature.settings.component
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Abc
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.use_homoglyph_characters_encoding
|
||||
import org.meshtastic.core.ui.component.SwitchListItem
|
||||
|
||||
@Composable
|
||||
fun HomoglyphSetting(homoglyphEncodingEnabled: Boolean, onToggle: () -> Unit) {
|
||||
val currentLocale = ConfigurationCompat.getLocales(LocalConfiguration.current).get(0)
|
||||
val supportedLanguages = listOf("ru", "uk", "be", "bg", "sr", "mk", "kk", "ky", "tg", "mn")
|
||||
if (currentLocale?.language in supportedLanguages) {
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.use_homoglyph_characters_encoding),
|
||||
checked = homoglyphEncodingEnabled,
|
||||
leadingIcon = Icons.Default.Abc,
|
||||
onClick = onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.feature.settings.component
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Output
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.toDate
|
||||
import org.meshtastic.core.common.util.toInstant
|
||||
import org.meshtastic.core.database.DatabaseConstants
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.app_settings
|
||||
import org.meshtastic.core.resources.device_db_cache_limit
|
||||
import org.meshtastic.core.resources.device_db_cache_limit_summary
|
||||
import org.meshtastic.core.resources.export_data_csv
|
||||
import org.meshtastic.core.resources.save_rangetest
|
||||
import org.meshtastic.core.ui.component.DropDownPreference
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
/** Section for settings related to data persistence and exports. */
|
||||
@Composable
|
||||
fun PersistenceSection(
|
||||
cacheLimit: Int,
|
||||
onSetCacheLimit: (Int) -> Unit,
|
||||
nodeShortName: String,
|
||||
onExportData: (android.net.Uri) -> Unit,
|
||||
) {
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(nowMillis.toInstant().toDate())
|
||||
|
||||
val exportRangeTestLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == RESULT_OK) {
|
||||
it.data?.data?.let { uri -> onExportData(uri) }
|
||||
}
|
||||
}
|
||||
|
||||
val exportDataLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == RESULT_OK) {
|
||||
it.data?.data?.let { uri -> onExportData(uri) }
|
||||
}
|
||||
}
|
||||
|
||||
ExpressiveSection(title = stringResource(Res.string.app_settings)) {
|
||||
val cacheItems = remember {
|
||||
(DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map { it.toLong() to it.toString() }
|
||||
}
|
||||
DropDownPreference(
|
||||
title = stringResource(Res.string.device_db_cache_limit),
|
||||
enabled = true,
|
||||
items = cacheItems,
|
||||
selectedItem = cacheLimit.toLong(),
|
||||
onItemSelected = { selected -> onSetCacheLimit(selected.toInt()) },
|
||||
summary = stringResource(Res.string.device_db_cache_limit_summary),
|
||||
)
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.save_rangetest),
|
||||
leadingIcon = Icons.Rounded.Output,
|
||||
trailingIcon = null,
|
||||
) {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/csv"
|
||||
putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_${nodeShortName}_$timestamp.csv")
|
||||
}
|
||||
exportRangeTestLauncher.launch(intent)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.export_data_csv),
|
||||
leadingIcon = Icons.Rounded.Output,
|
||||
trailingIcon = null,
|
||||
) {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/csv"
|
||||
putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_${nodeShortName}_$timestamp.csv")
|
||||
}
|
||||
exportDataLauncher.launch(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PersistenceSectionPreview() {
|
||||
AppTheme { PersistenceSection(cacheLimit = 100, onSetCacheLimit = {}, nodeShortName = "TEST", onExportData = {}) }
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.feature.settings.component
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.rounded.LocationOn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.gpsDisabled
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.analytics_okay
|
||||
import org.meshtastic.core.resources.app_settings
|
||||
import org.meshtastic.core.resources.location_disabled
|
||||
import org.meshtastic.core.resources.provide_location_to_mesh
|
||||
import org.meshtastic.core.ui.component.SwitchListItem
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
|
||||
/** Section managing privacy settings like analytics and location sharing. */
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun PrivacySection(
|
||||
analyticsAvailable: Boolean,
|
||||
analyticsEnabled: Boolean,
|
||||
onToggleAnalytics: () -> Unit,
|
||||
provideLocation: Boolean,
|
||||
onToggleLocation: (Boolean) -> Unit,
|
||||
homoglyphEnabled: Boolean,
|
||||
onToggleHomoglyph: () -> Unit,
|
||||
startProvideLocation: () -> Unit,
|
||||
stopProvideLocation: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val locationPermissionsState =
|
||||
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
|
||||
val isGpsDisabled = context.gpsDisabled()
|
||||
|
||||
LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
|
||||
if (provideLocation) {
|
||||
if (locationPermissionsState.allPermissionsGranted) {
|
||||
if (!isGpsDisabled) {
|
||||
startProvideLocation()
|
||||
} else {
|
||||
context.showToast(Res.string.location_disabled)
|
||||
}
|
||||
} else {
|
||||
locationPermissionsState.launchMultiplePermissionRequest()
|
||||
}
|
||||
} else {
|
||||
stopProvideLocation()
|
||||
}
|
||||
}
|
||||
|
||||
ExpressiveSection(title = stringResource(Res.string.app_settings)) {
|
||||
if (analyticsAvailable) {
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.analytics_okay),
|
||||
checked = analyticsEnabled,
|
||||
leadingIcon = Icons.Default.BugReport,
|
||||
onClick = onToggleAnalytics,
|
||||
)
|
||||
}
|
||||
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.provide_location_to_mesh),
|
||||
leadingIcon = Icons.Rounded.LocationOn,
|
||||
enabled = !isGpsDisabled,
|
||||
checked = provideLocation,
|
||||
onClick = { onToggleLocation(!provideLocation) },
|
||||
)
|
||||
|
||||
HomoglyphSetting(homoglyphEncodingEnabled = homoglyphEnabled, onToggle = onToggleHomoglyph)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PrivacySectionPreview() {
|
||||
AppTheme {
|
||||
PrivacySection(
|
||||
analyticsAvailable = true,
|
||||
analyticsEnabled = true,
|
||||
onToggleAnalytics = {},
|
||||
provideLocation = true,
|
||||
onToggleLocation = {},
|
||||
homoglyphEnabled = false,
|
||||
onToggleHomoglyph = {},
|
||||
startProvideLocation = {},
|
||||
stopProvideLocation = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.clean_node_database_description
|
||||
import org.meshtastic.core.resources.clean_node_database_title
|
||||
|
|
@ -150,7 +150,7 @@ private fun UnknownNodesFilter(onlyUnknownNodes: Boolean, onCheckedChanged: (Boo
|
|||
* @param nodesToDelete The list of nodes to be deleted.
|
||||
*/
|
||||
@Composable
|
||||
private fun NodesDeletionPreview(nodesToDelete: List<NodeEntity>) {
|
||||
private fun NodesDeletionPreview(nodesToDelete: List<Node>) {
|
||||
Text(
|
||||
stringResource(Res.string.nodes_queued_for_deletion, nodesToDelete.size),
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
|
|
@ -160,8 +160,6 @@ private fun NodesDeletionPreview(nodesToDelete: List<NodeEntity>) {
|
|||
horizontalArrangement = Arrangement.Center,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
nodesToDelete.forEach { node ->
|
||||
NodeChip(node = node.toModel(), modifier = Modifier.padding(end = 8.dp, bottom = 8.dp))
|
||||
}
|
||||
nodesToDelete.forEach { node -> NodeChip(node = node, modifier = Modifier.padding(end = 8.dp, bottom = 8.dp)) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,16 +24,14 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.domain.usecase.settings.CleanNodeDatabaseUseCase
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.are_you_sure
|
||||
import org.meshtastic.core.resources.clean_node_database_confirmation
|
||||
import org.meshtastic.core.resources.clean_now
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.ui.util.AlertManager
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
private const val MIN_DAYS_THRESHOLD = 7f
|
||||
|
||||
|
|
@ -45,8 +43,7 @@ private const val MIN_DAYS_THRESHOLD = 7f
|
|||
class CleanNodeDatabaseViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val cleanNodeDatabaseUseCase: CleanNodeDatabaseUseCase,
|
||||
private val alertManager: AlertManager,
|
||||
) : ViewModel() {
|
||||
private val _olderThanDays = MutableStateFlow(30f)
|
||||
|
|
@ -55,7 +52,7 @@ constructor(
|
|||
private val _onlyUnknownNodes = MutableStateFlow(false)
|
||||
val onlyUnknownNodes = _onlyUnknownNodes.asStateFlow()
|
||||
|
||||
private val _nodesToDelete = MutableStateFlow<List<NodeEntity>>(emptyList())
|
||||
private val _nodesToDelete = MutableStateFlow<List<Node>>(emptyList())
|
||||
val nodesToDelete = _nodesToDelete.asStateFlow()
|
||||
|
||||
fun onOlderThanDaysChanged(value: Float) {
|
||||
|
|
@ -69,40 +66,15 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the list of nodes to be deleted based on the current filter criteria. The logic is as follows:
|
||||
* - The "older than X days" filter (controlled by the slider) is always active.
|
||||
* - If "only unknown nodes" is also enabled, nodes that are BOTH unknown AND older than X days are selected.
|
||||
* - If "only unknown nodes" is not enabled, all nodes older than X days are selected.
|
||||
* - Nodes with an associated public key (PKI) heard from within the last 7 days are always excluded from deletion.
|
||||
* - Nodes marked as ignored or favorite are always excluded from deletion.
|
||||
*/
|
||||
/** Updates the list of nodes to be deleted based on the current filter criteria. */
|
||||
fun getNodesToDelete() {
|
||||
viewModelScope.launch {
|
||||
val onlyUnknownEnabled = _onlyUnknownNodes.value
|
||||
val currentTimeSeconds = nowSeconds
|
||||
val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds
|
||||
val olderThanTimestamp = currentTimeSeconds - _olderThanDays.value.toInt().days.inWholeSeconds
|
||||
|
||||
val initialNodesToConsider =
|
||||
if (onlyUnknownEnabled) {
|
||||
// Both "older than X days" and "only unknown nodes" filters apply
|
||||
val olderNodes = nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
|
||||
val unknownNodes = nodeRepository.getUnknownNodes()
|
||||
olderNodes.filter { itNode -> unknownNodes.any { unknownNode -> itNode.num == unknownNode.num } }
|
||||
} else {
|
||||
// Only "older than X days" filter applies
|
||||
nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
|
||||
}
|
||||
|
||||
_nodesToDelete.value =
|
||||
initialNodesToConsider.filterNot { node ->
|
||||
// Exclude nodes with PKI heard in the last 7 days
|
||||
(node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) ||
|
||||
// Exclude ignored or favorite nodes
|
||||
node.isIgnored ||
|
||||
node.isFavorite
|
||||
}
|
||||
cleanNodeDatabaseUseCase.getNodesToClean(
|
||||
olderThanDays = _olderThanDays.value,
|
||||
onlyUnknownNodes = _onlyUnknownNodes.value,
|
||||
currentTimeSeconds = nowSeconds,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,16 +98,7 @@ constructor(
|
|||
fun cleanNodes() {
|
||||
viewModelScope.launch {
|
||||
val nodeNums = _nodesToDelete.value.map { it.num }
|
||||
if (nodeNums.isNotEmpty()) {
|
||||
nodeRepository.deleteNodes(nodeNums)
|
||||
|
||||
val service = serviceRepository.meshService
|
||||
if (service != null) {
|
||||
for (nodeNum in nodeNums) {
|
||||
service.removeByNodenum(service.packetId, nodeNum)
|
||||
}
|
||||
}
|
||||
}
|
||||
cleanNodeDatabaseUseCase.cleanNodes(nodeNums)
|
||||
// Clear the list after deletion or if it was empty
|
||||
_nodesToDelete.value = emptyList()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@ package org.meshtastic.feature.settings.radio
|
|||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
|
|
@ -35,16 +33,11 @@ import androidx.compose.material.icons.rounded.Settings
|
|||
import androidx.compose.material.icons.rounded.Storage
|
||||
import androidx.compose.material.icons.rounded.SystemUpdate
|
||||
import androidx.compose.material.icons.rounded.Upload
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
|
@ -72,10 +65,9 @@ import org.meshtastic.core.resources.shutdown
|
|||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
import org.meshtastic.feature.settings.component.ExpressiveSection
|
||||
import org.meshtastic.feature.settings.navigation.ConfigRoute
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun RadioConfigItemList(
|
||||
state: RadioConfigState,
|
||||
|
|
@ -89,130 +81,135 @@ fun RadioConfigItemList(
|
|||
val enabled = state.connected && !state.responseState.isWaiting() && !isManaged
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
ExpressiveSection(title = stringResource(Res.string.radio_configuration)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
ConfigRoute.radioConfigRoutes.forEach {
|
||||
ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
|
||||
}
|
||||
}
|
||||
|
||||
ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
ListItem(
|
||||
text = stringResource(Res.string.device_configuration),
|
||||
leadingIcon = Icons.Rounded.AppSettingsAlt,
|
||||
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
enabled = enabled,
|
||||
) {
|
||||
onNavigate(SettingsRoutes.DeviceConfiguration)
|
||||
}
|
||||
}
|
||||
|
||||
ExpressiveSection(title = stringResource(Res.string.module_settings)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
ListItem(
|
||||
text = stringResource(Res.string.module_settings),
|
||||
leadingIcon = Icons.Rounded.Settings,
|
||||
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
enabled = enabled,
|
||||
) {
|
||||
onNavigate(SettingsRoutes.ModuleConfiguration)
|
||||
}
|
||||
}
|
||||
RadioConfigSection(isManaged, enabled, onRouteClick)
|
||||
DeviceConfigSection(isManaged, enabled, onNavigate)
|
||||
ModuleSettingsSection(isManaged, enabled, onNavigate)
|
||||
|
||||
if (state.isLocal) {
|
||||
ExpressiveSection(title = stringResource(Res.string.backup_restore)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.import_configuration),
|
||||
leadingIcon = Icons.Rounded.Download,
|
||||
enabled = enabled,
|
||||
onClick = onImport,
|
||||
)
|
||||
ListItem(
|
||||
text = stringResource(Res.string.export_configuration),
|
||||
leadingIcon = Icons.Rounded.Upload,
|
||||
enabled = enabled,
|
||||
onClick = onExport,
|
||||
)
|
||||
}
|
||||
BackupRestoreSection(isManaged, enabled, onImport, onExport)
|
||||
}
|
||||
|
||||
ExpressiveSection(title = stringResource(Res.string.administration)) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.administration),
|
||||
leadingIcon = Icons.Rounded.AdminPanelSettings,
|
||||
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
leadingIconTint = MaterialTheme.colorScheme.error,
|
||||
textColor = MaterialTheme.colorScheme.error,
|
||||
trailingIconTint = MaterialTheme.colorScheme.error,
|
||||
enabled = enabled,
|
||||
) {
|
||||
onNavigate(SettingsRoutes.Administration)
|
||||
}
|
||||
}
|
||||
AdministrationSection(enabled, onNavigate)
|
||||
|
||||
if (state.isLocal) {
|
||||
ExpressiveSection(title = stringResource(Res.string.advanced_title)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
|
||||
if (isOtaCapable) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.firmware_update_title),
|
||||
leadingIcon = Icons.Rounded.SystemUpdate,
|
||||
enabled = enabled,
|
||||
onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
|
||||
)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.clean_node_database_title),
|
||||
leadingIcon = Icons.Rounded.CleaningServices,
|
||||
enabled = enabled,
|
||||
onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
|
||||
)
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.debug_panel),
|
||||
leadingIcon = Icons.Rounded.BugReport,
|
||||
enabled = enabled,
|
||||
onClick = { onNavigate(SettingsRoutes.DebugPanel) },
|
||||
)
|
||||
}
|
||||
AdvancedSection(isManaged, isOtaCapable, enabled, onNavigate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpressiveSection(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
titleColor: Color = MaterialTheme.colorScheme.primary,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = titleColor,
|
||||
private fun RadioConfigSection(isManaged: Boolean, enabled: Boolean, onRouteClick: (Enum<*>) -> Unit) {
|
||||
ExpressiveSection(title = stringResource(Res.string.radio_configuration)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
ConfigRoute.radioConfigRoutes.forEach {
|
||||
ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceConfigSection(isManaged: Boolean, enabled: Boolean, onNavigate: (Route) -> Unit) {
|
||||
ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
ListItem(
|
||||
text = stringResource(Res.string.device_configuration),
|
||||
leadingIcon = Icons.Rounded.AppSettingsAlt,
|
||||
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
enabled = enabled,
|
||||
) {
|
||||
onNavigate(SettingsRoutes.DeviceConfiguration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModuleSettingsSection(isManaged: Boolean, enabled: Boolean, onNavigate: (Route) -> Unit) {
|
||||
ExpressiveSection(title = stringResource(Res.string.module_settings)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
ListItem(
|
||||
text = stringResource(Res.string.module_settings),
|
||||
leadingIcon = Icons.Rounded.Settings,
|
||||
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
enabled = enabled,
|
||||
) {
|
||||
onNavigate(SettingsRoutes.ModuleConfiguration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackupRestoreSection(isManaged: Boolean, enabled: Boolean, onImport: () -> Unit, onExport: () -> Unit) {
|
||||
ExpressiveSection(title = stringResource(Res.string.backup_restore)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.import_configuration),
|
||||
leadingIcon = Icons.Rounded.Download,
|
||||
enabled = enabled,
|
||||
onClick = onImport,
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
content = content,
|
||||
ListItem(
|
||||
text = stringResource(Res.string.export_configuration),
|
||||
leadingIcon = Icons.Rounded.Upload,
|
||||
enabled = enabled,
|
||||
onClick = onExport,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AdministrationSection(enabled: Boolean, onNavigate: (Route) -> Unit) {
|
||||
ExpressiveSection(title = stringResource(Res.string.administration)) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.administration),
|
||||
leadingIcon = Icons.Rounded.AdminPanelSettings,
|
||||
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
leadingIconTint = MaterialTheme.colorScheme.error,
|
||||
textColor = MaterialTheme.colorScheme.error,
|
||||
trailingIconTint = MaterialTheme.colorScheme.error,
|
||||
enabled = enabled,
|
||||
) {
|
||||
onNavigate(SettingsRoutes.Administration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AdvancedSection(isManaged: Boolean, isOtaCapable: Boolean, enabled: Boolean, onNavigate: (Route) -> Unit) {
|
||||
ExpressiveSection(title = stringResource(Res.string.advanced_title)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
|
||||
if (isOtaCapable) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.firmware_update_title),
|
||||
leadingIcon = Icons.Rounded.SystemUpdate,
|
||||
enabled = enabled,
|
||||
onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
|
||||
)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.clean_node_database_title),
|
||||
leadingIcon = Icons.Rounded.CleaningServices,
|
||||
enabled = enabled,
|
||||
onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
|
||||
)
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.debug_panel),
|
||||
leadingIcon = Icons.Rounded.BugReport,
|
||||
enabled = enabled,
|
||||
onClick = { onNavigate(SettingsRoutes.DebugPanel) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ import android.app.Application
|
|||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
import android.util.Base64
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
|
|
@ -44,15 +42,23 @@ import kotlinx.coroutines.flow.update
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.json.JSONObject
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.data.repository.LocationRepository
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.database.entity.MyNodeEntity
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.database.model.getStringResFrom
|
||||
import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
|
||||
import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.navigation.SettingsRoutes
|
||||
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
|
||||
|
|
@ -61,8 +67,6 @@ import org.meshtastic.core.prefs.map.MapConsentPrefs
|
|||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.UiText
|
||||
import org.meshtastic.core.resources.cant_shutdown
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.service.IMeshService
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.ui.util.getChannelList
|
||||
import org.meshtastic.feature.settings.navigation.ConfigRoute
|
||||
|
|
@ -79,8 +83,6 @@ import org.meshtastic.proto.LocalConfig
|
|||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.Routing
|
||||
import org.meshtastic.proto.User
|
||||
import java.io.FileOutputStream
|
||||
import javax.inject.Inject
|
||||
|
|
@ -119,20 +121,26 @@ constructor(
|
|||
private val mapConsentPrefs: MapConsentPrefs,
|
||||
private val analyticsPrefs: AnalyticsPrefs,
|
||||
private val homoglyphEncodingPrefs: HomoglyphPrefs,
|
||||
private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase,
|
||||
private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase,
|
||||
private val importProfileUseCase: ImportProfileUseCase,
|
||||
private val exportProfileUseCase: ExportProfileUseCase,
|
||||
private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase,
|
||||
private val installProfileUseCase: InstallProfileUseCase,
|
||||
private val radioConfigUseCase: RadioConfigUseCase,
|
||||
private val adminActionsUseCase: AdminActionsUseCase,
|
||||
private val processRadioResponseUseCase: ProcessRadioResponseUseCase,
|
||||
) : ViewModel() {
|
||||
private val meshService: IMeshService?
|
||||
get() = serviceRepository.meshService
|
||||
|
||||
var analyticsAllowedFlow = analyticsPrefs.getAnalyticsAllowedChangesFlow()
|
||||
|
||||
fun toggleAnalyticsAllowed() {
|
||||
analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed
|
||||
toggleAnalyticsUseCase()
|
||||
}
|
||||
|
||||
val homoglyphEncodingEnabledFlow = homoglyphEncodingPrefs.getHomoglyphEncodingEnabledChangesFlow()
|
||||
|
||||
fun toggleHomoglyphCharactersEncodingEnabled() {
|
||||
homoglyphEncodingPrefs.homoglyphEncodingEnabled = !homoglyphEncodingPrefs.homoglyphEncodingEnabled
|
||||
toggleHomoglyphEncodingUseCase()
|
||||
}
|
||||
|
||||
private val destNum =
|
||||
|
|
@ -234,52 +242,30 @@ constructor(
|
|||
Logger.d { "RadioConfigViewModel cleared" }
|
||||
}
|
||||
|
||||
private fun request(destNum: Int, requestAction: suspend (IMeshService, Int, Int) -> Unit, errorMessage: String) =
|
||||
viewModelScope.launch {
|
||||
meshService?.let { service ->
|
||||
val packetId = service.getPacketId()
|
||||
try {
|
||||
requestAction(service, packetId, destNum)
|
||||
requestIds.update { it.apply { add(packetId) } }
|
||||
_radioConfigState.update { state ->
|
||||
if (state.responseState is ResponseState.Loading) {
|
||||
val total = maxOf(requestIds.value.size, state.responseState.total)
|
||||
state.copy(responseState = state.responseState.copy(total = total))
|
||||
} else {
|
||||
state.copy(
|
||||
route = "", // setter (response is PortNum.ROUTING_APP)
|
||||
responseState = ResponseState.Loading(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (ex: RemoteException) {
|
||||
Logger.e { "$errorMessage: ${ex.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setOwner(user: User) {
|
||||
setRemoteOwner(destNode.value?.num ?: return, user)
|
||||
val destNum = destNode.value?.num ?: return
|
||||
viewModelScope.launch {
|
||||
_radioConfigState.update { it.copy(userConfig = user) }
|
||||
val packetId = radioConfigUseCase.setOwner(destNum, user)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setRemoteOwner(destNum: Int, user: User) = request(
|
||||
destNum,
|
||||
{ service, packetId, _ ->
|
||||
_radioConfigState.update { it.copy(userConfig = user) }
|
||||
service.setRemoteOwner(packetId, destNum, user.encode())
|
||||
},
|
||||
"Request setOwner error",
|
||||
)
|
||||
|
||||
private fun getOwner(destNum: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.getRemoteOwner(packetId, dest) },
|
||||
"Request getOwner error",
|
||||
)
|
||||
private fun getOwner(destNum: Int) {
|
||||
viewModelScope.launch {
|
||||
val packetId = radioConfigUseCase.getOwner(destNum)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateChannels(new: List<ChannelSettings>, old: List<ChannelSettings>) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
getChannelList(new, old).forEach { setRemoteChannel(destNum, it) }
|
||||
getChannelList(new, old).forEach { channel ->
|
||||
viewModelScope.launch {
|
||||
val packetId = radioConfigUseCase.setRemoteChannel(destNum, channel)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
|
||||
if (destNum == myNodeNum) {
|
||||
viewModelScope.launch {
|
||||
|
|
@ -290,25 +276,16 @@ constructor(
|
|||
_radioConfigState.update { it.copy(channelList = new) }
|
||||
}
|
||||
|
||||
private fun setRemoteChannel(destNum: Int, channel: Channel) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.setRemoteChannel(packetId, dest, channel.encode()) },
|
||||
"Request setRemoteChannel error",
|
||||
)
|
||||
|
||||
private fun getChannel(destNum: Int, index: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.getRemoteChannel(packetId, dest, index) },
|
||||
"Request getChannel error",
|
||||
)
|
||||
|
||||
fun setConfig(config: Config) {
|
||||
setRemoteConfig(destNode.value?.num ?: return, config)
|
||||
private fun getChannel(destNum: Int, index: Int) {
|
||||
viewModelScope.launch {
|
||||
val packetId = radioConfigUseCase.getChannel(destNum, index)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setRemoteConfig(destNum: Int, config: Config) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest ->
|
||||
fun setConfig(config: Config) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
viewModelScope.launch {
|
||||
_radioConfigState.update { state ->
|
||||
state.copy(
|
||||
radioConfig =
|
||||
|
|
@ -324,24 +301,22 @@ constructor(
|
|||
),
|
||||
)
|
||||
}
|
||||
service.setRemoteConfig(packetId, dest, config.encode())
|
||||
},
|
||||
"Request setConfig error",
|
||||
)
|
||||
|
||||
private fun getConfig(destNum: Int, configType: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.getRemoteConfig(packetId, dest, configType) },
|
||||
"Request getConfig error",
|
||||
)
|
||||
|
||||
fun setModuleConfig(config: ModuleConfig) {
|
||||
setRemoteModuleConfig(destNode.value?.num ?: return, config)
|
||||
val packetId = radioConfigUseCase.setConfig(destNum, config)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setRemoteModuleConfig(destNum: Int, config: ModuleConfig) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest ->
|
||||
private fun getConfig(destNum: Int, configType: Int) {
|
||||
viewModelScope.launch {
|
||||
val packetId = radioConfigUseCase.getConfig(destNum, configType)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
fun setModuleConfig(config: ModuleConfig) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
viewModelScope.launch {
|
||||
_radioConfigState.update { state ->
|
||||
state.copy(
|
||||
moduleConfig =
|
||||
|
|
@ -366,97 +341,78 @@ constructor(
|
|||
),
|
||||
)
|
||||
}
|
||||
service.setModuleConfig(packetId, dest, config.encode())
|
||||
},
|
||||
"Request setModuleConfig error",
|
||||
)
|
||||
val packetId = radioConfigUseCase.setModuleConfig(destNum, config)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getModuleConfig(destNum: Int, configType: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.getModuleConfig(packetId, dest, configType) },
|
||||
"Request getModuleConfig error",
|
||||
)
|
||||
private fun getModuleConfig(destNum: Int, configType: Int) {
|
||||
viewModelScope.launch {
|
||||
val packetId = radioConfigUseCase.getModuleConfig(destNum, configType)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
|
||||
fun setRingtone(ringtone: String) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
_radioConfigState.update { it.copy(ringtone = ringtone) }
|
||||
try {
|
||||
meshService?.setRingtone(destNum, ringtone)
|
||||
} catch (ex: RemoteException) {
|
||||
Logger.e { "Set ringtone error: ${ex.message}" }
|
||||
}
|
||||
viewModelScope.launch { radioConfigUseCase.setRingtone(destNum, ringtone) }
|
||||
}
|
||||
|
||||
private fun getRingtone(destNum: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.getRingtone(packetId, dest) },
|
||||
"Request getRingtone error",
|
||||
)
|
||||
private fun getRingtone(destNum: Int) {
|
||||
viewModelScope.launch {
|
||||
val packetId = radioConfigUseCase.getRingtone(destNum)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
|
||||
fun setCannedMessages(messages: String) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
_radioConfigState.update { it.copy(cannedMessageMessages = messages) }
|
||||
try {
|
||||
meshService?.setCannedMessages(destNum, messages)
|
||||
} catch (ex: RemoteException) {
|
||||
Logger.e { "Set canned messages error: ${ex.message}" }
|
||||
viewModelScope.launch { radioConfigUseCase.setCannedMessages(destNum, messages) }
|
||||
}
|
||||
|
||||
private fun getCannedMessages(destNum: Int) {
|
||||
viewModelScope.launch {
|
||||
val packetId = radioConfigUseCase.getCannedMessages(destNum)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCannedMessages(destNum: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.getCannedMessages(packetId, dest) },
|
||||
"Request getCannedMessages error",
|
||||
)
|
||||
private fun getDeviceConnectionStatus(destNum: Int) {
|
||||
viewModelScope.launch {
|
||||
val packetId = radioConfigUseCase.getDeviceConnectionStatus(destNum)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDeviceConnectionStatus(destNum: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.getDeviceConnectionStatus(packetId, dest) },
|
||||
"Request getDeviceConnectionStatus error",
|
||||
)
|
||||
private fun requestShutdown(destNum: Int) {
|
||||
viewModelScope.launch {
|
||||
val packetId = adminActionsUseCase.shutdown(destNum)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestShutdown(destNum: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.requestShutdown(packetId, dest) },
|
||||
"Request shutdown error",
|
||||
)
|
||||
|
||||
private fun requestReboot(destNum: Int) =
|
||||
request(destNum, { service, packetId, dest -> service.requestReboot(packetId, dest) }, "Request reboot error")
|
||||
private fun requestReboot(destNum: Int) {
|
||||
viewModelScope.launch {
|
||||
val packetId = adminActionsUseCase.reboot(destNum)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestFactoryReset(destNum: Int) {
|
||||
request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.requestFactoryReset(packetId, dest) },
|
||||
"Request factory reset error",
|
||||
)
|
||||
if (destNum == myNodeNum) {
|
||||
viewModelScope.launch {
|
||||
// Clear the service's in-memory node cache first so screens refresh immediately.
|
||||
val existingNodeNums = nodeRepository.getNodeDBbyNum().firstOrNull()?.keys?.toList().orEmpty()
|
||||
meshService?.let { service ->
|
||||
existingNodeNums.forEach { service.removeByNodenum(service.getPacketId(), it) }
|
||||
}
|
||||
nodeRepository.clearNodeDB()
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val isLocal = (destNum == myNodeNum)
|
||||
val packetId = adminActionsUseCase.factoryReset(destNum, isLocal)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestNodedbReset(destNum: Int, preserveFavorites: Boolean) {
|
||||
request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.requestNodedbReset(packetId, dest, preserveFavorites) },
|
||||
"Request NodeDB reset error",
|
||||
)
|
||||
if (destNum == myNodeNum) {
|
||||
viewModelScope.launch {
|
||||
// Clear the service's in-memory node cache as well so UI updates immediately.
|
||||
val existingNodeNums = nodeRepository.getNodeDBbyNum().firstOrNull()?.keys?.toList().orEmpty()
|
||||
meshService?.let { service ->
|
||||
existingNodeNums.forEach { service.removeByNodenum(service.getPacketId(), it) }
|
||||
}
|
||||
nodeRepository.clearNodeDB(preserveFavorites)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val isLocal = (destNum == myNodeNum)
|
||||
val packetId = adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal)
|
||||
registerRequestId(packetId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -484,21 +440,18 @@ constructor(
|
|||
|
||||
fun setFixedPosition(position: Position) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
try {
|
||||
meshService?.setFixedPosition(destNum, position)
|
||||
} catch (ex: RemoteException) {
|
||||
Logger.e { "Set fixed position error: ${ex.message}" }
|
||||
}
|
||||
viewModelScope.launch { radioConfigUseCase.setFixedPosition(destNum, position) }
|
||||
}
|
||||
|
||||
fun removeFixedPosition() = setFixedPosition(Position(0.0, 0.0, 0))
|
||||
fun removeFixedPosition() {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
viewModelScope.launch { radioConfigUseCase.removeFixedPosition(destNum) }
|
||||
}
|
||||
|
||||
fun importProfile(uri: Uri, onResult: (DeviceProfile) -> Unit) = viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
app.contentResolver.openInputStream(uri).use { inputStream ->
|
||||
val bytes = inputStream?.readBytes() ?: ByteArray(0)
|
||||
val protobuf = DeviceProfile.ADAPTER.decode(bytes)
|
||||
onResult(protobuf)
|
||||
app.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
importProfileUseCase(inputStream).onSuccess(onResult).onFailure { throw it }
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Logger.e { "Import DeviceProfile error: ${ex.message}" }
|
||||
|
|
@ -506,104 +459,44 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun exportProfile(uri: Uri, profile: DeviceProfile) = viewModelScope.launch { writeToUri(uri, profile) }
|
||||
|
||||
private suspend fun writeToUri(uri: Uri, message: com.squareup.wire.Message<*, *>) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
|
||||
FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
|
||||
outputStream.write(message.encode())
|
||||
fun exportProfile(uri: Uri, profile: DeviceProfile) = viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
|
||||
FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
|
||||
exportProfileUseCase(outputStream, profile)
|
||||
.onSuccess { setResponseStateSuccess() }
|
||||
.onFailure { throw it }
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Logger.e { "Can't write file error: ${ex.message}" }
|
||||
sendError(ex.customMessage)
|
||||
}
|
||||
setResponseStateSuccess()
|
||||
} catch (ex: Exception) {
|
||||
Logger.e { "Can't write file error: ${ex.message}" }
|
||||
sendError(ex.customMessage)
|
||||
}
|
||||
}
|
||||
|
||||
fun exportSecurityConfig(uri: Uri, securityConfig: Config.SecurityConfig) =
|
||||
viewModelScope.launch { writeSecurityKeysJsonToUri(uri, securityConfig) }
|
||||
|
||||
private val indentSpaces = 4
|
||||
|
||||
private suspend fun writeSecurityKeysJsonToUri(uri: Uri, securityConfig: Config.SecurityConfig) =
|
||||
fun exportSecurityConfig(uri: Uri, securityConfig: Config.SecurityConfig) = viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val publicKeyBytes = securityConfig.public_key.toByteArray()
|
||||
val privateKeyBytes = securityConfig.private_key.toByteArray()
|
||||
|
||||
// Convert byte arrays to Base64 strings for human readability in JSON
|
||||
val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP)
|
||||
val privateKeyBase64 = Base64.encodeToString(privateKeyBytes, Base64.NO_WRAP)
|
||||
|
||||
// Create a JSON object
|
||||
val jsonObject =
|
||||
JSONObject().apply {
|
||||
put("timestamp", nowMillis)
|
||||
put("public_key", publicKeyBase64)
|
||||
put("private_key", privateKeyBase64)
|
||||
}
|
||||
|
||||
// Convert JSON object to a string
|
||||
val jsonString = jsonObject.toString(indentSpaces)
|
||||
|
||||
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
|
||||
FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
|
||||
outputStream.write(jsonString.toByteArray(Charsets.UTF_8))
|
||||
exportSecurityConfigUseCase(outputStream, securityConfig)
|
||||
.onSuccess { setResponseStateSuccess() }
|
||||
.onFailure { throw it }
|
||||
}
|
||||
}
|
||||
setResponseStateSuccess()
|
||||
} catch (ex: Exception) {
|
||||
val errorMessage = "Can't write security keys JSON error: ${ex.message}"
|
||||
Logger.e { errorMessage }
|
||||
sendError(ex.customMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun installProfile(protobuf: DeviceProfile) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
with(protobuf) {
|
||||
meshService?.beginEditSettings(destNum)
|
||||
if (long_name != null || short_name != null) {
|
||||
destNode.value?.user?.let {
|
||||
val user = it.copy(long_name = long_name ?: it.long_name, short_name = short_name ?: it.short_name)
|
||||
setOwner(user)
|
||||
}
|
||||
}
|
||||
config?.let { lc ->
|
||||
lc.device?.let { setConfig(Config(device = it)) }
|
||||
lc.position?.let { setConfig(Config(position = it)) }
|
||||
lc.power?.let { setConfig(Config(power = it)) }
|
||||
lc.network?.let { setConfig(Config(network = it)) }
|
||||
lc.display?.let { setConfig(Config(display = it)) }
|
||||
lc.lora?.let { setConfig(Config(lora = it)) }
|
||||
lc.bluetooth?.let { setConfig(Config(bluetooth = it)) }
|
||||
lc.security?.let { setConfig(Config(security = it)) }
|
||||
}
|
||||
if (fixed_position != null) {
|
||||
setFixedPosition(Position(fixed_position!!))
|
||||
}
|
||||
module_config?.let { lmc ->
|
||||
lmc.mqtt?.let { setModuleConfig(ModuleConfig(mqtt = it)) }
|
||||
lmc.serial?.let { setModuleConfig(ModuleConfig(serial = it)) }
|
||||
lmc.external_notification?.let { setModuleConfig(ModuleConfig(external_notification = it)) }
|
||||
lmc.store_forward?.let { setModuleConfig(ModuleConfig(store_forward = it)) }
|
||||
lmc.range_test?.let { setModuleConfig(ModuleConfig(range_test = it)) }
|
||||
lmc.telemetry?.let { setModuleConfig(ModuleConfig(telemetry = it)) }
|
||||
lmc.canned_message?.let { setModuleConfig(ModuleConfig(canned_message = it)) }
|
||||
lmc.audio?.let { setModuleConfig(ModuleConfig(audio = it)) }
|
||||
lmc.remote_hardware?.let { setModuleConfig(ModuleConfig(remote_hardware = it)) }
|
||||
lmc.neighbor_info?.let { setModuleConfig(ModuleConfig(neighbor_info = it)) }
|
||||
lmc.ambient_lighting?.let { setModuleConfig(ModuleConfig(ambient_lighting = it)) }
|
||||
lmc.detection_sensor?.let { setModuleConfig(ModuleConfig(detection_sensor = it)) }
|
||||
lmc.paxcounter?.let { setModuleConfig(ModuleConfig(paxcounter = it)) }
|
||||
lmc.statusmessage?.let { setModuleConfig(ModuleConfig(statusmessage = it)) }
|
||||
lmc.traffic_management?.let { setModuleConfig(ModuleConfig(traffic_management = it)) }
|
||||
lmc.tak?.let { setModuleConfig(ModuleConfig(tak = it)) }
|
||||
}
|
||||
meshService?.commitEditSettings(destNum)
|
||||
}
|
||||
viewModelScope.launch { installProfileUseCase(destNum, protobuf, destNode.value?.user) }
|
||||
}
|
||||
|
||||
fun clearPacketResponse() {
|
||||
|
|
@ -686,6 +579,8 @@ constructor(
|
|||
|
||||
private fun sendError(id: StringResource) = setResponseStateError(UiText.Resource(id))
|
||||
|
||||
private fun sendError(error: UiText) = setResponseStateError(error)
|
||||
|
||||
private fun setResponseStateError(error: UiText) {
|
||||
_radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) }
|
||||
}
|
||||
|
|
@ -701,171 +596,156 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun processPacketResponse(packet: MeshPacket) {
|
||||
val data = packet.decoded ?: return
|
||||
if (data.request_id !in requestIds.value) return
|
||||
val route = radioConfigState.value.route
|
||||
|
||||
val destNum = destNode.value?.num ?: return
|
||||
val debugMsg = "requestId: ${data.request_id.toUInt()} to: ${destNum.toUInt()} received %s"
|
||||
|
||||
if (data.portnum == PortNum.ROUTING_APP) {
|
||||
val parsed = Routing.ADAPTER.decode(data.payload)
|
||||
Logger.d { debugMsg.format(parsed.error_reason?.name) }
|
||||
if (parsed.error_reason != Routing.Error.NONE) {
|
||||
sendError(getStringResFrom(parsed.error_reason?.value ?: 0))
|
||||
} else if (packet.from == destNum && route.isEmpty()) {
|
||||
requestIds.update { it.apply { remove(data.request_id) } }
|
||||
if (requestIds.value.isEmpty()) {
|
||||
setResponseStateSuccess()
|
||||
} else {
|
||||
incrementCompleted()
|
||||
}
|
||||
private fun registerRequestId(packetId: Int) {
|
||||
requestIds.update { it.apply { add(packetId) } }
|
||||
_radioConfigState.update { state ->
|
||||
if (state.responseState is ResponseState.Loading) {
|
||||
val total = maxOf(requestIds.value.size, state.responseState.total)
|
||||
state.copy(responseState = state.responseState.copy(total = total))
|
||||
} else {
|
||||
state.copy(
|
||||
route = "", // setter (response is PortNum.ROUTING_APP)
|
||||
responseState = ResponseState.Loading(),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (data.portnum == PortNum.ADMIN_APP) {
|
||||
val parsed = AdminMessage.ADAPTER.decode(data.payload)
|
||||
// Explicitly log the non-null field name for clarity
|
||||
val variant =
|
||||
when {
|
||||
parsed.get_device_metadata_response != null -> "get_device_metadata_response"
|
||||
parsed.get_channel_response != null -> "get_channel_response"
|
||||
parsed.get_owner_response != null -> "get_owner_response"
|
||||
parsed.get_config_response != null -> "get_config_response"
|
||||
parsed.get_module_config_response != null -> "get_module_config_response"
|
||||
parsed.get_canned_message_module_messages_response != null ->
|
||||
"get_canned_message_module_messages_response"
|
||||
parsed.get_ringtone_response != null -> "get_ringtone_response"
|
||||
parsed.get_device_connection_status_response != null -> "get_device_connection_status_response"
|
||||
else -> "unknown"
|
||||
}
|
||||
Logger.d { debugMsg.format(variant) }
|
||||
if (destNum != packet.from) {
|
||||
sendError("Unexpected sender: ${packet.from.toUInt()} instead of ${destNum.toUInt()}.")
|
||||
return
|
||||
}
|
||||
when {
|
||||
parsed.get_device_metadata_response != null -> {
|
||||
_radioConfigState.update { it.copy(metadata = parsed.get_device_metadata_response) }
|
||||
incrementCompleted()
|
||||
}
|
||||
}
|
||||
|
||||
parsed.get_channel_response != null -> {
|
||||
val response = parsed.get_channel_response!!
|
||||
// Stop once we get to the first disabled entry
|
||||
if (response.role != Channel.Role.DISABLED) {
|
||||
_radioConfigState.update { state ->
|
||||
state.copy(
|
||||
channelList =
|
||||
state.channelList.toMutableList().apply {
|
||||
val index = response.index
|
||||
val settings = response.settings ?: ChannelSettings()
|
||||
// Make sure list is large enough
|
||||
while (size <= index) add(ChannelSettings())
|
||||
set(index, settings)
|
||||
},
|
||||
)
|
||||
}
|
||||
incrementCompleted()
|
||||
val index = response.index
|
||||
if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
|
||||
// Not done yet, request next channel
|
||||
getChannel(destNum, index + 1)
|
||||
}
|
||||
private fun processPacketResponse(packet: MeshPacket) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
val result = processRadioResponseUseCase(packet, destNum, requestIds.value) ?: return
|
||||
val route = radioConfigState.value.route
|
||||
|
||||
when (result) {
|
||||
is RadioResponseResult.Error -> sendError(result.message)
|
||||
is RadioResponseResult.Success -> {
|
||||
if (route.isEmpty()) {
|
||||
val data = packet.decoded!!
|
||||
requestIds.update { it.apply { remove(data.request_id) } }
|
||||
if (requestIds.value.isEmpty()) {
|
||||
setResponseStateSuccess()
|
||||
} else {
|
||||
// Received last channel, update total and start channel editor
|
||||
setResponseStateTotal(response.index + 1)
|
||||
incrementCompleted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsed.get_owner_response != null -> {
|
||||
_radioConfigState.update { it.copy(userConfig = parsed.get_owner_response!!) }
|
||||
incrementCompleted()
|
||||
}
|
||||
is RadioResponseResult.Metadata -> {
|
||||
_radioConfigState.update { it.copy(metadata = result.metadata) }
|
||||
incrementCompleted()
|
||||
}
|
||||
|
||||
parsed.get_config_response != null -> {
|
||||
val response = parsed.get_config_response!!
|
||||
is RadioResponseResult.ChannelResponse -> {
|
||||
val response = result.channel
|
||||
// Stop once we get to the first disabled entry
|
||||
if (response.role != Channel.Role.DISABLED) {
|
||||
_radioConfigState.update { state ->
|
||||
state.copy(
|
||||
radioConfig =
|
||||
state.radioConfig.copy(
|
||||
device = response.device ?: state.radioConfig.device,
|
||||
position = response.position ?: state.radioConfig.position,
|
||||
power = response.power ?: state.radioConfig.power,
|
||||
network = response.network ?: state.radioConfig.network,
|
||||
display = response.display ?: state.radioConfig.display,
|
||||
lora = response.lora ?: state.radioConfig.lora,
|
||||
bluetooth = response.bluetooth ?: state.radioConfig.bluetooth,
|
||||
security = response.security ?: state.radioConfig.security,
|
||||
),
|
||||
channelList =
|
||||
state.channelList.toMutableList().apply {
|
||||
val index = response.index
|
||||
val settings = response.settings ?: ChannelSettings()
|
||||
// Make sure list is large enough
|
||||
while (size <= index) add(ChannelSettings())
|
||||
set(index, settings)
|
||||
},
|
||||
)
|
||||
}
|
||||
incrementCompleted()
|
||||
}
|
||||
|
||||
parsed.get_module_config_response != null -> {
|
||||
val response = parsed.get_module_config_response!!
|
||||
_radioConfigState.update { state ->
|
||||
state.copy(
|
||||
moduleConfig =
|
||||
state.moduleConfig.copy(
|
||||
mqtt = response.mqtt ?: state.moduleConfig.mqtt,
|
||||
serial = response.serial ?: state.moduleConfig.serial,
|
||||
external_notification =
|
||||
response.external_notification ?: state.moduleConfig.external_notification,
|
||||
store_forward = response.store_forward ?: state.moduleConfig.store_forward,
|
||||
range_test = response.range_test ?: state.moduleConfig.range_test,
|
||||
telemetry = response.telemetry ?: state.moduleConfig.telemetry,
|
||||
canned_message = response.canned_message ?: state.moduleConfig.canned_message,
|
||||
audio = response.audio ?: state.moduleConfig.audio,
|
||||
remote_hardware = response.remote_hardware ?: state.moduleConfig.remote_hardware,
|
||||
neighbor_info = response.neighbor_info ?: state.moduleConfig.neighbor_info,
|
||||
ambient_lighting = response.ambient_lighting ?: state.moduleConfig.ambient_lighting,
|
||||
detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor,
|
||||
paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter,
|
||||
statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage,
|
||||
traffic_management =
|
||||
response.traffic_management ?: state.moduleConfig.traffic_management,
|
||||
tak = response.tak ?: state.moduleConfig.tak,
|
||||
),
|
||||
)
|
||||
val index = response.index
|
||||
if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
|
||||
// Not done yet, request next channel
|
||||
getChannel(destNum, index + 1)
|
||||
}
|
||||
incrementCompleted()
|
||||
} else {
|
||||
// Received last channel, update total and start channel editor
|
||||
setResponseStateTotal(response.index + 1)
|
||||
}
|
||||
|
||||
parsed.get_canned_message_module_messages_response != null -> {
|
||||
_radioConfigState.update {
|
||||
it.copy(cannedMessageMessages = parsed.get_canned_message_module_messages_response!!)
|
||||
}
|
||||
incrementCompleted()
|
||||
}
|
||||
|
||||
parsed.get_ringtone_response != null -> {
|
||||
_radioConfigState.update { it.copy(ringtone = parsed.get_ringtone_response!!) }
|
||||
incrementCompleted()
|
||||
}
|
||||
|
||||
parsed.get_device_connection_status_response != null -> {
|
||||
_radioConfigState.update {
|
||||
it.copy(deviceConnectionStatus = parsed.get_device_connection_status_response!!)
|
||||
}
|
||||
incrementCompleted()
|
||||
}
|
||||
|
||||
else -> Logger.d { "No custom processing needed for $parsed" }
|
||||
}
|
||||
|
||||
if (AdminRoute.entries.any { it.name == route }) {
|
||||
sendAdminRequest(destNum)
|
||||
is RadioResponseResult.Owner -> {
|
||||
_radioConfigState.update { it.copy(userConfig = result.user) }
|
||||
incrementCompleted()
|
||||
}
|
||||
requestIds.update { it.apply { remove(data.request_id) } }
|
||||
|
||||
if (requestIds.value.isEmpty()) {
|
||||
if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) {
|
||||
clearPacketResponse()
|
||||
} else if (route.isEmpty()) {
|
||||
setResponseStateSuccess()
|
||||
is RadioResponseResult.ConfigResponse -> {
|
||||
val response = result.config
|
||||
_radioConfigState.update { state ->
|
||||
state.copy(
|
||||
radioConfig =
|
||||
state.radioConfig.copy(
|
||||
device = response.device ?: state.radioConfig.device,
|
||||
position = response.position ?: state.radioConfig.position,
|
||||
power = response.power ?: state.radioConfig.power,
|
||||
network = response.network ?: state.radioConfig.network,
|
||||
display = response.display ?: state.radioConfig.display,
|
||||
lora = response.lora ?: state.radioConfig.lora,
|
||||
bluetooth = response.bluetooth ?: state.radioConfig.bluetooth,
|
||||
security = response.security ?: state.radioConfig.security,
|
||||
),
|
||||
)
|
||||
}
|
||||
incrementCompleted()
|
||||
}
|
||||
|
||||
is RadioResponseResult.ModuleConfigResponse -> {
|
||||
val response = result.config
|
||||
_radioConfigState.update { state ->
|
||||
state.copy(
|
||||
moduleConfig =
|
||||
state.moduleConfig.copy(
|
||||
mqtt = response.mqtt ?: state.moduleConfig.mqtt,
|
||||
serial = response.serial ?: state.moduleConfig.serial,
|
||||
external_notification =
|
||||
response.external_notification ?: state.moduleConfig.external_notification,
|
||||
store_forward = response.store_forward ?: state.moduleConfig.store_forward,
|
||||
range_test = response.range_test ?: state.moduleConfig.range_test,
|
||||
telemetry = response.telemetry ?: state.moduleConfig.telemetry,
|
||||
canned_message = response.canned_message ?: state.moduleConfig.canned_message,
|
||||
audio = response.audio ?: state.moduleConfig.audio,
|
||||
remote_hardware = response.remote_hardware ?: state.moduleConfig.remote_hardware,
|
||||
neighbor_info = response.neighbor_info ?: state.moduleConfig.neighbor_info,
|
||||
ambient_lighting = response.ambient_lighting ?: state.moduleConfig.ambient_lighting,
|
||||
detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor,
|
||||
paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter,
|
||||
statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage,
|
||||
traffic_management =
|
||||
response.traffic_management ?: state.moduleConfig.traffic_management,
|
||||
tak = response.tak ?: state.moduleConfig.tak,
|
||||
),
|
||||
)
|
||||
}
|
||||
incrementCompleted()
|
||||
}
|
||||
|
||||
is RadioResponseResult.CannedMessages -> {
|
||||
_radioConfigState.update { it.copy(cannedMessageMessages = result.messages) }
|
||||
incrementCompleted()
|
||||
}
|
||||
|
||||
is RadioResponseResult.Ringtone -> {
|
||||
_radioConfigState.update { it.copy(ringtone = result.ringtone) }
|
||||
incrementCompleted()
|
||||
}
|
||||
|
||||
is RadioResponseResult.ConnectionStatus -> {
|
||||
_radioConfigState.update { it.copy(deviceConnectionStatus = result.status) }
|
||||
incrementCompleted()
|
||||
}
|
||||
}
|
||||
|
||||
if (AdminRoute.entries.any { it.name == route }) {
|
||||
sendAdminRequest(destNum)
|
||||
}
|
||||
|
||||
val requestId = packet.decoded?.request_id ?: return
|
||||
requestIds.update { it.apply { remove(requestId) } }
|
||||
|
||||
if (requestIds.value.isEmpty()) {
|
||||
if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) {
|
||||
clearPacketResponse()
|
||||
} else if (route.isEmpty()) {
|
||||
setResponseStateSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import org.junit.runner.RunWith
|
|||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.getString
|
||||
import org.meshtastic.core.resources.use_homoglyph_characters_encoding
|
||||
import org.meshtastic.feature.settings.component.HomoglyphSetting
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.Locale
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.feature.settings
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.database.DatabaseManager
|
||||
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
|
||||
import org.meshtastic.core.prefs.ui.UiPrefs
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SettingsViewModelTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
|
||||
private val radioController: RadioController = mockk(relaxed = true)
|
||||
private val nodeRepository: NodeRepository = mockk(relaxed = true)
|
||||
private val uiPrefs: UiPrefs = mockk(relaxed = true)
|
||||
private val buildConfigProvider: BuildConfigProvider = mockk(relaxed = true)
|
||||
private val databaseManager: DatabaseManager = mockk(relaxed = true)
|
||||
private val meshLogPrefs: MeshLogPrefs = mockk(relaxed = true)
|
||||
|
||||
private val setThemeUseCase: SetThemeUseCase = mockk(relaxed = true)
|
||||
private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase = mockk(relaxed = true)
|
||||
private val setProvideLocationUseCase: SetProvideLocationUseCase = mockk(relaxed = true)
|
||||
private val setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase = mockk(relaxed = true)
|
||||
private val setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase = mockk(relaxed = true)
|
||||
private val meshLocationUseCase: MeshLocationUseCase = mockk(relaxed = true)
|
||||
private val exportDataUseCase: ExportDataUseCase = mockk(relaxed = true)
|
||||
private val isOtaCapableUseCase: IsOtaCapableUseCase = mockk(relaxed = true)
|
||||
|
||||
private lateinit var viewModel: SettingsViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
|
||||
// Return real StateFlows to avoid ClassCastException
|
||||
every { databaseManager.cacheLimit } returns MutableStateFlow(100)
|
||||
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
|
||||
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
|
||||
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(org.meshtastic.proto.LocalConfig())
|
||||
every { radioController.connectionState } returns
|
||||
MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
|
||||
every { isOtaCapableUseCase() } returns flowOf(false)
|
||||
|
||||
viewModel =
|
||||
SettingsViewModel(
|
||||
app = mockk(),
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
radioController = radioController,
|
||||
nodeRepository = nodeRepository,
|
||||
uiPrefs = uiPrefs,
|
||||
buildConfigProvider = buildConfigProvider,
|
||||
databaseManager = databaseManager,
|
||||
meshLogPrefs = meshLogPrefs,
|
||||
setThemeUseCase = setThemeUseCase,
|
||||
setAppIntroCompletedUseCase = setAppIntroCompletedUseCase,
|
||||
setProvideLocationUseCase = setProvideLocationUseCase,
|
||||
setDatabaseCacheLimitUseCase = setDatabaseCacheLimitUseCase,
|
||||
setMeshLogSettingsUseCase = setMeshLogSettingsUseCase,
|
||||
meshLocationUseCase = meshLocationUseCase,
|
||||
exportDataUseCase = exportDataUseCase,
|
||||
isOtaCapableUseCase = isOtaCapableUseCase,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setTheme calls useCase`() {
|
||||
viewModel.setTheme(1)
|
||||
verify { setThemeUseCase(1) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setDbCacheLimit calls useCase`() {
|
||||
viewModel.setDbCacheLimit(50)
|
||||
verify { setDatabaseCacheLimitUseCase(50) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startProvidingLocation calls useCase`() {
|
||||
viewModel.startProvidingLocation()
|
||||
verify { meshLocationUseCase.startProvidingLocation() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.feature.settings.debugging
|
||||
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.data.repository.MeshLogRepository
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
|
||||
import org.meshtastic.core.ui.util.AlertManager
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DebugViewModelTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
private val meshLogRepository: MeshLogRepository = mockk(relaxed = true)
|
||||
private val nodeRepository: NodeRepository = mockk(relaxed = true)
|
||||
private val meshLogPrefs: MeshLogPrefs = mockk(relaxed = true)
|
||||
private val alertManager: AlertManager = mockk(relaxed = true)
|
||||
|
||||
private lateinit var viewModel: DebugViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
|
||||
every { meshLogRepository.getAllLogs() } returns flowOf(emptyList())
|
||||
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
|
||||
every { meshLogPrefs.retentionDays } returns 7
|
||||
every { meshLogPrefs.loggingEnabled } returns true
|
||||
|
||||
viewModel =
|
||||
DebugViewModel(
|
||||
meshLogRepository = meshLogRepository,
|
||||
nodeRepository = nodeRepository,
|
||||
meshLogPrefs = meshLogPrefs,
|
||||
alertManager = alertManager,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setRetentionDays updates prefs and deletes old logs`() = runTest {
|
||||
viewModel.setRetentionDays(14)
|
||||
|
||||
verify { meshLogPrefs.retentionDays = 14 }
|
||||
coVerify { meshLogRepository.deleteLogsOlderThan(14) }
|
||||
assertEquals(14, viewModel.retentionDays.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setLoggingEnabled false deletes all logs`() = runTest {
|
||||
viewModel.setLoggingEnabled(false)
|
||||
|
||||
verify { meshLogPrefs.loggingEnabled = false }
|
||||
coVerify { meshLogRepository.deleteAll() }
|
||||
assertEquals(false, viewModel.loggingEnabled.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search filters results correctly`() = runTest {
|
||||
val logs =
|
||||
listOf(
|
||||
DebugViewModel.UiMeshLog("1", "TypeA", "Date1", "Message Apple"),
|
||||
DebugViewModel.UiMeshLog("2", "TypeB", "Date2", "Message Banana"),
|
||||
)
|
||||
|
||||
viewModel.searchManager.updateMatches("Apple", logs)
|
||||
|
||||
val state = viewModel.searchState.value
|
||||
assertEquals(true, state.hasMatches)
|
||||
assertEquals(1, state.allMatches.size)
|
||||
assertEquals(0, state.allMatches[0].logIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requestDeleteAllLogs shows alert`() {
|
||||
viewModel.requestDeleteAllLogs()
|
||||
verify { alertManager.showAlert(titleRes = any(), messageRes = any(), onConfirm = any()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.feature.settings.filter
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.prefs.filter.FilterPrefs
|
||||
import org.meshtastic.core.service.filter.MessageFilterService
|
||||
|
||||
class FilterSettingsViewModelTest {
|
||||
|
||||
private val filterPrefs: FilterPrefs = mockk(relaxed = true)
|
||||
private val messageFilterService: MessageFilterService = mockk(relaxed = true)
|
||||
|
||||
private lateinit var viewModel: FilterSettingsViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { filterPrefs.filterEnabled } returns true
|
||||
every { filterPrefs.filterWords } returns setOf("apple", "banana")
|
||||
|
||||
viewModel = FilterSettingsViewModel(filterPrefs = filterPrefs, messageFilterService = messageFilterService)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setFilterEnabled updates prefs and state`() {
|
||||
viewModel.setFilterEnabled(false)
|
||||
verify { filterPrefs.filterEnabled = false }
|
||||
assertEquals(false, viewModel.filterEnabled.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addFilterWord updates prefs and rebuilds patterns`() {
|
||||
viewModel.addFilterWord("cherry")
|
||||
|
||||
verify { filterPrefs.filterWords = any() }
|
||||
verify { messageFilterService.rebuildPatterns() }
|
||||
assertEquals(listOf("apple", "banana", "cherry"), viewModel.filterWords.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeFilterWord updates prefs and rebuilds patterns`() {
|
||||
viewModel.removeFilterWord("apple")
|
||||
|
||||
verify { filterPrefs.filterWords = any() }
|
||||
verify { messageFilterService.rebuildPatterns() }
|
||||
assertEquals(listOf("banana"), viewModel.filterWords.value)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.feature.settings.radio
|
||||
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.domain.usecase.settings.CleanNodeDatabaseUseCase
|
||||
import org.meshtastic.core.ui.util.AlertManager
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class CleanNodeDatabaseViewModelTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private lateinit var cleanNodeDatabaseUseCase: CleanNodeDatabaseUseCase
|
||||
private lateinit var alertManager: AlertManager
|
||||
private lateinit var viewModel: CleanNodeDatabaseViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
cleanNodeDatabaseUseCase = mockk(relaxed = true)
|
||||
alertManager = mockk(relaxed = true)
|
||||
viewModel = CleanNodeDatabaseViewModel(cleanNodeDatabaseUseCase, alertManager)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getNodesToDelete updates state`() = runTest {
|
||||
val nodes = listOf(Node(num = 1), Node(num = 2))
|
||||
coEvery { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
|
||||
|
||||
viewModel.getNodesToDelete()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(nodes, viewModel.nodesToDelete.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cleanNodes calls useCase and clears state`() = runTest {
|
||||
val nodes = listOf(Node(num = 1))
|
||||
coEvery { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
|
||||
viewModel.getNodesToDelete()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.cleanNodes()
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify { cleanNodeDatabaseUseCase.cleanNodes(listOf(1)) }
|
||||
assertEquals(0, viewModel.nodesToDelete.value.size)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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 org.meshtastic.feature.settings.radio
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.data.repository.LocationRepository
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
|
||||
import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
|
||||
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
|
||||
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
|
||||
import org.meshtastic.core.prefs.map.MapConsentPrefs
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.ChannelSettings
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.DeviceProfile
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RadioConfigViewModelTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
|
||||
private val packetRepository: PacketRepository = mockk(relaxed = true)
|
||||
private val serviceRepository: ServiceRepository = mockk(relaxed = true)
|
||||
private val nodeRepository: NodeRepository = mockk(relaxed = true)
|
||||
private val locationRepository: LocationRepository = mockk(relaxed = true)
|
||||
private val mapConsentPrefs: MapConsentPrefs = mockk(relaxed = true)
|
||||
private val analyticsPrefs: AnalyticsPrefs = mockk(relaxed = true)
|
||||
private val homoglyphEncodingPrefs: HomoglyphPrefs = mockk(relaxed = true)
|
||||
private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase = mockk(relaxed = true)
|
||||
private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase = mockk(relaxed = true)
|
||||
private val importProfileUseCase: ImportProfileUseCase = mockk(relaxed = true)
|
||||
private val exportProfileUseCase: ExportProfileUseCase = mockk(relaxed = true)
|
||||
private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase = mockk(relaxed = true)
|
||||
private val installProfileUseCase: InstallProfileUseCase = mockk(relaxed = true)
|
||||
private val radioConfigUseCase: RadioConfigUseCase = mockk(relaxed = true)
|
||||
private val adminActionsUseCase: AdminActionsUseCase = mockk(relaxed = true)
|
||||
private val processRadioResponseUseCase: ProcessRadioResponseUseCase = mockk(relaxed = true)
|
||||
|
||||
private lateinit var viewModel: RadioConfigViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
|
||||
every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(DeviceProfile())
|
||||
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig())
|
||||
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
|
||||
every { radioConfigRepository.moduleConfigFlow } returns MutableStateFlow(LocalModuleConfig())
|
||||
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
|
||||
every { serviceRepository.connectionState } returns
|
||||
MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
|
||||
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
|
||||
|
||||
viewModel = createViewModel()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun createViewModel() = RadioConfigViewModel(
|
||||
savedStateHandle = SavedStateHandle(),
|
||||
app = mockk(),
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
packetRepository = packetRepository,
|
||||
serviceRepository = serviceRepository,
|
||||
nodeRepository = nodeRepository,
|
||||
locationRepository = locationRepository,
|
||||
mapConsentPrefs = mapConsentPrefs,
|
||||
analyticsPrefs = analyticsPrefs,
|
||||
homoglyphEncodingPrefs = homoglyphEncodingPrefs,
|
||||
toggleAnalyticsUseCase = toggleAnalyticsUseCase,
|
||||
toggleHomoglyphEncodingUseCase = toggleHomoglyphEncodingUseCase,
|
||||
importProfileUseCase = importProfileUseCase,
|
||||
exportProfileUseCase = exportProfileUseCase,
|
||||
exportSecurityConfigUseCase = exportSecurityConfigUseCase,
|
||||
installProfileUseCase = installProfileUseCase,
|
||||
radioConfigUseCase = radioConfigUseCase,
|
||||
adminActionsUseCase = adminActionsUseCase,
|
||||
processRadioResponseUseCase = processRadioResponseUseCase,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `setConfig updates state and calls useCase`() = runTest {
|
||||
val node = Node(num = 123)
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
|
||||
viewModel = createViewModel()
|
||||
|
||||
val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER))
|
||||
coEvery { radioConfigUseCase.setConfig(123, any()) } returns 42
|
||||
|
||||
viewModel.setConfig(config)
|
||||
|
||||
val state = viewModel.radioConfigState.value
|
||||
assertEquals(Config.DeviceConfig.Role.ROUTER, state.radioConfig.device?.role)
|
||||
coVerify { radioConfigUseCase.setConfig(123, config) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processPacketResponse updates state on metadata result`() = runTest {
|
||||
val node = Node(num = 123)
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
|
||||
|
||||
val packet = MeshPacket()
|
||||
val metadata = DeviceMetadata(firmware_version = "3.0.0")
|
||||
val packetFlow = MutableSharedFlow<MeshPacket>()
|
||||
|
||||
every { serviceRepository.meshPacketFlow } returns packetFlow
|
||||
every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Metadata(metadata)
|
||||
|
||||
viewModel = createViewModel()
|
||||
|
||||
packetFlow.emit(packet)
|
||||
|
||||
val state = viewModel.radioConfigState.value
|
||||
assertEquals("3.0.0", state.metadata?.firmware_version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setOwner calls useCase`() = runTest {
|
||||
val node = Node(num = 123)
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
|
||||
viewModel = createViewModel()
|
||||
|
||||
val user = org.meshtastic.proto.User(long_name = "Test")
|
||||
coEvery { radioConfigUseCase.setOwner(123, any()) } returns 42
|
||||
|
||||
viewModel.setOwner(user)
|
||||
|
||||
coVerify { radioConfigUseCase.setOwner(123, user) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateChannels calls useCase for each changed channel`() = runTest {
|
||||
val node = Node(num = 123)
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
|
||||
viewModel = createViewModel()
|
||||
|
||||
val old = listOf(ChannelSettings(name = "Old"))
|
||||
val new = listOf(ChannelSettings(name = "New"))
|
||||
|
||||
coEvery { radioConfigUseCase.setRemoteChannel(123, any()) } returns 42
|
||||
|
||||
viewModel.updateChannels(new, old)
|
||||
|
||||
coVerify { radioConfigUseCase.setRemoteChannel(123, any()) }
|
||||
assertEquals(new, viewModel.radioConfigState.value.channelList)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setResponseStateLoading for REBOOT calls useCase after packet response`() = runTest {
|
||||
val node = Node(num = 123)
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
|
||||
|
||||
val packetFlow = MutableSharedFlow<MeshPacket>()
|
||||
every { serviceRepository.meshPacketFlow } returns packetFlow
|
||||
every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.Success
|
||||
|
||||
viewModel = createViewModel()
|
||||
|
||||
coEvery { adminActionsUseCase.reboot(123) } returns 42
|
||||
|
||||
viewModel.setResponseStateLoading(AdminRoute.REBOOT)
|
||||
|
||||
// Emit a packet to trigger processPacketResponse -> sendAdminRequest
|
||||
packetFlow.emit(MeshPacket())
|
||||
|
||||
coVerify { adminActionsUseCase.reboot(123) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setResponseStateLoading for FACTORY_RESET calls useCase after packet response`() = runTest {
|
||||
val node = Node(num = 123)
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
|
||||
|
||||
val packetFlow = MutableSharedFlow<MeshPacket>()
|
||||
every { serviceRepository.meshPacketFlow } returns packetFlow
|
||||
every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.Success
|
||||
|
||||
viewModel = createViewModel()
|
||||
|
||||
coEvery { adminActionsUseCase.factoryReset(123, any()) } returns 42
|
||||
|
||||
viewModel.setResponseStateLoading(AdminRoute.FACTORY_RESET)
|
||||
|
||||
// Emit a packet to trigger processPacketResponse -> sendAdminRequest
|
||||
packetFlow.emit(MeshPacket())
|
||||
|
||||
coVerify { adminActionsUseCase.factoryReset(123, any()) }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue