Skip to content

feat: firmware lockdown mode (provision / unlock / lock-now)#5439

Open
niccellular wants to merge 20 commits into
mainfrom
features/lockdown-v2
Open

feat: firmware lockdown mode (provision / unlock / lock-now)#5439
niccellular wants to merge 20 commits into
mainfrom
features/lockdown-v2

Conversation

@niccellular
Copy link
Copy Markdown
Member

@niccellular niccellular commented May 13, 2026

Summary

Adds end-to-end UI and service support for the firmware's hardened LOCKDOWN build (see meshtastic/firmware#10349). On a locked device the client prompts for a passphrase, optionally auto-replays a stored one, surfaces backoff/lock-reason from firmware, and offers a Lock Now control.

Built against the typed schema in meshtastic/protobufs#911AdminMessage.lockdown_auth = LockdownAuth(...) for provision / unlock / lock-now, and FromRadio.lockdown_status = LockdownStatus(...) for state reports.

Dependency on protobufs#911

This PR will not compile in CI until the proto submodule is bumped to a revision containing LockdownAuth (admin.proto tag 104) and LockdownStatus (mesh.proto tag 18). Opening as draft pending that bump. Local development against 1c62540 of protobufs works.

The proto bump also introduces four new ModemPreset enum entries (LITE_FAST / LITE_SLOW / NARROW_FAST / NARROW_SLOW) that surface as when exhaustiveness errors in Channel.kt, ChannelOption.kt, and ModelExtensions.kt. Those fixes are intentionally not in this PR — they belong with the proto bump.

Architecture

  • LockdownCoordinator (commonMain interface + LockdownCoordinatorImpl in core/data) is the single owner of lockdown lifecycle. Processes LockdownStatus messages from firmware, drives LockdownState exposed to UI via ServiceRepository, and manages auto-replay of cached passphrases.
  • LockdownPassphraseStore (commonMain interface) with platform implementations:
    • Android (LockdownPassphraseStoreImpl in core/service/androidMain): EncryptedSharedPreferences backed by AES-256-GCM MasterKey. Not biometric-gated — auto-replay runs without user interaction. Init failures are logged; subsequent operations fail fast for explicit error handling by callers.
    • JVM/Desktop (LockdownPassphraseStoreImpl in core/service/jvmMain): PKCS12 KeyStore + AES-256-GCM file-backed store at $MESHTASTIC_DATA_DIR/lockdown/ (default ~/.meshtastic/lockdown/). Generates a random AES-256 master key on first use, stores per-device passphrase entries as individual .enc files. Read failures return null; write failures throw so the coordinator can log and keep the session unlocked.
  • LockdownState sealed class: None, NeedsProvision, Locked(lockReason), Unlocked, UnlockFailed, UnlockBackoff(backoffSeconds), LockNowAcknowledged. Session TTL metadata is separate as LockdownTokenInfo(bootsRemaining, expiryEpoch).
  • UI flows through LockdownDialog (provision/unlock/backoff) and LockdownSessionStatus (token info display), both in feature/settings/lockdown/ using stringResource(Res.string.lockdown_*).
  • Lock Now wired via SecurityConfigScreenRadioConfigViewModelLockdownCoordinator.lockNow()CommandSender.sendLockNow().
  • Region-unset banner on Connections screen gated on sessionAuthorized so unauthorized clients aren't prompted to fix config they can't access.

Lock Now ACK handling

The typed schema has only one LOCKED variant carrying lock_reason. To preserve the UX where Lock Now drops the connection without flashing the passphrase dialog, the coordinator tracks a client-side wasLockNow flag set when the user issues a lock-now command; the next LOCKED status routes to LockdownState.LockNowAcknowledged, clears the flag, resets session authorization, and clears cached radio config. This is local state, not on the wire.

What changed (latest commits)

The original 4 commits established the core lockdown plumbing. The subsequent commits refactored and hardened the implementation:

Refactoring

  • Extracted LockdownCoordinator and LockdownPassphraseStore interfaces into core/repository (commonMain)
  • Moved coordinator implementation from androidMain to core/data commonMain (LockdownCoordinatorImpl)
  • Deleted unused LockdownUnlockDialog.kt (replaced by LockdownDialog in feature/settings)
  • Extracted resetTransientState() helper to dedup field resets across onConnect/onDisconnect/handleLockNowAcknowledged

