feat: Allow unlocking excluded modules (#2180)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2025-06-20 23:19:51 +00:00
committed by GitHub
parent 8639228721
commit bcc3a0f107
4 changed files with 100 additions and 15 deletions

View File

@@ -206,7 +206,18 @@ class UIViewModel @Inject constructor(
private val _lastTraceRouteTime = MutableStateFlow<Long?>(null)
val lastTraceRouteTime: StateFlow<Long?> = _lastTraceRouteTime.asStateFlow()
val clientNotification: StateFlow<MeshProtos.ClientNotification?> = radioConfigRepository.clientNotification
private val _excludedModulesUnlocked = MutableStateFlow(false)
val excludedModulesUnlocked: StateFlow<Boolean> = _excludedModulesUnlocked.asStateFlow()
fun unlockExcludedModules() {
viewModelScope.launch {
_excludedModulesUnlocked.value = true
}
}
val clientNotification: StateFlow<MeshProtos.ClientNotification?> =
radioConfigRepository.clientNotification
fun clearClientNotification(notification: MeshProtos.ClientNotification) {
radioConfigRepository.clearClientNotification()
meshServiceNotifications.clearClientNotification(notification)

View File

@@ -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<ContactsRoutes.Messages>() == 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<Route.DebugPanel>() ->
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<Route.DebugPanel>() ->
DebugMenuActions()
currentDestination.hasRoute<RadioConfigRoutes.RadioConfig>() ->
RadioConfigMenuActions(viewModel = viewModel)
else -> {}
}
}
@Composable
private fun MainMenuActions(
isManaged: Boolean,

View File

@@ -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 {

View File

@@ -692,4 +692,5 @@
<string name="regenerate_keys_confirmation">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.</string>
<string name="export_keys">Export Keys</string>
<string name="export_keys_confirmation">Exports public and private keys to a file. Please store somewhere securely.</string>
<string name="modules_unlocked">Modules unlocked</string>
</resources>