Skip to content

feat(applock): Add app lock with biometric/device credential authentication#10539

Open
thoomi wants to merge 5 commits into
thunderbird:mainfrom
thoomi:feature/app-lock
Open

feat(applock): Add app lock with biometric/device credential authentication#10539
thoomi wants to merge 5 commits into
thunderbird:mainfrom
thoomi:feature/app-lock

Conversation

@thoomi
Copy link
Copy Markdown

@thoomi thoomi commented Feb 13, 2026

Summary

Adds an App Lock feature that allows users to protect access to Thunderbird using
biometric authentication (fingerprint, face) or device credentials (PIN/pattern/password).

This is a highly requested community feature:

Changes

New modules

  • feature/applock/api - Public contracts (AppLockCoordinator, AppLockGate, state types)
  • feature/applock/impl - Implementation (coordinator, biometric auth, UI overlays, settings)

Architecture

  • Follows API/impl module split
  • Clean Architecture layers: domain (coordinator, authenticator), data (config store), UI (gate, overlays, settings)
  • Koin dependency injection throughout
  • MVI pattern for settings UI

Key design decisions

  • Pull model: Activities call ensureUnlocked() rather than coordinator pushing UI
  • Fail-closed security: If enabled but biometrics unavailable, app blocks access (doesn't silently unlock)
  • Privacy overlays: Plain View overlay (not Compose) for synchronous rendering in task switcher
  • Process death: No state persistence - always re-authenticates (security feature)
  • Authentication-first enable: User must authenticate before enabling (prevents locking themselves out)

Integration

  • Minimal app-common integration via ActivityLifecycleCallbacks
  • Settings entry point added to General Settings > Security
  • No changes to existing feature modules

Screenshots & Videos

Settings UI

General Settings App Lock Disabled App Lock Enabled
applock_settings_security applock_settings_auth_not_enabled applock_settings_auth_enabled
Auth Not Available Timeout Options Biometric Prompt (enable)
applock_settings_auth_not_available applock_settings_timout_options applock_settings_auth_before_enabled

Unlock Flow

applock_standard_biometric_auth
applock_happy_path_flow.mp4

Biometric prompt shown when opening the app

Videos

Enabling app lock (requires authentication first)
applock_settings_enable_flow.mp4
PIN fallback authentication
applock_settings_pin_fallback_flow.mp4
Unavailable: navigate to device settings to enroll
applock_unavailable_to_device_settings_flow.mp4
Full flow: unavailable → enroll biometrics → return to app
applock_unavailable_to_device_settings_biometric_back_to_app_flow.mp4

Testing

  • ~2,000 lines of tests across 11 test files
  • Comprehensive coordinator state machine tests (all transitions, edge cases)
  • Gate overlay tests (visibility, authentication triggering)
  • Settings ViewModel tests
  • Integration callback tests
  • All tests use fakes (no mocking libraries), AssertK assertions, Robolectric

Manual testing checklist

  • Enable app lock in Settings > General > Security
  • Verify biometric/credential prompt appears on app open
  • Verify privacy overlay in task switcher
  • Verify screen-off triggers re-lock
  • Verify timeout setting works (immediate, 1min, 5min, etc.)
  • Verify "unavailable" state when no biometrics enrolled
  • Verify settings link to device biometric settings
  • Test with device rotation during auth prompt

Review

This PR is structured as 5 focused commits that can be reviewed individually. If the overall diff is too large, I'm happy to split this into smaller, incremental PRs — let me know what works best. I just wanted to put it into a one big PR to see how a full implementation would look like.

I also see this PR as a spark for the discussion on the topic. Lets discuss if we want to integrate such a feature at all, since many valid concerns have been raised about it in the past. 😊

Future work

The following enhancements are planned as follow-ups:

  • External intent exception: Returning from external intents (e.g., file picker for attachments, camera) currently re-triggers authentication. These should be exempted when the app initiated the intent.
  • Notification content privacy: Option to hide sensitive notification content (sender, subject, preview) while the app is locked

Disclaimer

Large parts of the PR have been written and reviewed by Coding Agents. I am more like an Android and Kotlin novice but i am of course very eager to learn more about it :)

@wmontwe
Copy link
Copy Markdown
Member

wmontwe commented Mar 3, 2026

@thoomi Sorry for the delay. We’re currently quite busy, and since this isn’t on our immediate roadmap and involves AI-assisted written code, it requires a more thorough review—something we’re unfortunately not able to complete in a timely manner.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an App Lock feature to protect app access using biometrics and/or device credentials, implemented as new feature/applock API + implementation modules and wired into the app via lifecycle callbacks and Settings navigation.

Changes:

  • Added new feature/applock/api (public contracts) and feature/applock/impl (coordinator, biometric auth, overlays, settings UI) modules and included them in the build.
  • Integrated app lock gating into app startup (BaseApplication ActivityLifecycleCallbacks) and added a “Security” entry in General Settings.
  • Added substantial unit/Robolectric/Compose UI test coverage for the coordinator state machine, overlays, settings ViewModel, and lifecycle callback integration.

Reviewed changes