Error resilience

  • LockdownPassphraseStoreImpl (Android): prefs is now SharedPreferences? with try/catch on EncryptedSharedPreferences init — subsequent operations fail fast via requirePrefs()
  • LockdownCoordinatorImpl: passphraseStore.getPassphrase(), savePassphrase(), and clearPassphrase() wrapped in try/catch — store failures don't crash sessions
  • Save failure during unlock still authorizes session (passphrase worked, just wasn't persisted)
  • Fixed log ordering bug: success log for clearPassphrase moved inside try block

UI polish

  • All hardcoded strings replaced with stringResource(Res.string.lockdown_*)
  • Passphrase validation uses encodeToByteArray().size (UTF-8 byte length) instead of character count to match proto wire limit
  • LockdownDialog references LockdownPassphraseStore.DEFAULT_BOOTS instead of duplicated local constant
  • LockdownSessionStatus formats token expiry with DateFormatter.formatDateTime()

Code quality

  • Replaced UINT32_MASK constant with .toUInt().toLong() idiom for proto uint32 fields
  • Added threading doc comment on LockdownCoordinatorImpl
  • Implemented encrypted JVM passphrase store (PKCS12 KeyStore + AES-256-GCM)
  • @Volatile fields for cross-thread visibility with documented single-threaded assumption
  • KDoc cleaned: coordinator is now "firmware-locked devices" (not TAK-specific), onConfigComplete() documented as a lifecycle hook retained for future use
  • All detekt/spotless clean

Tests & review follow-ups

  • LockdownCoordinatorImplTest — 17 test cases covering:
    • All state transitions (NEEDS_PROVISION, LOCKED, UNLOCKED, UNLOCK_FAILED, STATE_UNSPECIFIED)
    • Auto-replay success and failure paths
    • Lock Now with wasLockNow flag behavior
    • Backoff enforcement
    • onConnect/onDisconnect reset
    • Store error resilience (getPassphrase/savePassphrase/clearPassphrase throwing)
    • uint32 overflow edge case (0xFFFF_FFFF)
    • Retry after failure: submit replacement passphrase → saves correctly on success
  • FromRadioPacketHandlerImplTest — added lockdown_status routing and config_complete_idonConfigComplete() assertion
  • MeshActionHandlerImplTest — added handleSendLockdownUnlock and handleSendLockNow delegation tests
  • MeshConnectionManagerImplTest — added onConnect() / onDisconnect() hook assertions
  • LockdownPassphraseStoreImplTest (jvmTest) — save/get/clear round-trip with temp user.home override
  • Updated FakeLockdownCoordinator to track boots and hours parameters
  • All tests pass: ./gradlew spotlessApply detekt assembleDebug test allTests

Spec docs

  • Synchronized all lockdown spec artifacts (spec.md, plan.md, tasks.md, data-model.md, quickstart.md, research.md, and contract docs) to match the shipped API, state model, module layout, and UI behavior

Commits

  1. feat(lockdown): add LockdownState model and coordinator interfaces — pure commonMain plumbing.
  2. feat(lockdown): implement coordinator and typed status dispatch — coordinator, passphrase store, wire changes in CommandSenderImpl / FromRadioPacketHandlerImpl / MeshConnectionManagerImpl.
  3. feat(lockdown): wire AIDL, RadioController, and ViewModels — IPC + ViewModel exposure.
  4. feat(lockdown): add unlock dialog, Lock Now button, region gating — UI.
  5. fix: resolve compile errors from PR merge — merge cleanup.
  6. refactor(lockdown): extract interfaces and move coordinator to commonMain — KMP architecture.
  7. feat(lockdown): add non-dismissable LockdownDialog and app shell integration — dialog + Main.kt.
  8. feat(lockdown): Lock Now auto-disconnect, session status, provision confirm — polish.
  9. feat: implement lockdown mode authentication — error resilience, review fixes, tests.
  10. feat(lockdown): implement encrypted JVM passphrase store — replace no-op stub with PKCS12 + AES-256-GCM file-backed store.
  11. fix: finish lockdown review follow-ups — integration tests, JVM store round-trip test, edge-case coordinator tests, KDoc accuracy, spec doc sync.
  12. fix: break Koin circular dependency with Lazy<MeshConnectionManager>MeshConnectionManagerImplLockdownCoordinatorImpl cycle caused StackOverflowError; defer coordinator's reference with Lazy<T>.
  13. fix: use positional format specifiers and show TTL fields in unlock mode%s/%d%1$s/%1$d for CMP stringResource; Boot TTL / Hour TTL shown in both provision and unlock dialogs.
  14. fix: sync spec docs and add edge-case coordinator tests — TTL fields in UI contract, Lazy<> in data-model relationships, :core:service module ref; 2 new tests (NEEDS_PROVISION after lockNow, UNLOCKED with null deviceAddress).

Test plan

Requires a device flashed with firmware from meshtastic/firmware#10349 (a LOCKDOWN build).

  • Provision (fresh device). No passphrase set. Connect → expect LockdownStatus(NEEDS_PROVISION) → "Set Passphrase" dialog. Enter passphrase + optional boots/hours. Verify UNLOCKED with non-zero boots_remaining / valid_until_epoch. Settings → Security shows session info and Lock Now.
  • Reboot, auto-unlock. Restart device. On reconnect expect LOCKED then auto-replay; dialog never appears. Second status is UNLOCKED.
  • Wrong passphrase, no backoff. Wipe stored passphrase, reconnect, enter wrong passphrase. Expect UNLOCK_FAILED with backoff_seconds=0 → "Incorrect passphrase" with retry allowed.
  • Wrong passphrase, with backoff. Burn enough attempts to trigger firmware backoff. Expect backoff_seconds>0 → countdown shown, Submit disabled.
  • Stored passphrase rejected. Clear firmware-side credential so stored one is invalid. Reconnect: auto-attempt arrives, firmware returns UNLOCK_FAILED with backoff_seconds=0; client purges stored credential and prompts.
  • Token wall-clock expiry. Provision with hours=1. Wait past expiry, reboot. Expect LOCKED, lock_reason="token_expired".
  • Boots-exhausted. Provision with boots_remaining=2. Reboot twice. Expect LOCKED, lock_reason="token_boots_zero".
  • Lock Now. From Security → "Lock Now" while UNLOCKED. Expect firmware reply then reboot. Client transitions to LockNowAcknowledged (no dialog flash) and the BLE connection drops.
  • Wire capture. Sniff the to-radio bytes for unlock and lock-now. Confirm AdminMessage.lockdown_auth (tag 104) is set and set_config.security.private_key is not.
  • Unit tests. ./gradlew :core:data:allTests :core:service:jvmTest passes all lockdown test cases.

niccellular and others added 4 commits May 13, 2026 09:00
Introduce the TAK passphrase lockdown abstractions:

- LockdownState sealed class + LockdownTokenInfo for UI to observe.
- LockdownCoordinator interface for the authentication lifecycle
  (onConnect/onDisconnect/onConfigComplete/handleLockdownStatus,
  plus submitPassphrase/lockNow).
- Add sendLockdownPassphrase/sendLockNow to CommandSender, RadioController.
- Add handleSendLockdownUnlock/handleSendLockNow to MeshActionHandler.
- Add clearRadioConfig to MeshConnectionManager (used during lock-now).
- Add lockdownState/lockdownTokenInfo/sessionAuthorized flows to
  ServiceRepository.

handleLockdownStatus consumes the typed firmware LockdownStatus message
from FromRadio (protobufs#911) instead of parsing string-prefixed
ClientNotification messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- CommandSenderImpl: build AdminMessage.lockdown_auth = LockdownAuth(...)
  for provision/unlock and lock_now=true for the lock command.
- FromRadioPacketHandlerImpl: route the new FromRadio.lockdown_status
  variant to the coordinator; also notify the coordinator on
  config_complete_id.
- MeshActionHandlerImpl: forward handleSendLockdownUnlock/handleSendLockNow
  to the coordinator.
- MeshConnectionManagerImpl: call coordinator.onConnect/onDisconnect; add
  clearRadioConfig to purge cached config after a lock-now ACK.
- ServiceRepositoryImpl: back the lockdownState/lockdownTokenInfo/
  sessionAuthorized flows.
- LockdownHandlerImpl: orchestration. Switches on LockdownStatus.State
  (NEEDS_PROVISION / LOCKED / UNLOCKED / UNLOCK_FAILED), auto-replays
  stored passphrase on LOCKED, clears stored passphrase on a fresh
  UNLOCK_FAILED, surfaces backoff_seconds on rate-limit. Tracks a
  wasLockNow flag locally so the next LOCKED status after a lock-now
  command is translated to LockdownState.LockNowAcknowledged for an
  immediate UI disconnect (the new schema has no explicit ACK type).
- LockdownPassphraseStore: per-device EncryptedSharedPreferences store
  for auto-unlock. Not biometric-gated by design.
- Add androidx.security:security-crypto dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- IMeshService: sendLockdownUnlock(passphrase, bootTtl, hourTtl) and
  sendLockNow() AIDL methods.
- MeshService: AIDL stubs forwarding to MeshActionHandler.
- AndroidRadioControllerImpl: forward to meshService over AIDL.
- DirectRadioControllerImpl: forward directly to actionHandler (in-process
  non-Android targets).
- FakeIMeshService: test stubs.
- UIViewModel: lockdownState/lockdownTokenInfo flows, sendLockdownUnlock,
  sendLockNow, clearLockdownState. Routed through radioController so the
  commonMain code does not depend on the AIDL service directly.
- ConnectionsViewModel: expose lockdownState.
- RadioConfigViewModel: lockdownTokenInfo + sendLockNow for the Lock Now
  button in security settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- LockdownUnlockDialog: passphrase entry with boots / hours TTL inputs.
  Shows lock_reason on LOCKED, a backoff countdown on UNLOCK_FAILED with
  backoff_seconds > 0 (Submit disabled while in backoff), and switches
  the title to "Set Passphrase" on NEEDS_PROVISION.
- Main: collect lockdownState/lockdownTokenInfo, show the dialog,
  auto-clear on LockNowAcknowledged so the connection drops without
  a dialog flash.
- ConnectionsScreen: gate the "must set region" banner on
  isLockdownAuthorized so an unauthorized client isn't told to fix a
  region it can't see.
- SecurityConfigItemList: "Lock Now" button under Administration,
  labelled with the active session token's boots remaining and (if set)
  the wall-clock expiry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added needs-review enhancement New feature or request labels May 13, 2026
…Meshtastic-Android into feat/lockdown-mode

# Conflicts:
#	app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
#	core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
#	core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
#	core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
#	core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
#	core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt
#	feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
#	feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
- Replace java.text.DateFormat/java.util.Date usage in SecurityConfigScreen
  (constitution violation: no java.* in commonMain) with simplified Lock Now button
- Replace material.icons imports with MeshtasticIcons in LockdownUnlockDialog
- Proper token info display to be re-implemented in Phase 5 (T025-T026)
…Main

Phase 2 foundational refactor:
- T006: Extract LockdownPassphraseStore interface to core/repository
- T007: Make concrete Android impl implement the interface (renamed to Impl)
- T008: Add JVM/Desktop no-op passphrase store stub
- T010: Move coordinator state machine from core/service/androidMain to
  core/data/commonMain as LockdownCoordinatorImpl (pure KMP, no Android deps)
- Remove old LockdownHandlerImpl (superseded)
- Convert KoinComponent lazy inject to constructor injection
…gration

Phase 3 (User Story 1 - Unlock a Locked Node):
- T014: Create LockdownDialog composable in feature/settings/lockdown/
  - Non-dismissable AlertDialog (onDismissRequest = {})
  - Passphrase field with visibility toggle
  - Provision mode with boots/hours TTL fields
  - Error display for UnlockFailed and UnlockBackoff states
  - Disconnect button instead of Cancel
- T017: Integrate dialog in Main.kt app shell
  - Observe lockdownState from UIViewModel
  - Submit triggers sendLockdownUnlock
  - Disconnect triggers setDeviceAddress("n") to drop connection
…onfirm

- T027/T028: Auto-disconnect on LockNowAcknowledged state in app shell
- T020/T021: Confirm passphrase field in provision mode with mismatch validation
- T035/T036: LockdownSessionStatus composable showing boots remaining and expiry
- Wire session status and Lock Now button enabled state based on sessionAuthorized
- Expose lockdownTokenInfo and sessionAuthorized from RadioConfigViewModel
- Add LockdownCoordinator state machine with auto-replay, lock-now,
  and error-resilient passphrase store calls
- Add EncryptedSharedPreferences-backed Android passphrase store
  with nullable fallback on crypto init failure
- Add LockdownDialog (provision/unlock/backoff) with byte-length
  passphrase validation and string resources
- Add LockdownSessionStatus composable for token info display
- Gate region-unset banner on sessionAuthorized in ConnectionsScreen
- Wire Lock Now button in SecurityConfigScreen
- Add LockdownCoordinatorImplTest covering all state transitions,
  auto-replay, lock-now, error paths, and uint32 overflow
- Add FakeLockdownCoordinator and update test fakes
- Delete unused LockdownUnlockDialog.kt
Replace no-op stub with PKCS12 KeyStore + AES-256-GCM file-backed
store at ~/.meshtastic/lockdown/. Passphrases now persist across
Desktop sessions with same error resilience as Android impl.
Sync contracts, plan, and tasks with actual interface signatures,
module paths, and JVM encrypted store implementation.
Address remaining review items, add integration and JVM store tests,
and sync the lockdown spec docs with the implemented API and UI.
@jamesarich jamesarich changed the title feat: TAK passphrase lockdown (provision / unlock / lock-now) feat: firmware lockdown mode (provision / unlock / lock-now) May 13, 2026
@niccellular
Copy link
Copy Markdown
Member Author

On 2a1734d93 the app still crashes on launch with StackOverflowError from Koin — same failure as before the fix commit. The follow-ups commit was tests + docs and didn't touch the cycle in core/data.

The cycle is unresolved:

  • MeshConnectionManagerImpl constructor-injects LockdownCoordinator (used in onConnect/onDisconnect).
  • LockdownCoordinatorImpl constructor-injects MeshConnectionManager (used in handleLockNowAcknowledgedclearRadioConfig and handleUnlockedstartConfigOnly).

Koin resolves each singleton by trying to build the other first, recurses forever.

Crash (head of the stack — repeats indefinitely):

FATAL EXCEPTION: main
Process: com.geeksville.mesh.google.debug
java.lang.StackOverflowError: stack size 8188KB
  at org.koin.core.scope.Scope.get(Scope.kt:234)
  at org.meshtastic.core.data.di.OrgMeshtasticCoreDataDiCoreDataAndroidModuleModuleKt.module$lambda$0$17(...)   // resolves MeshConnectionManager
  at org.koin.core.instance.SingleInstanceFactory.create(...)
  at org.koin.core.scope.Scope.get(Scope.kt:234)
  at org.meshtastic.core.data.di.OrgMeshtasticCoreDataDiCoreDataAndroidModuleModuleKt.module$lambda$0$13(...)   // resolves LockdownCoordinator
  at org.koin.core.instance.SingleInstanceFactory.create(...)
  ... // 17 ↔ 13 repeats ...

Fix options:

  1. Lazy on the coordinator side (smaller change). The coordinator only needs MeshConnectionManager on lock-now-ack and on successful unlock — both rare. Make LockdownCoordinatorImpl a KoinComponent and resolve MeshConnectionManager via by inject() (delays resolution until first use). This was the pattern in the original PR before the constructor-injection refactor in d3ae49781.
  2. Lazy on the connection manager side. Symmetric option; defer lockdownCoordinator since onConnect/onDisconnect only run once per connection.
  3. Decouple via a callback/listenerMeshConnectionManager exposes connect/disconnect events; LockdownCoordinator subscribes during its own init. Cleaner, larger change.

Happy to push (1) if it's useful.

MeshConnectionManagerImpl and LockdownCoordinatorImpl constructor-inject
each other, causing a StackOverflowError at Koin resolution time.

The coordinator only needs MeshConnectionManager in two rare paths
(lock-now-ack and post-unlock config reload), so defer its resolution
with Lazy<T> — matching the existing Lazy<MeshRouter> pattern in
FromRadioPacketHandlerImpl.
@jamesarich
Copy link
Copy Markdown
Collaborator

Good catch — went with option 1. 2b1ccd653 changes LockdownCoordinatorImpl to take Lazy<MeshConnectionManager> (same pattern as FromRadioPacketHandlerImpl already uses for Lazy<MeshRouter>).

The coordinator only touches connectionManager.value in two rare paths (handleLockNowAcknowledgedclearRadioConfig() and handleUnlockedstartConfigOnly()), so lazy is the right side to defer. All tests pass.

Compose Multiplatform stringResource requires positional specifiers
(%1$s, %1$d) — plain %s/%d renders literal format tokens.

Boot TTL and Hour TTL fields are now shown for both provision and
unlock, matching the original implementation. Confirm passphrase
field remains provisioning-only.
Spec docs:
- lockdown-ui.md: TTL fields now shown in unlock mode, not just provision
- data-model.md: note Lazy<MeshConnectionManager> in relationships
- plan.md: correct module :core:datastore -> :core:service

Tests (2 new):
- NEEDS_PROVISION after lockNow does not trigger LockNowAcknowledged
- UNLOCKED with no deviceAddress skips save but still authorizes
- spotlessApply across core:repository, core:service, core:ui
- Android LockdownPassphraseStoreImpl: inline requirePrefs() body
  (FunctionSignature)
- JVM LockdownPassphraseStoreImpl: suppress ReturnCount on deserialize()
@jamesarich jamesarich added this to the 2.8.0 milestone May 14, 2026
niccellular and others added 2 commits May 18, 2026 16:57
Pulls in protobufs PR #916 which adds LockdownAuth.max_session_seconds
(uint32, field 5) — per-boot uptime cap on the unlocked session.
Wire-compatible (proto3 default 0 = unchanged behaviour).

Note: this pointer is on the upstream `develop` branch — `master` has
not absorbed it yet. Will need updating if the maintainer prefers a
master pointer at merge time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end plumbing for LockdownAuth.max_session_seconds (per-boot
uptime cap on the unlocked session; 0 = unlimited).

Wire:
- CommandSenderImpl populates LockdownAuth.max_session_seconds in the
  outbound admin packet (clamped non-negative).

Coordinator + persistence:
- LockdownCoordinator.submitPassphrase gains optional maxSessionSeconds
  (default 0); persisted alongside boots/hours and replayed by
  auto-unlock so cached sessions keep the operator's cap on reconnect.
- StoredPassphrase gains a new field with a default of 0 so existing
  call sites stay source-compatible.
- LockdownPassphraseStore (Android EncryptedSharedPreferences impl):
  reads/writes the new field with a `_maxSessionSeconds` key suffix;
  legacy entries decode to 0.
- LockdownPassphraseStore (JVM file-backed impl): bumps the per-entry
  on-disk serialization from 3-line to 4-line; legacy 3-line entries
  still decode (treated as maxSessionSeconds=0).

IPC + radio plumbing:
- IMeshService.sendLockdownUnlock AIDL gains a 4th int parameter.
- MeshService stub, MeshActionHandler, RadioController interface, and
  both impls (AndroidRadioControllerImpl, DirectRadioControllerImpl)
  thread the field through.
- FakeIMeshService, FakeRadioController, FakeLockdownCoordinator
  updated to match.

UI:
- LockdownDialog adds a single optional "Session cap (minutes)" field
  below the boots/hours row. Operators enter minutes for ergonomics;
  the dialog multiplies by 60 before passing to the coordinator. Blank
  or 0 = unlimited (firmware default).
- UIViewModel.sendLockdownUnlock gains the new param with default 0.
- New string resources: lockdown_session_minutes,
  lockdown_session_minutes_help. Strings re-sorted via
  scripts/sort-strings.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@niccellular
Copy link
Copy Markdown
Member Author

Bumped the proto submodule and threaded a new field through (6ce565f16, 70a413168 + 70cff2424).

Proto bump: 1c625407ffb4bb. Pulls in protobufs#916 which adds LockdownAuth.max_session_seconds (uint32, field 5) — a per-boot uptime cap on the unlocked session. Wire-compatible (proto3 default 0 = unchanged behaviour). Companion firmware change: firmware#10349.

⚠️ The new pointer is on the upstream develop branch — master has not absorbed it yet. If you'd prefer a master-only pointer at merge time, this needs to wait for the develop → master sync.

What it does: when non-zero, the firmware arms an uptime timer at unlock; on expiry it revokes per-connection admin auth, re-engages screen redaction, and reboots — without deleting the token, so the next boot auto-unlocks via the boot-count TTL (decrementing boots_remaining) and arms a fresh session. Total exposure ceiling = boots_remaining * max_session_seconds. The cap uses CPU uptime (millis), not wall-clock, so it's immune to GPS spoofing / RTC backup-battery removal / Faraday cage isolation. Operator-initiated Lock Now still deletes the token outright.

Client wiring:

  • LockdownAuth.max_session_seconds plumbed end-to-end: CommandSenderMeshActionHandlerLockdownCoordinatorRadioController interfaces; all impls; IMeshService AIDL (4th int param on sendLockdownUnlock); MeshService stub; both LockdownPassphraseStore impls (Android EncryptedSharedPreferences and JVM file-backed) with backward-compat for legacy entries (decode to 0).
  • StoredPassphrase gained the field with a default of 0 so existing call sites stay source-compatible.
  • All test fakes updated; existing tests pass unchanged.

UI: a single optional "Session cap (minutes)" field added to LockdownDialog below the boots/hours row. Operators enter minutes for ergonomics; the dialog multiplies by 60 before passing to the coordinator. Blank or 0 = unlimited (firmware default). String resources: lockdown_session_minutes, lockdown_session_minutes_help (re-sorted via scripts/sort-strings.py).

Test coverage: existing unit tests still pass. I have not added new tests for the round-trip of maxSessionSeconds through the coordinator / store. Happy to add if you want parity with the existing boots / hours coverage — just say the word.

🤖 Generated with Claude Code

@jamesarich jamesarich marked this pull request as ready for review May 19, 2026 12:20
# Conflicts:
#	.specify/feature.json
#	AGENTS.md
#	core/proto/src/main/proto
@github-actions
Copy link
Copy Markdown
Contributor

📄 Docs staleness check — advisory

This PR modifies user-facing UI source files but does not update any page under docs/en/user/ or docs/en/developer/.

⚠️ Doc changes propagate to 3 consumers: in-app docs browser, Jekyll site (GitHub Pages), and meshtastic.org (Docusaurus sync). Updating a page in docs/en/ automatically flows to all three.

Changed source files:

core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt
core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt

What to check:

Changed area Likely doc page
feature/messaging/ docs/en/user/messages-and-channels.md
feature/node/ docs/en/user/nodes.md or docs/en/user/node-metrics.md
feature/map/ docs/en/user/map-and-waypoints.md
feature/connections/ docs/en/user/connections.md
feature/settings/ docs/en/user/settings-radio-user.md or docs/en/user/settings-module-admin.md
feature/firmware/ docs/en/user/firmware.md
feature/intro/ docs/en/user/onboarding.md
feature/discovery/ docs/en/user/discovery.md
feature/docs/ Internal docs infrastructure
core/ui/ docs/en/developer/codebase.md or component-specific user pages

New page checklist (if adding a new doc page):

  1. Create the .md file in docs/en/user/ or docs/en/developer/ with last_updated frontmatter
  2. Register in DocBundleLoader.kt with string resources (in-app browser)
  3. Jekyll and Docusaurus sync pick up new pages automatically — no config change needed

If this PR does not require a doc update (e.g., internal refactor, bug fix, test change), add the skip-docs-check label to dismiss this check.

Cross-platform note: This check is advisory while doc coverage matures. Both Android and Apple repos use the same skip-docs-check label and advisory severity. See meshtastic/design standards for shared conventions.

@github-actions
Copy link
Copy Markdown
Contributor

🖼️ Preview staleness check — advisory

This PR modifies UI composables but does not update any *Previews.kt files.

Previews power screenshot tests and in-app docs screenshots. Keeping them current ensures visual regression coverage stays accurate.

Changed UI files:

feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt

What to check:

Pattern Preview file convention
feature/{name}/…/ui/ or component/ feature/{name}/…/*Previews.kt
core/ui/…/ core/ui/…/ (previews colocated)

Adding previews checklist:

  1. Create or update a *Previews.kt file in the same module with @PreviewLightDark
  2. Add @Suppress("PreviewPublic") if the preview is consumed by screenshot-tests
  3. Add corresponding @PreviewTest function in screenshot-tests/src/screenshotTest/
  4. Run ./gradlew :screenshot-tests:updateDebugScreenshotTest to generate reference images

If this PR does not require preview updates (e.g., logic-only change, non-visual refactor), add the skip-preview-check label to dismiss.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request needs-review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants