Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
913c3c2
feat(lockdown): add LockdownState model and coordinator interfaces
niccellular May 13, 2026
f6e97d7
feat(lockdown): implement coordinator and typed status dispatch
niccellular May 13, 2026
0c8e530
feat(lockdown): wire AIDL, RadioController, and ViewModels
niccellular May 13, 2026
dae4369
feat(lockdown): add unlock dialog, Lock Now button, region gating
niccellular May 13, 2026
c9c416e
Merge branch 'features/lockdown-v2' of https://github.com/meshtastic/…
jamesarich May 13, 2026
d25136f
fix: resolve compile errors from PR merge
jamesarich May 13, 2026
d3ae497
refactor(lockdown): extract interfaces and move coordinator to common…
jamesarich May 13, 2026
ed7c8aa
feat(lockdown): add non-dismissable LockdownDialog and app shell inte…
jamesarich May 13, 2026
585666b
feat(lockdown): Lock Now auto-disconnect, session status, provision c…
jamesarich May 13, 2026
7beb639
feat: implement lockdown mode authentication
jamesarich May 13, 2026
e1e678e
feat(lockdown): implement encrypted JVM passphrase store
jamesarich May 13, 2026
3b518ba
docs: update lockdown spec docs to match implementation
jamesarich May 13, 2026
2a1734d
fix: finish lockdown review follow-ups
jamesarich May 13, 2026
2b1ccd6
fix: break Koin circular dependency with Lazy<MeshConnectionManager>
jamesarich May 13, 2026
d3324b1
fix: use positional format specifiers and show TTL fields in unlock mode
jamesarich May 13, 2026
1d24b38
fix: sync spec docs and add edge-case coordinator tests
jamesarich May 13, 2026
431f0d7
fix: resolve CI lint failures (spotless + detekt)
jamesarich May 13, 2026
b5b56a3
chore(proto): bump submodule to develop (1c62540 -> 7ffb4bb)
niccellular May 18, 2026
6ce565f
feat(lockdown): thread max_session_seconds through coordinator and UI
niccellular May 18, 2026
3b02df3
Merge remote-tracking branch 'origin/main' into features/lockdown-v2
niccellular May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .skills/compose-ui/strings-index.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
{"feature_directory":"specs/20260513-160000-m3-expressive-adoption"}
{
"feature_directory": "specs/20260513-075218-lockdown-mode"
}
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,5 @@ You are an expert Android/KMP engineer. Maintain architectural boundaries, use M
<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan
at `specs/20260513-160000-m3-expressive-adoption/plan.md`
at `specs/20260513-075218-lockdown-mode/plan.md`
<!-- SPECKIT END -->
19 changes: 19 additions & 0 deletions androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import co.touchlab.kermit.Logger
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.BuildConfig
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.service.LockdownState
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.rememberMultiBackstack
Expand All @@ -48,6 +49,7 @@ import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
import org.meshtastic.feature.messaging.navigation.contactsGraph
import org.meshtastic.feature.node.navigation.nodesGraph
import org.meshtastic.feature.settings.lockdown.LockdownDialog
import org.meshtastic.feature.settings.navigation.settingsGraph
import org.meshtastic.feature.settings.radio.channel.channelsGraph
import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph
Expand All @@ -68,6 +70,21 @@ fun MainScreen() {

AndroidAppVersionCheck(viewModel)

val lockdownState by viewModel.lockdownState.collectAsStateWithLifecycle()
LockdownDialog(
lockdownState = lockdownState,
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
LaunchedEffect(lockdownState) {
if (lockdownState is LockdownState.LockNowAcknowledged) {
viewModel.setDeviceAddress("n")
}
}

MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = viewModel, hostModifier = Modifier) {
MeshtasticNavigationSuite(
multiBackstack = multiBackstack,
Expand Down Expand Up @@ -132,3 +149,5 @@ private fun AndroidAppVersionCheck(viewModel: UIViewModel) {
}
}
}

private const val SECONDS_PER_MINUTE = 60
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,10 @@ interface IMeshService {
* hash is the 32-byte firmware SHA256 hash (optional, can be null)
*/
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, in int maxSessionSeconds);

/// Send a Lock Now command to the connected TAK-enabled device
void sendLockNow();
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,15 @@ import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HostMetrics
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.LockdownAuth
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Neighbor
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.PowerMetrics
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
import kotlin.math.absoluteValue
import kotlin.random.Random
import kotlin.time.Duration.Companion.hours
Expand Down Expand Up @@ -373,6 +375,43 @@ class CommandSenderImpl(
}
}

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()
} else {
0
}
val lockdownAuth =
LockdownAuth(
passphrase = passphrase.encodeToByteArray().toByteString(),
boots_remaining = boots.coerceAtLeast(0),
valid_until_epoch = validUntilEpoch,
max_session_seconds = maxSessionSeconds.coerceAtLeast(0),
)
sendLockdownAdmin(AdminMessage(lockdown_auth = lockdownAuth))
}