Copilot reviewed 52 out of 52 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
settings.gradle.kts Includes the new feature:applock:api and feature:applock:impl modules.
legacy/ui/legacy/src/test/java/com/fsck/k9/TestApp.kt Adds an AppLockCoordinator binding for legacy test DI.
legacy/ui/legacy/src/main/res/xml/general_settings.xml Adds a “Security” preference entry point.
legacy/ui/legacy/src/main/res/values/strings.xml Adds Security string for the new settings entry.
legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsFragment.kt Wires the “Security” preference click to app lock settings navigation.
legacy/ui/legacy/build.gradle.kts Adds dependency on feature.applock.api for navigation contract injection.
feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsViewModelTest.kt Tests settings MVI/ViewModel behavior.
feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/DefaultAppLockGateTest.kt Robolectric tests for overlay/auth triggering and multi-activity behavior.
feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/AppLockUnavailableOverlayTest.kt Compose UI tests for the “unavailable” overlay.
feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/AppLockFailedOverlayTest.kt Compose UI tests for the “failed” overlay.
feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/MapErrorCodeTest.kt Tests mapping BiometricPrompt error codes to domain errors.
feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/FakeAuthenticator.kt Test fake authenticator implementation.
feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/FakeAppLockCoordinator.kt Test fake coordinator for UI-layer tests.
feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultAppLockCoordinatorTest.kt Comprehensive coordinator state machine tests.
feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/data/AppLockConfigStoreTest.kt Tests SharedPreferences-backed config storage.
feature/applock/impl/src/main/res/values/strings.xml Adds strings for prompts, errors, overlays, and settings UI.
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/DefaultAppLockSettingsNavigation.kt Implements settings navigation contract.
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsViewModel.kt Settings screen ViewModel (enable/auth-first, timeout changes).
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsScreen.kt Compose screen wiring effects (auth request) + lifecycle resume refresh.
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsContract.kt Defines MVI contract for settings UI.
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsContent.kt Compose UI for enable + timeout selection.
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsActivity.kt Hosts the settings screen in a dedicated Activity.
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/DefaultAppLockGate.kt Gate implementation: overlays + auth triggering tied to activity lifecycle.
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/AppLockOverlayContent.kt Compose overlays for failed/unavailable states.
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/FeatureAppLockModule.kt Koin module wiring for coordinator, authenticator, gate factory, settings VM/navigation.
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultBiometricAvailabilityChecker.kt Availability checking via BiometricManager + mapping to unavailable reasons.
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultAppLockLifecycleHandler.kt Process lifecycle + screen-off receiver integration.
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultAppLockCoordinator.kt Coordinator state machine and auth orchestration (pull model).
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/BiometricAuthenticatorFactory.kt Creates BiometricPrompt-backed authenticator with localized strings.
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/BiometricAuthenticator.kt BiometricPrompt authenticator + error mapping + allowed authenticators mask.
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/AppLockLifecycleHandler.kt Abstraction for lifecycle/screen-off registration (testability).
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/AppLockConfigRepository.kt Abstraction for config persistence.
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/AppLockAvailability.kt Abstraction for auth availability/reason checks.
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/data/AppLockPreferences.kt SharedPreferences keys for app lock config.
feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/data/AppLockConfigStore.kt SharedPreferences-backed config store implementation.
feature/applock/impl/src/main/AndroidManifest.xml Declares the non-exported settings activity.
feature/applock/impl/build.gradle.kts Adds module setup + dependencies + Robolectric Android resources for tests.
feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockState.kt Defines app lock state model + unavailable reasons + isUnlocked() helper.
feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockResult.kt Defines AppLockResult alias.
feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockError.kt Defines domain-level authentication errors.
feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockCoordinator.kt Public coordinator contract and threading notes.
feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockConfig.kt Public config model + defaults.
feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockAuthenticator.kt Public authenticator abstraction.
feature/applock/api/src/androidMain/kotlin/net/thunderbird/feature/applock/api/AppLockSettingsNavigation.kt Android navigation contract to open settings.
feature/applock/api/src/androidMain/kotlin/net/thunderbird/feature/applock/api/AppLockGate.kt Android gate contract (lifecycle observer + factory).
feature/applock/api/src/androidMain/kotlin/net/thunderbird/feature/applock/api/AppLockAuthenticatorFactory.kt Android factory contract for activity-bound authenticators.
feature/applock/api/build.gradle.kts Declares the KMP API module and dependencies.
app-common/src/test/kotlin/net/thunderbird/app/common/feature/applock/AppLockActivityLifecycleCallbacksTest.kt Tests lifecycle callback integration (gate created only for FragmentActivity, null-safe).
app-common/src/main/kotlin/net/thunderbird/app/common/feature/applock/AppLockActivityLifecycleCallbacks.kt Registers a per-activity gate via ActivityLifecycleCallbacks.
app-common/src/main/kotlin/net/thunderbird/app/common/feature/AppCommonFeatureModule.kt Includes the app lock DI module in app-common’s feature module.
app-common/src/main/kotlin/net/thunderbird/app/common/BaseApplication.kt Registers AppLockActivityLifecycleCallbacks at application startup.
app-common/build.gradle.kts Adds applock api/impl deps and Android resources for Robolectric tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +217 to +232
private fun resolveWindowBackgroundColor(): Int {
val typedValue = TypedValue()

val isWindowBackgroundColor =
activity.theme.resolveAttribute(android.R.attr.windowBackground, typedValue, true) &&
typedValue.type >= TypedValue.TYPE_FIRST_COLOR_INT &&
typedValue.type <= TypedValue.TYPE_LAST_COLOR_INT

val isColorBackground = !isWindowBackgroundColor &&
activity.theme.resolveAttribute(android.R.attr.colorBackground, typedValue, true)

return when {
isWindowBackgroundColor || isColorBackground -> typedValue.data
else -> Color.BLACK
}
}
Comment on lines +5 to +16
kotlin {
androidLibrary {
namespace = "net.thunderbird.feature.applock.api"
withHostTest {}
}
sourceSets {
commonMain.dependencies {
api(projects.core.outcome)
api(libs.kotlinx.coroutines.core)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Biometrics to open the email app

3 participants