From bcc3a0f1079077c536444c67909bbb9cec44f944 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 20 Jun 2025 23:19:51 +0000 Subject: [PATCH] feat: Allow unlocking excluded modules (#2180) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../java/com/geeksville/mesh/model/UIState.kt | 13 ++++- .../main/java/com/geeksville/mesh/ui/Main.kt | 46 +++++++++++----- .../mesh/ui/radioconfig/RadioConfig.kt | 55 ++++++++++++++++++- app/src/main/res/values/strings.xml | 1 + 4 files changed, 100 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 157f344e8..2ca3576d9 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -206,7 +206,18 @@ class UIViewModel @Inject constructor( private val _lastTraceRouteTime = MutableStateFlow(null) val lastTraceRouteTime: StateFlow = _lastTraceRouteTime.asStateFlow() - val clientNotification: StateFlow = radioConfigRepository.clientNotification + private val _excludedModulesUnlocked = MutableStateFlow(false) + val excludedModulesUnlocked: StateFlow = _excludedModulesUnlocked.asStateFlow() + + fun unlockExcludedModules() { + viewModelScope.launch { + _excludedModulesUnlocked.value = true + } + } + + val clientNotification: StateFlow = + radioConfigRepository.clientNotification + fun clearClientNotification(notification: MeshProtos.ClientNotification) { radioConfigRepository.clearClientNotification() meshServiceNotifications.clearClientNotification(notification) diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 05367408a..b8a7c2681 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -90,6 +90,7 @@ import com.geeksville.mesh.ui.common.components.MultipleChoiceAlertDialog import com.geeksville.mesh.ui.common.components.ScannedQrCodeDialog import com.geeksville.mesh.ui.common.components.SimpleAlertDialog import com.geeksville.mesh.ui.debug.DebugMenuActions +import com.geeksville.mesh.ui.radioconfig.RadioConfigMenuActions enum class TopLevelDestination(@StringRes val label: Int, val icon: ImageVector, val route: Route) { Contacts(R.string.contacts, Icons.AutoMirrored.TwoTone.Chat, ContactsRoutes.Contacts), @@ -126,8 +127,6 @@ fun MainScreen( VersionChecks(viewModel) - val title by viewModel.title.collectAsStateWithLifecycle() - val alertDialogState by viewModel.currentAlert.collectAsStateWithLifecycle() alertDialogState?.let { state -> if (state.choices.isNotEmpty()) { @@ -245,7 +244,7 @@ fun MainScreen( .fillMaxSize() ) { MainAppBar( - title = title, + viewModel = viewModel, isManaged = localConfig.security.isManaged, navController = navController, onAction = { action -> @@ -336,7 +335,7 @@ enum class MainMenuAction(@StringRes val stringRes: Int) { @Suppress("LongMethod") @Composable private fun MainAppBar( - title: String, + viewModel: UIViewModel = hiltViewModel(), isManaged: Boolean, navController: NavHostController, modifier: Modifier = Modifier, @@ -350,6 +349,7 @@ private fun MainAppBar( if (currentDestination?.hasRoute() == true) { return } + val title by viewModel.title.collectAsStateWithLifecycle("") TopAppBar( title = { when { @@ -405,19 +405,39 @@ private fun MainAppBar( } }, actions = { - when { - currentDestination == null || isTopLevelRoute -> - MainMenuActions(isManaged, onAction) - - currentDestination.hasRoute() -> - DebugMenuActions() - - else -> {} - } + TopBarActions( + viewModel = viewModel, + currentDestination = currentDestination, + isManaged = isManaged, + isTopLevelRoute = isTopLevelRoute, + onAction = onAction + ) }, ) } +@Composable +private fun TopBarActions( + viewModel: UIViewModel = hiltViewModel(), + currentDestination: NavDestination?, + isManaged: Boolean, + isTopLevelRoute: Boolean, + onAction: (MainMenuAction) -> Unit +) { + when { + currentDestination == null || isTopLevelRoute -> + MainMenuActions(isManaged, onAction) + + currentDestination.hasRoute() -> + DebugMenuActions() + + currentDestination.hasRoute() -> + RadioConfigMenuActions(viewModel = viewModel) + + else -> {} + } +} + @Composable private fun MainMenuActions( isManaged: Boolean, diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfig.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfig.kt index b391ec888..e27f05f67 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfig.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfig.kt @@ -19,6 +19,7 @@ package com.geeksville.mesh.ui.radioconfig import android.app.Activity import android.content.Intent +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes @@ -45,17 +46,21 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -73,6 +78,8 @@ import com.geeksville.mesh.ui.common.components.PreferenceCategory import com.geeksville.mesh.ui.common.theme.AppTheme import com.geeksville.mesh.ui.radioconfig.components.EditDeviceProfileDialog import com.geeksville.mesh.ui.radioconfig.components.PacketResponseStateDialog +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable @@ -88,6 +95,8 @@ fun RadioConfigScreen( uiViewModel.setTitle(it) } + val excludedModulesUnlocked by uiViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle() + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() var isWaiting by remember { mutableStateOf(false) } if (isWaiting) { @@ -161,6 +170,7 @@ fun RadioConfigScreen( RadioConfigItemList( modifier = modifier, state = state, + excludedModulesUnlocked = excludedModulesUnlocked, onRouteClick = { route -> isWaiting = true viewModel.setResponseStateLoading(route) @@ -288,12 +298,21 @@ private fun NavButton(@StringRes title: Int, enabled: Boolean, onClick: () -> Un @Composable private fun RadioConfigItemList( state: RadioConfigState, + excludedModulesUnlocked: Boolean = false, modifier: Modifier = Modifier, onRouteClick: (Enum<*>) -> Unit = {}, onImport: () -> Unit = {}, onExport: () -> Unit = {}, ) { val enabled = state.connected && !state.responseState.isWaiting() + var modules by remember { mutableStateOf(ModuleRoute.filterExcludedFrom(state.metadata)) } + LaunchedEffect(excludedModulesUnlocked) { + if (excludedModulesUnlocked) { + modules = ModuleRoute.entries + } else { + modules = ModuleRoute.filterExcludedFrom(state.metadata) + } + } LazyColumn( modifier = modifier, contentPadding = PaddingValues(horizontal = 16.dp), @@ -308,7 +327,7 @@ private fun RadioConfigItemList( } item { PreferenceCategory(stringResource(R.string.module_settings)) } - items(ModuleRoute.filterExcludedFrom(state.metadata)) { + items(modules) { NavCard( title = stringResource(it.title), icon = it.icon, @@ -338,6 +357,40 @@ private fun RadioConfigItemList( } } +private const val UNLOCK_CLICK_COUNT = 5 // Number of clicks required to unlock excluded modules. +private const val UNLOCK_TIMEOUT_SECONDS = 3 // Timeout in seconds to reset the click counter. + +@Composable +fun RadioConfigMenuActions( + modifier: Modifier = Modifier, + viewModel: UIViewModel = hiltViewModel(), +) { + val context = LocalContext.current + var counter by remember { mutableIntStateOf(0) } + LaunchedEffect(counter) { + if (counter > 0 && counter < UNLOCK_CLICK_COUNT) { + delay(UNLOCK_TIMEOUT_SECONDS.seconds) + counter = 0 + } + } + IconButton( + enabled = counter < UNLOCK_CLICK_COUNT, + onClick = { + counter++ + if (counter == UNLOCK_CLICK_COUNT) { + viewModel.unlockExcludedModules() + Toast.makeText( + context, + context.getString(R.string.modules_unlocked), + Toast.LENGTH_LONG + ).show() + } + }, + modifier = modifier, + ) { + } +} + @Preview(showBackground = true) @Composable private fun RadioSettingsScreenPreview() = AppTheme { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 06cbf1159..fd852c283 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -692,4 +692,5 @@ Are you sure you want to regenerate your Private Key?\n\nNodes that may have previously exchanged keys with this node will need to Remove that node and re-exchange keys in order to resume secure communication. Export Keys Exports public and private keys to a file. Please store somewhere securely. + Modules unlocked