override fun sendLockNow() {
sendLockdownAdmin(AdminMessage(lockdown_auth = LockdownAuth(lock_now = true)))
}

private fun sendLockdownAdmin(adminMessage: AdminMessage) {
val myNum = nodeManager.myNodeNum.value ?: return
val packet =
MeshPacket(
to = myNum,
id = generatePacketId(),
channel = 0,
want_ack = true,
hop_limit = DEFAULT_HOP_LIMIT,
hop_start = DEFAULT_HOP_LIMIT,
priority = MeshPacket.Priority.RELIABLE,
decoded = Data(portnum = PortNum.ADMIN_APP, payload = adminMessage.encode().toByteString()),
)
packetHandler.sendToRadio(ToRadio(packet = packet))
}

fun resolveNodeNum(toId: String): Int = when (toId) {
DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST

Expand Down Expand Up @@ -462,5 +501,8 @@ class CommandSenderImpl(
private const val HEX_RADIX = 16

private const val DEFAULT_HOP_LIMIT = 3

private const val MILLIS_PER_SECOND = 1000L
private const val SECONDS_PER_HOUR = 3600
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.LockdownCoordinator
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.Notification
Expand All @@ -48,6 +49,7 @@ class FromRadioPacketHandlerImpl(
private val mqttManager: MqttManager,
private val packetHandler: PacketHandler,
private val notificationManager: NotificationManager,
private val lockdownCoordinator: LockdownCoordinator,
) : FromRadioPacketHandler {

// Application-scoped coroutine context for suspend work (e.g. getStringSuspend).
Expand All @@ -69,6 +71,7 @@ class FromRadioPacketHandlerImpl(
val deviceUIConfig = proto.deviceuiConfig
val fileInfo = proto.fileInfo
val xmodemPacket = proto.xmodemPacket
val lockdownStatus = proto.lockdown_status

when {
myInfo != null -> router.value.configFlowManager.handleMyInfo(myInfo)
Expand All @@ -84,7 +87,10 @@ class FromRadioPacketHandlerImpl(
serviceRepository.setConnectionProgress("Nodes (${router.value.configFlowManager.newNodeCount})")
}

configCompleteId != null -> router.value.configFlowManager.handleConfigComplete(configCompleteId)
configCompleteId != null -> {
router.value.configFlowManager.handleConfigComplete(configCompleteId)
lockdownCoordinator.onConfigComplete()
}

mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage)

Expand All @@ -100,6 +106,8 @@ class FromRadioPacketHandlerImpl(

xmodemPacket != null -> router.value.xmodemManager.handleIncomingXModem(xmodemPacket)

lockdownStatus != null -> lockdownCoordinator.handleLockdownStatus(lockdownStatus)

clientNotification != null -> handleClientNotification(clientNotification)

// Firmware rebooted without a transport-level disconnect (common on serial/TCP).
Expand Down
Loading
Loading