diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index d37b77af32..c373d96880 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -599,6 +599,8 @@ lockdown_passphrase lockdown_passphrases_do_not_match lockdown_session_boots_remaining lockdown_session_expires +lockdown_session_minutes +lockdown_session_minutes_help lockdown_session_no_time_limit lockdown_set_passphrase lockdown_show_passphrase diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt index 6075c536ae..6f28dc3ed4 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -72,7 +72,9 @@ fun MainScreen() { val lockdownState by viewModel.lockdownState.collectAsStateWithLifecycle() LockdownDialog( lockdownState = lockdownState, - onSubmit = { passphrase, boots, hours -> viewModel.sendLockdownUnlock(passphrase, boots, hours) }, + onSubmit = { passphrase, boots, hours, sessionMinutes -> + viewModel.sendLockdownUnlock(passphrase, boots, hours, sessionMinutes * SECONDS_PER_MINUTE) + }, onDisconnect = { viewModel.setDeviceAddress("n") }, ) // Auto-disconnect when firmware acknowledges Lock Now @@ -145,3 +147,5 @@ private fun AndroidAppVersionCheck(viewModel: UIViewModel) { } } } + +private const val SECONDS_PER_MINUTE = 60 diff --git a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl b/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl index c4b099b813..ae07820378 100644 --- a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl +++ b/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl @@ -206,7 +206,7 @@ interface IMeshService { void requestRebootOta(in int requestId, in int destNum, in int mode, in byte []hash); /// Send a lockdown passphrase to authenticate with a TAK-locked device - void sendLockdownUnlock(in String passphrase, in int bootTtl, in int hourTtl); + void sendLockdownUnlock(in String passphrase, in int bootTtl, in int hourTtl, in int maxSessionSeconds); /// Send a Lock Now command to the connected TAK-enabled device void sendLockNow(); diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt index f35b41caac..d8578f61de 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt @@ -375,7 +375,7 @@ class CommandSenderImpl( } } - override fun sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int) { + override fun sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) { val validUntilEpoch = if (hours > 0) { (nowMillis / MILLIS_PER_SECOND + hours.toLong() * SECONDS_PER_HOUR).toInt() @@ -387,6 +387,7 @@ class CommandSenderImpl( passphrase = passphrase.encodeToByteArray().toByteString(), boots_remaining = boots.coerceAtLeast(0), valid_until_epoch = validUntilEpoch, + max_session_seconds = maxSessionSeconds.coerceAtLeast(0), ) sendLockdownAdmin(AdminMessage(lockdown_auth = lockdownAuth)) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt index 179d14496d..4ff43721a1 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt @@ -56,6 +56,8 @@ class LockdownCoordinatorImpl( @Volatile private var pendingHours: Int = 0 + @Volatile private var pendingMaxSessionSeconds: Int = 0 + override fun onConnect() { serviceRepository.setSessionAuthorized(false) resetTransientState() @@ -108,7 +110,12 @@ class LockdownCoordinatorImpl( if (stored != null) { Logger.i { "Lockdown: Auto-unlocking with stored passphrase" } wasAutoAttempt = true - commandSender.sendLockdownPassphrase(stored.passphrase, stored.boots, stored.hours) + commandSender.sendLockdownPassphrase( + stored.passphrase, + stored.boots, + stored.hours, + stored.maxSessionSeconds, + ) return } } @@ -126,7 +133,13 @@ class LockdownCoordinatorImpl( // Only save on manual submit — auto-unlock already has a stored passphrase. if (deviceAddress != null && passphrase != null) { try { - passphraseStore.savePassphrase(deviceAddress, passphrase, pendingBoots, pendingHours) + passphraseStore.savePassphrase( + deviceAddress, + passphrase, + pendingBoots, + pendingHours, + pendingMaxSessionSeconds, + ) Logger.i { "Lockdown: Saved passphrase for device" } } catch (e: Exception) { Logger.e(e) { "Lockdown: Failed to persist passphrase (session still unlocked)" } @@ -174,14 +187,15 @@ class LockdownCoordinatorImpl( } } - override fun submitPassphrase(passphrase: String, boots: Int, hours: Int) { + override fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) { pendingPassphrase = passphrase pendingBoots = boots pendingHours = hours + pendingMaxSessionSeconds = maxSessionSeconds wasAutoAttempt = false wasLockNow = false serviceRepository.setLockdownState(LockdownState.None) - commandSender.sendLockdownPassphrase(passphrase, boots, hours) + commandSender.sendLockdownPassphrase(passphrase, boots, hours, maxSessionSeconds) } override fun lockNow() { @@ -195,5 +209,6 @@ class LockdownCoordinatorImpl( pendingPassphrase = null pendingBoots = LockdownPassphraseStore.DEFAULT_BOOTS pendingHours = 0 + pendingMaxSessionSeconds = 0 } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt index b9026dac71..74b7d3b6d0 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt @@ -404,8 +404,8 @@ class MeshActionHandlerImpl( } } - override fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) { - lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl) + override fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) { + lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl, maxSessionSeconds) } override fun handleSendLockNow() { diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt index e2c207ccbe..284dbe3bb5 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt @@ -341,7 +341,7 @@ interface RadioController { fun setDeviceAddress(address: String) /** Submits a lockdown passphrase to authenticate with a TAK-locked device. */ - suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) + suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int = 0) /** Sends a Lock Now command to the connected TAK-enabled device. */ suspend fun sendLockNow() diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt index 3c01bbd377..a3d1128ac6 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt @@ -85,7 +85,12 @@ interface CommandSender { fun requestNeighborInfo(requestId: Int, destNum: Int) /** Sends a lockdown passphrase to authenticate with a TAK-locked device. */ - fun sendLockdownPassphrase(passphrase: String, boots: Int = 0, hours: Int = 0) + fun sendLockdownPassphrase( + passphrase: String, + boots: Int = 0, + hours: Int = 0, + maxSessionSeconds: Int = 0, + ) /** Sends a Lock Now command to immediately lock a TAK-enabled device. */ fun sendLockNow() diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt index 67f5f4974d..18e17ccf1b 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt @@ -43,7 +43,7 @@ interface LockdownCoordinator { fun handleLockdownStatus(status: LockdownStatus) /** Submits a passphrase to authenticate with the locked device. */ - fun submitPassphrase(passphrase: String, boots: Int, hours: Int) + fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int = 0) /** Sends a Lock Now command to the connected device. */ fun lockNow() diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt index a544d05715..b024bd24f1 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownPassphraseStore.kt @@ -16,8 +16,19 @@ */ package org.meshtastic.core.repository -/** Stored passphrase entry with associated TTL parameters. */ -data class StoredPassphrase(val passphrase: String, val boots: Int, val hours: Int) { +/** + * Stored passphrase entry with associated TTL parameters. + * + * @param maxSessionSeconds Per-boot uptime cap, in seconds. 0 = unlimited. + * Non-zero is firmware-side enforcement: the device revokes auth and reboots + * after this many seconds of uptime even if the boot-count TTL is still valid. + */ +data class StoredPassphrase( + val passphrase: String, + val boots: Int, + val hours: Int, + val maxSessionSeconds: Int = 0, +) { init { require(passphrase.isNotEmpty()) { "passphrase must not be empty" } } @@ -35,7 +46,13 @@ interface LockdownPassphraseStore { fun getPassphrase(deviceAddress: String): StoredPassphrase? /** Saves the passphrase and TTL parameters for the given device address. */ - fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) + fun savePassphrase( + deviceAddress: String, + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int = 0, + ) /** Clears the stored passphrase for the given device address. */ fun clearPassphrase(deviceAddress: String) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt index 4c6b58af88..19fd1f3577 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt @@ -118,7 +118,7 @@ interface MeshActionHandler { fun handleUpdateLastAddress(deviceAddr: String?) /** Submits a lockdown passphrase to authenticate with a TAK-locked device. */ - fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) + fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int = 0) /** Sends a Lock Now command to the connected TAK-enabled device. */ fun handleSendLockNow() diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 20b92cbd37..c9df41bdfa 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -623,6 +623,8 @@ Passphrases do not match Session: %1$d reboots remaining Expires %1$s + Session cap (minutes) + Per-boot uptime cap. 0 = unlimited. No time limit Set Passphrase Show diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt index 041a884449..a811bd0e2a 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt @@ -221,8 +221,8 @@ class AndroidRadioControllerImpl( context.startForegroundService(intent) } - override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) { - serviceRepository.meshService?.sendLockdownUnlock(passphrase, bootTtl, hourTtl) + override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) { + serviceRepository.meshService?.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) } override suspend fun sendLockNow() { diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt index 731e53aba9..0c908ae2cd 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt @@ -61,23 +61,36 @@ class LockdownPassphraseStoreImpl(app: Application) : LockdownPassphraseStore { val passphrase = p.getString("${key}_passphrase", null) ?: return null val boots = p.getInt("${key}_boots", LockdownPassphraseStore.DEFAULT_BOOTS) val hours = p.getInt("${key}_hours", 0) - return StoredPassphrase(passphrase, boots, hours) + val maxSessionSeconds = p.getInt("${key}_maxSessionSeconds", 0) + return StoredPassphrase(passphrase, boots, hours, maxSessionSeconds) } - override fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) { + override fun savePassphrase( + deviceAddress: String, + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int, + ) { val p = requirePrefs() val key = sanitizeKey(deviceAddress) p.edit() .putString("${key}_passphrase", passphrase) .putInt("${key}_boots", boots) .putInt("${key}_hours", hours) + .putInt("${key}_maxSessionSeconds", maxSessionSeconds) .apply() } override fun clearPassphrase(deviceAddress: String) { val p = requirePrefs() val key = sanitizeKey(deviceAddress) - p.edit().remove("${key}_passphrase").remove("${key}_boots").remove("${key}_hours").apply() + p.edit() + .remove("${key}_passphrase") + .remove("${key}_boots") + .remove("${key}_hours") + .remove("${key}_maxSessionSeconds") + .apply() } private fun sanitizeKey(address: String): String = address.replace(":", "_") diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index 19e80684e7..a8c80c35b8 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt @@ -402,8 +402,18 @@ class MeshService : Service() { router.actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash) } - override fun sendLockdownUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int) = toRemoteExceptions { - router.actionHandler.handleSendLockdownUnlock(passphrase.orEmpty(), bootTtl, hourTtl) + override fun sendLockdownUnlock( + passphrase: String?, + bootTtl: Int, + hourTtl: Int, + maxSessionSeconds: Int, + ) = toRemoteExceptions { + router.actionHandler.handleSendLockdownUnlock( + passphrase.orEmpty(), + bootTtl, + hourTtl, + maxSessionSeconds, + ) } override fun sendLockNow() = toRemoteExceptions { router.actionHandler.handleSendLockNow() } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt index e88b451051..0f0e54ee2a 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt @@ -126,7 +126,7 @@ open class FakeIMeshService : IMeshService.Stub() { override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {} - override fun sendLockdownUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int) {} + override fun sendLockdownUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) {} override fun sendLockNow() {} } diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt index 86b7a0398d..318ee7981e 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt @@ -235,8 +235,8 @@ class DirectRadioControllerImpl( radioInterfaceService.setDeviceAddress(address) } - override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) { - actionHandler.handleSendLockdownUnlock(passphrase, bootTtl, hourTtl) + override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) { + actionHandler.handleSendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) } override suspend fun sendLockNow() { diff --git a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt index 8bbf4eab9a..868f72a75c 100644 --- a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt +++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt @@ -71,9 +71,15 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore { } } - override fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) { + override fun savePassphrase( + deviceAddress: String, + passphrase: String, + boots: Int, + hours: Int, + maxSessionSeconds: Int, + ) { val key = masterKey ?: error("Lockdown: Cannot save passphrase - keystore unavailable") - val plaintext = serialize(passphrase, boots, hours) + val plaintext = serialize(passphrase, boots, hours, maxSessionSeconds) val encrypted = encrypt(key, plaintext) entryFile(deviceAddress).writeBytes(encrypted) } @@ -114,24 +120,42 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore { // region Serialization (simple line-based to avoid adding kotlinx-serialization dependency) - private fun serialize(passphrase: String, boots: Int, hours: Int): ByteArray = - "$boots\n$hours\n$passphrase".encodeToByteArray() + // Format v2: "boots\nhours\nmaxSessionSeconds\npassphrase" (4 lines). + // Backward-compat: legacy 3-line entries (no maxSessionSeconds) decode with maxSessionSeconds=0. + private fun serialize(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int): ByteArray = + "$boots\n$hours\n$maxSessionSeconds\n$passphrase".encodeToByteArray() @Suppress("ReturnCount") private fun deserialize(plaintext: ByteArray): StoredPassphrase? { val text = plaintext.decodeToString() - val lines = text.split("\n", limit = 3) - if (lines.size < SERIALIZED_LINE_COUNT) { + // Try v2 (4-line) format first. + val v2 = text.split("\n", limit = 4) + if (v2.size == SERIALIZED_LINE_COUNT_V2) { + val boots = v2[0].toIntOrNull() + val hours = v2[1].toIntOrNull() + val maxSession = v2[2].toIntOrNull() + if (boots != null && hours != null && maxSession != null) { + return StoredPassphrase( + passphrase = v2[3], + boots = boots, + hours = hours, + maxSessionSeconds = maxSession, + ) + } + } + // Fall back to v1 (3-line, no maxSessionSeconds). + val v1 = text.split("\n", limit = 3) + if (v1.size < SERIALIZED_LINE_COUNT_V1) { Logger.w { "Lockdown: Invalid passphrase entry format" } return null } - val boots = lines[0].toIntOrNull() - val hours = lines[1].toIntOrNull() + val boots = v1[0].toIntOrNull() + val hours = v1[1].toIntOrNull() if (boots == null || hours == null) { Logger.w { "Lockdown: Invalid passphrase entry metadata" } return null } - return StoredPassphrase(passphrase = lines[2], boots = boots, hours = hours) + return StoredPassphrase(passphrase = v1[2], boots = boots, hours = hours) } // endregion @@ -172,6 +196,7 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore { private const val AES_KEY_BITS = 256 private const val GCM_TAG_BITS = 128 private const val BYTE_MASK = 0xFF - private const val SERIALIZED_LINE_COUNT = 3 + private const val SERIALIZED_LINE_COUNT_V1 = 3 + private const val SERIALIZED_LINE_COUNT_V2 = 4 } } diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt index 40a318c359..9091242d3b 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt @@ -27,6 +27,7 @@ class FakeLockdownCoordinator : LockdownCoordinator { var lastPassphrase: String? = null var lastBoots: Int? = null var lastHours: Int? = null + var lastMaxSessionSeconds: Int? = null var lockNowCalled = false override fun onConnect() { @@ -45,10 +46,11 @@ class FakeLockdownCoordinator : LockdownCoordinator { lastStatus = status } - override fun submitPassphrase(passphrase: String, boots: Int, hours: Int) { + override fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) { lastPassphrase = passphrase lastBoots = boots lastHours = hours + lastMaxSessionSeconds = maxSessionSeconds } override fun lockNow() { diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index 036e9148a6..859fe07c17 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt @@ -162,7 +162,7 @@ class FakeRadioController : lastSetDeviceAddress = address } - override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) {} + override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) {} override suspend fun sendLockNow() {} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index 7aed6d7ad9..84dee5df94 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -141,8 +141,13 @@ class UIViewModel( val lockdownState = serviceRepository.lockdownState val lockdownTokenInfo = serviceRepository.lockdownTokenInfo - fun sendLockdownUnlock(passphrase: String, bootTtl: Int = DEFAULT_BOOT_TTL, hourTtl: Int = 0) { - viewModelScope.launch { radioController.sendLockdownUnlock(passphrase, bootTtl, hourTtl) } + fun sendLockdownUnlock( + passphrase: String, + bootTtl: Int = DEFAULT_BOOT_TTL, + hourTtl: Int = 0, + maxSessionSeconds: Int = 0, + ) { + viewModelScope.launch { radioController.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) } } fun sendLockNow() { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt index 1ec56fb7ce..6a83edf108 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt @@ -57,6 +57,8 @@ import org.meshtastic.core.resources.lockdown_incorrect_passphrase import org.meshtastic.core.resources.lockdown_lock_reason import org.meshtastic.core.resources.lockdown_passphrase import org.meshtastic.core.resources.lockdown_passphrases_do_not_match +import org.meshtastic.core.resources.lockdown_session_minutes +import org.meshtastic.core.resources.lockdown_session_minutes_help import org.meshtastic.core.resources.lockdown_set_passphrase import org.meshtastic.core.resources.lockdown_show_passphrase import org.meshtastic.core.resources.lockdown_submit @@ -75,7 +77,7 @@ import org.meshtastic.core.ui.icon.VisibilityOff @Composable fun LockdownDialog( lockdownState: LockdownState, - onSubmit: (passphrase: String, boots: Int, hours: Int) -> Unit, + onSubmit: (passphrase: String, boots: Int, hours: Int, sessionMinutes: Int) -> Unit, onDisconnect: () -> Unit, ) { val shouldShow = @@ -93,6 +95,7 @@ fun LockdownDialog( var passwordVisible by rememberSaveable { mutableStateOf(false) } var boots by rememberSaveable { mutableIntStateOf(LockdownPassphraseStore.DEFAULT_BOOTS) } var hours by rememberSaveable { mutableIntStateOf(0) } + var sessionMinutes by rememberSaveable { mutableIntStateOf(0) } val isProvisioning = lockdownState is LockdownState.NeedsProvision val title = @@ -208,10 +211,20 @@ fun LockdownDialog( modifier = Modifier.weight(1f), ) } + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + OutlinedTextField( + value = sessionMinutes.toString(), + onValueChange = { str -> str.toIntOrNull()?.let { sessionMinutes = it.coerceAtLeast(0) } }, + label = { Text(stringResource(Res.string.lockdown_session_minutes)) }, + supportingText = { Text(stringResource(Res.string.lockdown_session_minutes_help)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) } }, confirmButton = { - TextButton(onClick = { onSubmit(passphrase, boots, hours) }, enabled = isValid) { + TextButton(onClick = { onSubmit(passphrase, boots, hours, sessionMinutes) }, enabled = isValid) { Text(stringResource(Res.string.lockdown_submit)) } },